Files
docmost_custom/apps/client/src/features/page/tree/components/space-tree.tsx
T

738 lines
21 KiB
TypeScript
Raw Normal View History

2025-07-21 21:39:57 +01:00
import {
NodeApi,
NodeRendererProps,
Tree,
TreeApi,
SimpleTree,
} from "react-arborist";
2024-04-21 16:38:59 +01:00
import { atom, useAtom } from "jotai";
2024-04-16 21:55:24 +01:00
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import {
fetchAllAncestorChildren,
2024-04-16 21:55:24 +01:00
useGetRootSidebarPagesQuery,
2024-05-18 03:19:42 +01:00
usePageQuery,
2024-04-16 21:55:24 +01:00
useUpdatePageMutation,
} from "@/features/page/queries/page-query.ts";
2025-04-21 19:24:25 +01:00
import { useEffect, useRef, useState } from "react";
2025-04-22 20:37:32 +01:00
import { Link, useParams } from "react-router-dom";
2024-04-16 21:55:24 +01:00
import classes from "@/features/page/tree/styles/tree.module.css";
2026-02-16 00:33:16 +00:00
import { ActionIcon, Box, Menu, rem, Text } from "@mantine/core";
2023-10-01 01:25:04 +01:00
import {
2025-04-04 23:44:18 +01:00
IconArrowRight,
2023-10-01 01:25:04 +01:00
IconChevronDown,
IconChevronRight,
2025-04-30 14:43:16 +01:00
IconCopy,
2023-10-01 01:25:04 +01:00
IconDotsVertical,
IconFileDescription,
IconFileExport,
2023-10-01 01:25:04 +01:00
IconLink,
IconPlus,
2024-04-16 21:55:24 +01:00
IconPointFilled,
2023-10-01 01:25:04 +01:00
IconTrash,
2024-04-04 22:19:15 +01:00
} from "@tabler/icons-react";
2025-06-09 04:29:27 +01:00
import {
appendNodeChildrenAtom,
treeDataAtom,
} from "@/features/page/tree/atoms/tree-data-atom.ts";
2024-04-04 22:19:15 +01:00
import clsx from "clsx";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
2024-04-16 21:55:24 +01:00
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import {
2024-04-21 16:38:59 +01:00
appendNodeChildren,
2024-04-16 21:55:24 +01:00
buildTree,
2024-04-21 16:38:59 +01:00
buildTreeWithChildren,
2025-06-09 04:29:27 +01:00
mergeRootTrees,
2024-04-16 21:55:24 +01:00
updateTreeNodeIcon,
} from "@/features/page/tree/utils/utils.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
2024-04-21 16:38:59 +01:00
import {
getPageBreadcrumbs,
2025-01-16 12:48:35 +00:00
getPageById,
2024-04-21 16:38:59 +01:00
getSidebarPages,
} from "@/features/page/services/page-service.ts";
import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
2024-04-19 01:57:56 +01:00
import { queryClient } from "@/main.tsx";
2024-04-21 16:38:59 +01:00
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
import { useDisclosure, useElementSize, useMergedRef } from "@mantine/hooks";
import { useClipboard } from "@/hooks/use-clipboard";
2024-04-21 16:38:59 +01:00
import { dfs } from "react-arborist/dist/module/utils";
2024-04-27 15:40:22 +01:00
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
2024-05-31 21:51:44 +01:00
import { buildPageUrl } from "@/features/page/page.utils.ts";
2024-05-18 03:19:42 +01:00
import { notifications } from "@mantine/notifications";
2024-05-31 21:51:44 +01:00
import { getAppUrl } from "@/lib/config.ts";
import { extractPageSlugId } from "@/lib";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
2025-01-04 21:17:17 +08:00
import { useTranslation } from "react-i18next";
2024-11-30 19:47:22 +00:00
import ExportModal from "@/components/common/export-modal";
2025-04-04 23:44:18 +01:00
import MovePageModal from "../../components/move-page-modal.tsx";
2025-04-22 20:37:32 +01:00
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
2025-04-30 14:43:16 +01:00
import CopyPageModal from "../../components/copy-page-modal.tsx";
2025-07-21 21:39:57 +01:00
import { duplicatePage } from "../../services/page-service.ts";
2024-04-16 21:55:24 +01:00
interface SpaceTreeProps {
2024-04-13 17:16:31 +01:00
spaceId: string;
2024-06-03 02:54:12 +01:00
readOnly: boolean;
2024-04-13 17:16:31 +01:00
}
2024-04-21 16:38:59 +01:00
const openTreeNodesAtom = atom<OpenMap>({});
2024-06-03 02:54:12 +01:00
export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
2026-02-16 00:33:16 +00:00
const { t } = useTranslation();
2024-05-31 21:51:44 +01:00
const { pageSlug } = useParams();
2024-04-13 17:16:31 +01:00
const { data, setData, controllers } =
2024-04-16 21:55:24 +01:00
useTreeMutation<TreeApi<SpaceTreeNode>>(spaceId);
const {
data: pagesData,
hasNextPage,
fetchNextPage,
isFetching,
} = useGetRootSidebarPagesQuery({
spaceId,
});
2024-04-21 16:38:59 +01:00
const [, setTreeApi] = useAtom<TreeApi<SpaceTreeNode>>(treeApiAtom);
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
2023-10-26 00:35:49 +01:00
const rootElement = useRef<HTMLDivElement>();
2025-07-21 05:02:40 +01:00
const [isRootReady, setIsRootReady] = useState(false);
2024-04-21 16:38:59 +01:00
const { ref: sizeRef, width, height } = useElementSize();
2025-07-21 05:02:40 +01:00
const mergedRef = useMergedRef((element) => {
rootElement.current = element;
if (element && !isRootReady) {
setIsRootReady(true);
}
}, sizeRef);
2025-04-21 19:24:25 +01:00
const [isDataLoaded, setIsDataLoaded] = useState(false);
2026-02-14 20:00:38 -08:00
const spaceIdRef = useRef(spaceId);
spaceIdRef.current = spaceId;
2024-05-31 21:51:44 +01:00
const { data: currentPage } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
2023-10-15 01:01:17 +01:00
2026-02-14 20:00:38 -08:00
useEffect(() => {
setIsDataLoaded(false);
}, [spaceId]);
2024-04-16 21:55:24 +01:00
useEffect(() => {
if (hasNextPage && !isFetching) {
fetchNextPage();
2023-10-15 01:01:17 +01:00
}
2024-05-31 21:51:44 +01:00
}, [hasNextPage, fetchNextPage, isFetching, spaceId]);
2023-10-01 01:25:04 +01:00
useEffect(() => {
2024-04-16 21:55:24 +01:00
if (pagesData?.pages && !hasNextPage) {
const allItems = pagesData.pages.flatMap((page) => page.items);
const treeData = buildTree(allItems);
2024-07-05 00:41:30 +01:00
2025-06-09 04:29:27 +01:00
setData((prev) => {
// fresh space; full reset
if (prev.length === 0 || prev[0]?.spaceId !== spaceId) {
setIsDataLoaded(true);
setOpenTreeNodes({});
return treeData;
}
// same space; append only missing roots
2026-02-14 20:00:38 -08:00
setIsDataLoaded(true);
2025-06-09 04:29:27 +01:00
return mergeRootTrees(prev, treeData);
});
2024-04-16 21:55:24 +01:00
}
2026-02-14 20:00:38 -08:00
}, [pagesData, hasNextPage, spaceId]);
2023-10-01 01:25:04 +01:00
2024-04-21 16:38:59 +01:00
useEffect(() => {
2026-02-14 20:00:38 -08:00
const effectSpaceId = spaceId;
2024-04-21 16:38:59 +01:00
const fetchData = async () => {
2025-04-21 19:24:25 +01:00
if (isDataLoaded && currentPage) {
2024-04-21 16:38:59 +01:00
// check if pageId node is present in the tree
2024-07-05 00:30:56 +01:00
const node = dfs(treeApiRef.current?.root, currentPage.id);
2024-04-21 16:38:59 +01:00
if (node) {
// if node is found, no need to traverse its ancestors
return;
}
// if not found, fetch and build its ancestors and their children
2024-05-18 03:19:42 +01:00
if (!currentPage.id) return;
const ancestors = await getPageBreadcrumbs(currentPage.id);
2024-04-21 16:38:59 +01:00
2026-02-14 20:00:38 -08:00
if (spaceIdRef.current !== effectSpaceId) return;
2024-04-21 16:38:59 +01:00
if (ancestors && ancestors?.length > 1) {
let flatTreeItems = [...buildTree(ancestors)];
const fetchAndUpdateChildren = async (ancestor: IPage) => {
// we don't want to fetch the children of the opened page
2024-05-18 03:19:42 +01:00
if (ancestor.id === currentPage.id) {
2024-04-21 16:38:59 +01:00
return;
}
const children = await fetchAllAncestorChildren({
2024-04-21 16:38:59 +01:00
pageId: ancestor.id,
spaceId: ancestor.spaceId,
});
flatTreeItems = [
...flatTreeItems,
...children.filter(
(child) => !flatTreeItems.some((item) => item.id === child.id),
2024-04-21 16:38:59 +01:00
),
];
};
const fetchPromises = ancestors.map((ancestor) =>
fetchAndUpdateChildren(ancestor),
2024-04-21 16:38:59 +01:00
);
// Wait for all fetch operations to complete
Promise.all(fetchPromises).then(() => {
2026-02-14 20:00:38 -08:00
if (spaceIdRef.current !== effectSpaceId) return;
2024-04-21 16:38:59 +01:00
// build tree with children
const ancestorsTree = buildTreeWithChildren(flatTreeItems);
// child of root page we're attaching the built ancestors to
const rootChild = ancestorsTree[0];
2026-02-14 20:00:38 -08:00
// attach built ancestors to tree using functional updater
// to avoid stale closure overwriting the current tree data
setData((currentData) =>
appendNodeChildren(currentData, rootChild.id, rootChild.children),
2024-04-21 16:38:59 +01:00
);
setTimeout(() => {
// focus on node and open all parents
2026-02-14 20:00:38 -08:00
treeApiRef.current?.select(currentPage.id);
2024-04-21 16:38:59 +01:00
}, 100);
});
}
}
};
fetchData();
2025-04-21 19:24:25 +01:00
}, [isDataLoaded, currentPage?.id]);
2024-04-21 16:38:59 +01:00
2023-10-19 15:52:32 +01:00
useEffect(() => {
2024-05-22 23:24:57 +01:00
if (currentPage?.id) {
2024-05-31 21:51:44 +01:00
setTimeout(() => {
// focus on node and open all parents
treeApiRef.current?.select(currentPage.id, { align: "auto" });
}, 200);
2024-05-22 23:24:57 +01:00
} else {
treeApiRef.current?.deselectAll();
2024-05-18 03:19:42 +01:00
}
}, [currentPage?.id]);
2024-04-21 16:38:59 +01:00
2025-07-21 05:02:40 +01:00
// Clean up tree API on unmount
2024-04-21 16:38:59 +01:00
useEffect(() => {
2025-07-21 05:02:40 +01:00
return () => {
2024-04-21 16:38:59 +01:00
// @ts-ignore
2025-07-21 05:02:40 +01:00
setTreeApi(null);
};
}, [setTreeApi]);
2024-04-21 16:38:59 +01:00
2026-02-16 00:33:16 +00:00
const filteredData = data.filter((node) => node?.spaceId === spaceId);
2023-10-01 01:25:04 +01:00
return (
2024-04-21 16:38:59 +01:00
<div ref={mergedRef} className={classes.treeContainer}>
2026-02-16 00:33:16 +00:00
{isDataLoaded && filteredData.length === 0 && (
<Text size="xs" c="dimmed" py="xs" px="sm">
{t("No pages yet")}
</Text>
)}
2025-07-21 05:02:40 +01:00
{isRootReady && rootElement.current && (
2024-05-20 01:08:31 +01:00
<Tree
2026-02-16 00:33:16 +00:00
data={filteredData}
disableDrag={
readOnly
? true
: (data) => {
return data.canEdit === false;
}
}
disableDrop={
readOnly
? true
: ({ parentNode }) => parentNode?.data?.canEdit === false
}
disableEdit={readOnly ? true : (data) => data.canEdit === false}
2024-05-20 01:08:31 +01:00
{...controllers}
width={width}
2024-11-01 10:41:23 +01:00
height={rootElement.current.clientHeight}
2025-07-21 05:02:40 +01:00
ref={(ref) => {
treeApiRef.current = ref;
if (ref) {
//@ts-ignore
setTreeApi(ref);
}
}}
2024-05-20 01:08:31 +01:00
openByDefault={false}
disableMultiSelection={true}
className={classes.tree}
rowClassName={classes.row}
rowHeight={30}
overscanCount={10}
dndRootElement={rootElement.current}
onToggle={() => {
setOpenTreeNodes(treeApiRef.current?.openState);
2024-05-20 01:08:31 +01:00
}}
initialOpenState={openTreeNodes}
>
{Node}
</Tree>
)}
2023-10-01 01:25:04 +01:00
</div>
);
}
2024-04-21 16:38:59 +01:00
function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
2025-04-22 20:37:32 +01:00
const { t } = useTranslation();
2024-01-22 16:39:20 +01:00
const updatePageMutation = useUpdatePageMutation();
const [treeData, setTreeData] = useAtom(treeDataAtom);
const [, appendChildren] = useAtom(appendNodeChildrenAtom);
2024-04-27 15:40:22 +01:00
const emit = useQueryEmit();
2024-05-31 21:51:44 +01:00
const { spaceSlug } = useParams();
2025-01-16 12:48:35 +00:00
const timerRef = useRef(null);
2025-04-22 20:37:32 +01:00
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
2025-01-16 12:48:35 +00:00
const prefetchPage = () => {
timerRef.current = setTimeout(async () => {
const page = await queryClient.fetchQuery({
queryKey: ["pages", node.data.id],
queryFn: () => getPageById({ pageId: node.data.id }),
2025-01-16 12:48:35 +00:00
staleTime: 5 * 60 * 1000,
});
if (page?.slugId) {
queryClient.setQueryData(["pages", page.slugId], page);
}
2025-01-16 12:48:35 +00:00
}, 150);
};
const cancelPagePrefetch = () => {
if (timerRef.current) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
};
2023-10-19 15:52:32 +01:00
2024-04-16 21:55:24 +01:00
async function handleLoadChildren(node: NodeApi<SpaceTreeNode>) {
if (!node.data.hasChildren) return;
// in conflict with use-query-subscription.ts => case "addTreeNode","moveTreeNode" etc with websocket
// if (node.data.children && node.data.children.length > 0) {
// return;
// }
2024-04-16 21:55:24 +01:00
try {
const params: SidebarPagesParams = {
pageId: node.data.id,
spaceId: node.data.spaceId,
};
const childrenTree = await fetchAllAncestorChildren(params);
2024-04-16 21:55:24 +01:00
appendChildren({
parentId: node.data.id,
children: childrenTree,
});
2024-04-16 21:55:24 +01:00
} catch (error) {
console.error("Failed to fetch children:", error);
}
}
const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => {
2024-04-21 16:38:59 +01:00
const updatedTree = updateTreeNodeIcon(treeData, nodeId, newIcon);
2024-04-16 21:55:24 +01:00
setTreeData(updatedTree);
2024-01-22 16:39:20 +01:00
};
2024-04-21 16:38:59 +01:00
const handleEmojiIconClick = (e: any) => {
2024-01-22 16:39:20 +01:00
e.preventDefault();
e.stopPropagation();
2024-04-04 22:19:15 +01:00
};
2024-01-22 16:39:20 +01:00
2024-04-16 21:55:24 +01:00
const handleEmojiSelect = (emoji: { native: string }) => {
2024-01-22 16:39:20 +01:00
handleUpdateNodeIcon(node.id, emoji.native);
2025-06-09 04:29:27 +01:00
updatePageMutation
.mutateAsync({ pageId: node.id, icon: emoji.native })
.then((data) => {
setTimeout(() => {
emit({
operation: "updateOne",
spaceId: node.data.spaceId,
entity: ["pages"],
id: node.id,
payload: { icon: emoji.native, parentPageId: data.parentPageId },
});
}, 50);
});
2024-01-22 16:39:20 +01:00
};
2024-01-22 17:36:41 +01:00
const handleRemoveEmoji = () => {
handleUpdateNodeIcon(node.id, null);
2024-04-13 17:16:31 +01:00
updatePageMutation.mutateAsync({ pageId: node.id, icon: null });
2024-04-27 15:40:22 +01:00
setTimeout(() => {
emit({
operation: "updateOne",
2024-11-28 18:53:29 +00:00
spaceId: node.data.spaceId,
2024-04-27 15:40:22 +01:00
entity: ["pages"],
id: node.id,
payload: { icon: null },
});
}, 50);
2024-01-22 17:36:41 +01:00
};
2024-05-20 01:08:31 +01:00
if (
node.willReceiveDrop &&
node.isClosed &&
(node.children.length > 0 || node.data.hasChildren)
) {
2024-04-16 21:55:24 +01:00
handleLoadChildren(node);
2023-10-19 15:52:32 +01:00
setTimeout(() => {
2024-05-20 01:08:31 +01:00
if (node.state.willReceiveDrop) {
node.open();
}
2023-10-19 15:52:32 +01:00
}, 650);
}
2025-04-22 20:37:32 +01:00
const pageUrl = buildPageUrl(spaceSlug, node.data.slugId, node.data.name);
2023-10-01 01:25:04 +01:00
return (
<>
2025-04-22 20:37:32 +01:00
<Box
2023-10-01 01:25:04 +01:00
style={style}
2023-10-30 14:53:49 +00:00
className={clsx(classes.node, node.state)}
2025-04-22 20:37:32 +01:00
component={Link}
to={pageUrl}
// @ts-ignore
2023-10-01 01:25:04 +01:00
ref={dragHandle}
2025-04-22 20:37:32 +01:00
onClick={() => {
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
2025-01-16 12:48:35 +00:00
onMouseEnter={prefetchPage}
onMouseLeave={cancelPagePrefetch}
2023-10-01 01:25:04 +01:00
>
2024-04-16 21:55:24 +01:00
<PageArrow node={node} onExpandTree={() => handleLoadChildren(node)} />
2023-10-01 01:25:04 +01:00
2024-04-04 22:19:15 +01:00
<div onClick={handleEmojiIconClick} style={{ marginRight: "4px" }}>
<EmojiPicker
onEmojiSelect={handleEmojiSelect}
icon={
node.data.icon ? (
node.data.icon
) : (
2024-06-01 14:11:00 +01:00
<IconFileDescription size="18" />
2024-04-04 22:19:15 +01:00
)
}
readOnly={
tree.props.disableEdit === true || node.data.canEdit === false
}
2024-04-04 22:19:15 +01:00
removeEmojiAction={handleRemoveEmoji}
/>
2024-01-22 16:39:20 +01:00
</div>
2023-10-01 01:25:04 +01:00
2025-04-04 23:44:18 +01:00
<span className={classes.text}>{node.data.name || t("untitled")}</span>
2023-10-01 01:25:04 +01:00
2023-10-30 14:53:49 +00:00
<div className={classes.actions}>
2025-07-21 21:39:57 +01:00
<NodeMenu node={node} treeApi={tree} spaceId={node.data.spaceId} />
2024-06-03 02:54:12 +01:00
{tree.props.disableEdit !== true && node.data.canEdit !== false && (
2024-06-03 02:54:12 +01:00
<CreateNode
node={node}
treeApi={tree}
onExpandTree={() => handleLoadChildren(node)}
/>
)}
2023-10-01 01:25:04 +01:00
</div>
2025-04-22 20:37:32 +01:00
</Box>
2023-10-01 01:25:04 +01:00
</>
);
}
2024-04-16 21:55:24 +01:00
interface CreateNodeProps {
node: NodeApi<SpaceTreeNode>;
2024-04-21 16:38:59 +01:00
treeApi: TreeApi<SpaceTreeNode>;
2024-04-16 21:55:24 +01:00
onExpandTree?: () => void;
}
2024-05-18 03:19:42 +01:00
2024-04-21 16:38:59 +01:00
function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
2023-10-01 01:25:04 +01:00
function handleCreate() {
2024-04-16 21:55:24 +01:00
if (node.data.hasChildren && node.children.length === 0) {
node.toggle();
onExpandTree();
setTimeout(() => {
treeApi?.create({ type: "internal", parentId: node.id, index: 0 });
}, 500);
} else {
treeApi?.create({ type: "internal", parentId: node.id });
}
2023-10-01 01:25:04 +01:00
}
return (
2024-04-04 22:19:15 +01:00
<ActionIcon
variant="transparent"
c="gray"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCreate();
}}
>
2023-10-01 01:25:04 +01:00
<IconPlus style={{ width: rem(20), height: rem(20) }} stroke={2} />
</ActionIcon>
);
}
2024-04-21 16:38:59 +01:00
interface NodeMenuProps {
node: NodeApi<SpaceTreeNode>;
treeApi: TreeApi<SpaceTreeNode>;
2025-07-21 21:39:57 +01:00
spaceId: string;
2024-04-21 16:38:59 +01:00
}
2024-05-18 03:19:42 +01:00
2025-07-21 21:39:57 +01:00
function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
2025-01-04 21:17:17 +08:00
const { t } = useTranslation();
2024-05-18 03:19:42 +01:00
const clipboard = useClipboard({ timeout: 500 });
2024-05-31 21:51:44 +01:00
const { spaceSlug } = useParams();
const { openDeleteModal } = useDeletePageModal();
2025-07-21 21:39:57 +01:00
const [data, setData] = useAtom(treeDataAtom);
const emit = useQueryEmit();
2024-11-30 19:47:22 +00:00
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
2025-04-04 23:44:18 +01:00
const [
movePageModalOpened,
{ open: openMovePageModal, close: closeMoveSpaceModal },
] = useDisclosure(false);
2025-04-30 14:43:16 +01:00
const [
copyPageModalOpened,
{ open: openCopyPageModal, close: closeCopySpaceModal },
] = useDisclosure(false);
2024-05-18 03:19:42 +01:00
const handleCopyLink = () => {
2024-05-31 21:51:44 +01:00
const pageUrl =
getAppUrl() + buildPageUrl(spaceSlug, node.data.slugId, node.data.name);
clipboard.copy(pageUrl);
2025-01-04 21:17:17 +08:00
notifications.show({ message: t("Link copied") });
2024-05-18 03:19:42 +01:00
};
2025-07-21 21:39:57 +01:00
const handleDuplicatePage = async () => {
try {
const duplicatedPage = await duplicatePage({
pageId: node.id,
});
// Find the index of the current node
const parentId =
node.parent?.id === "__REACT_ARBORIST_INTERNAL_ROOT__"
? null
: node.parent?.id;
const siblings = parentId ? node.parent.children : treeApi?.props.data;
const currentIndex =
siblings?.findIndex((sibling) => sibling.id === node.id) || 0;
const newIndex = currentIndex + 1;
// Add the duplicated page to the tree
const treeNodeData: SpaceTreeNode = {
id: duplicatedPage.id,
slugId: duplicatedPage.slugId,
name: duplicatedPage.title,
position: duplicatedPage.position,
spaceId: duplicatedPage.spaceId,
parentPageId: duplicatedPage.parentPageId,
icon: duplicatedPage.icon,
hasChildren: duplicatedPage.hasChildren,
canEdit: true,
2025-07-21 21:39:57 +01:00
children: [],
};
// Update local tree
const simpleTree = new SimpleTree(data);
simpleTree.create({
parentId,
index: newIndex,
data: treeNodeData,
});
setData(simpleTree.data);
// Emit socket event
setTimeout(() => {
emit({
operation: "addTreeNode",
spaceId: spaceId,
payload: {
parentId,
index: newIndex,
data: treeNodeData,
},
});
}, 50);
notifications.show({
message: t("Page duplicated successfully"),
});
} catch (err) {
notifications.show({
message: err.response?.data.message || "An error occurred",
color: "red",
});
}
};
2023-10-01 01:25:04 +01:00
return (
2024-11-30 19:47:22 +00:00
<>
<Menu shadow="md" width={200}>
<Menu.Target>
<ActionIcon
variant="transparent"
c="gray"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<IconDotsVertical
style={{ width: rem(20), height: rem(20) }}
stroke={2}
/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLink size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopyLink();
}}
>
2025-01-04 21:17:17 +08:00
{t("Copy link")}
2024-11-30 19:47:22 +00:00
</Menu.Item>
<Menu.Item
leftSection={<IconFileExport size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openExportModal();
}}
>
2025-01-04 21:17:17 +08:00
{t("Export page")}
2024-11-30 19:47:22 +00:00
</Menu.Item>
{treeApi.props.disableEdit !== true &&
node.data.canEdit !== false && (
<>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDuplicatePage();
}}
>
{t("Duplicate")}
</Menu.Item>
<Menu.Item
leftSection={<IconArrowRight size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openMovePageModal();
}}
>
{t("Move")}
</Menu.Item>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openCopyPageModal();
}}
>
{t("Copy to space")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
c="red"
leftSection={<IconTrash size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
}}
>
{t("Move to trash")}
</Menu.Item>
</>
)}
2024-11-30 19:47:22 +00:00
</Menu.Dropdown>
</Menu>
2025-04-04 23:44:18 +01:00
<MovePageModal
pageId={node.id}
slugId={node.data.slugId}
currentSpaceSlug={spaceSlug}
onClose={closeMoveSpaceModal}
open={movePageModalOpened}
/>
2025-04-30 14:43:16 +01:00
<CopyPageModal
pageId={node.id}
currentSpaceSlug={spaceSlug}
onClose={closeCopySpaceModal}
open={copyPageModalOpened}
/>
2024-11-30 19:47:22 +00:00
<ExportModal
type="page"
id={node.id}
open={exportOpened}
onClose={closeExportModal}
/>
</>
2023-10-01 01:25:04 +01:00
);
}
2024-04-16 21:55:24 +01:00
interface PageArrowProps {
node: NodeApi<SpaceTreeNode>;
onExpandTree?: () => void;
}
2024-05-18 03:19:42 +01:00
2024-04-16 21:55:24 +01:00
function PageArrow({ node, onExpandTree }: PageArrowProps) {
useEffect(() => {
2025-06-09 04:29:27 +01:00
if (node.isOpen) {
onExpandTree();
}
}, []);
2023-10-01 01:25:04 +01:00
return (
2024-04-04 22:19:15 +01:00
<ActionIcon
size={20}
variant="subtle"
c="gray"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
node.toggle();
2024-04-16 21:55:24 +01:00
onExpandTree();
2024-04-04 22:19:15 +01:00
}}
>
2023-10-01 01:25:04 +01:00
{node.isInternal ? (
2024-04-16 21:55:24 +01:00
node.children && (node.children.length > 0 || node.data.hasChildren) ? (
2023-10-01 01:25:04 +01:00
node.isOpen ? (
2023-10-26 23:36:01 +01:00
<IconChevronDown stroke={2} size={18} />
2023-10-01 01:25:04 +01:00
) : (
2023-10-26 23:36:01 +01:00
<IconChevronRight stroke={2} size={18} />
2023-10-01 01:25:04 +01:00
)
) : (
2024-04-16 21:55:24 +01:00
<IconPointFilled size={8} />
2023-10-01 01:25:04 +01:00
)
) : null}
2023-10-26 23:36:01 +01:00
</ActionIcon>
2023-10-01 01:25:04 +01:00
);
}