From 73ee6ee8c342b78b9c9119d9a5c4f868dea9ae14 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 31 Aug 2025 18:54:52 +0100 Subject: [PATCH] feat: subpages (child pages) list node (#1462) * feat: subpages list node * disable user-select * support subpages node list in public pages --- .../public/locales/en-US/translation.json | 7 +- .../components/slash-menu/menu-items.ts | 15 ++- .../components/subpages/subpages-menu.tsx | 95 ++++++++++++++ .../components/subpages/subpages-view.tsx | 120 ++++++++++++++++++ .../components/subpages/subpages.module.css | 9 ++ .../features/editor/extensions/extensions.ts | 5 + .../src/features/editor/page-editor.tsx | 2 + .../features/editor/readonly-page-editor.tsx | 13 +- .../src/features/page/queries/page-query.ts | 1 + .../src/features/page/types/page.types.ts | 2 +- .../features/share/atoms/shared-page-atom.ts | 6 + .../features/share/components/share-shell.tsx | 20 ++- .../share/hooks/use-shared-page-subpages.ts | 29 +++++ apps/client/src/pages/share/shared-page.tsx | 1 + .../src/collaboration/collaboration.util.ts | 2 + .../src/core/page/dto/sidebar-page.dto.ts | 8 +- apps/server/src/core/page/page.controller.ts | 29 +++-- packages/editor-ext/src/index.ts | 1 + packages/editor-ext/src/lib/subpages/index.ts | 2 + .../editor-ext/src/lib/subpages/subpages.ts | 68 ++++++++++ 20 files changed, 410 insertions(+), 25 deletions(-) create mode 100644 apps/client/src/features/editor/components/subpages/subpages-menu.tsx create mode 100644 apps/client/src/features/editor/components/subpages/subpages-view.tsx create mode 100644 apps/client/src/features/editor/components/subpages/subpages.module.css create mode 100644 apps/client/src/features/share/atoms/shared-page-atom.ts create mode 100644 apps/client/src/features/share/hooks/use-shared-page-subpages.ts create mode 100644 packages/editor-ext/src/lib/subpages/index.ts create mode 100644 packages/editor-ext/src/lib/subpages/subpages.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index efad41cc..3efcdfec 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -495,5 +495,10 @@ "Page restored successfully": "Page restored successfully", "Deleted by": "Deleted by", "Deleted at": "Deleted at", - "Preview": "Preview" + "Preview": "Preview", + "Subpages": "Subpages", + "Failed to load subpages": "Failed to load subpages", + "No subpages": "No subpages", + "Subpages (Child pages)": "Subpages (Child pages)", + "List all subpages of the current page": "List all subpages of the current page" } diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 42bed5c1..f56d7f04 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -17,8 +17,10 @@ import { IconTable, IconTypography, IconMenu4, - IconCalendar, IconAppWindow, -} from '@tabler/icons-react'; + IconCalendar, + IconAppWindow, + IconSitemap, +} from "@tabler/icons-react"; import { CommandProps, SlashMenuGroupedItemsType, @@ -357,6 +359,15 @@ const CommandGroups: SlashMenuGroupedItemsType = { .run(); }, }, + { + title: "Subpages (Child pages)", + description: "List all subpages of the current page", + searchTerms: ["subpages", "child", "children", "nested", "hierarchy"], + icon: IconSitemap, + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).insertSubpages().run(); + }, + }, { title: "Iframe embed", description: "Embed any Iframe", diff --git a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx new file mode 100644 index 00000000..6cc017e2 --- /dev/null +++ b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx @@ -0,0 +1,95 @@ +import { + BubbleMenu as BaseBubbleMenu, + posToDOMRect, + findParentNode, +} from "@tiptap/react"; +import { Node as PMNode } from "@tiptap/pm/model"; +import React, { useCallback } from "react"; +import { ActionIcon, Tooltip } from "@mantine/core"; +import { IconTrash } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { Editor } from "@tiptap/core"; +import { sticky } from "tippy.js"; + +interface SubpagesMenuProps { + editor: Editor; +} + +interface ShouldShowProps { + state: any; + from?: number; + to?: number; +} + +export const SubpagesMenu = React.memo( + ({ editor }: SubpagesMenuProps): JSX.Element => { + const { t } = useTranslation(); + + const shouldShow = useCallback( + ({ state }: ShouldShowProps) => { + if (!state) { + return false; + } + + return editor.isActive("subpages"); + }, + [editor], + ); + + const getReferenceClientRect = useCallback(() => { + const { selection } = editor.state; + const predicate = (node: PMNode) => node.type.name === "subpages"; + const parent = findParentNode(predicate)(selection); + + if (parent) { + const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; + return dom.getBoundingClientRect(); + } + + return posToDOMRect(editor.view, selection.from, selection.to); + }, [editor]); + + const deleteNode = useCallback(() => { + const { selection } = editor.state; + editor + .chain() + .focus() + .setNodeSelection(selection.from) + .deleteSelection() + .run(); + }, [editor]); + + return ( + + + + + + + + ); + }, +); + +export default SubpagesMenu; diff --git a/apps/client/src/features/editor/components/subpages/subpages-view.tsx b/apps/client/src/features/editor/components/subpages/subpages-view.tsx new file mode 100644 index 00000000..525e2ec9 --- /dev/null +++ b/apps/client/src/features/editor/components/subpages/subpages-view.tsx @@ -0,0 +1,120 @@ +import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { Stack, Text, Anchor, ActionIcon } from "@mantine/core"; +import { IconFileDescription } from "@tabler/icons-react"; +import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query"; +import { useMemo } from "react"; +import { Link, useParams } from "react-router-dom"; +import classes from "./subpages.module.css"; +import styles from "../mention/mention.module.css"; +import { + buildPageUrl, + buildSharedPageUrl, +} from "@/features/page/page.utils.ts"; +import { useTranslation } from "react-i18next"; +import { sortPositionKeys } from "@/features/page/tree/utils/utils"; +import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages"; + +export default function SubpagesView(props: NodeViewProps) { + const { editor } = props; + const { spaceSlug, shareId } = useParams(); + const { t } = useTranslation(); + + const currentPageId = editor.storage.pageId; + + // Get subpages from shared tree if we're in a shared context + const sharedSubpages = useSharedPageSubpages(currentPageId); + + const { data, isLoading, error } = useGetSidebarPagesQuery({ + pageId: currentPageId, + }); + + const subpages = useMemo(() => { + // If we're in a shared context, use the shared subpages + if (shareId && sharedSubpages) { + return sharedSubpages.map((node) => ({ + id: node.value, + slugId: node.slugId, + title: node.name, + icon: node.icon, + position: node.position, + })); + } + + // Otherwise use the API data + if (!data?.pages) return []; + const allPages = data.pages.flatMap((page) => page.items); + return sortPositionKeys(allPages); + }, [data, shareId, sharedSubpages]); + + if (isLoading && !shareId) { + return null; + } + + if (error && !shareId) { + return ( + + + {t("Failed to load subpages")} + + + ); + } + + if (subpages.length === 0) { + return ( + + + + {t("No subpages")} + + + + ); + } + + return ( + + + + {subpages.map((page) => ( + + {page?.icon ? ( + {page.icon} + ) : ( + + + + )} + + + {page?.title || t("untitled")} + + + ))} + + + + ); +} diff --git a/apps/client/src/features/editor/components/subpages/subpages.module.css b/apps/client/src/features/editor/components/subpages/subpages.module.css new file mode 100644 index 00000000..fb43eaf6 --- /dev/null +++ b/apps/client/src/features/editor/components/subpages/subpages.module.css @@ -0,0 +1,9 @@ +.container { + margin: 0; + padding-left: 4px; + user-select: none; + + a { + border: none !important; + } +} diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index adfed7ed..51009b43 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -38,6 +38,7 @@ import { Embed, SearchAndReplace, Mention, + Subpages, TableDndExtension, } from "@docmost/editor-ext"; import { @@ -58,6 +59,7 @@ import CodeBlockView from "@/features/editor/components/code-block/code-block-vi import DrawioView from "../components/drawio/drawio-view"; import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx"; import EmbedView from "@/features/editor/components/embed/embed-view.tsx"; +import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx"; import plaintext from "highlight.js/lib/languages/plaintext"; import powershell from "highlight.js/lib/languages/powershell"; import abap from "highlightjs-sap-abap"; @@ -214,6 +216,9 @@ export const mainExtensions = [ Embed.configure({ view: EmbedView, }), + Subpages.configure({ + view: SubpagesView, + }), MarkdownClipboard.configure({ transformPastedText: true, }), diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 44aa403b..f68f50de 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -31,6 +31,7 @@ import TableMenu from "@/features/editor/components/table/table-menu.tsx"; import ImageMenu from "@/features/editor/components/image/image-menu.tsx"; import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx"; import VideoMenu from "@/features/editor/components/video/video-menu.tsx"; +import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx"; import { handleFileDrop, handlePaste, @@ -391,6 +392,7 @@ export default function PageEditor({ + diff --git a/apps/client/src/features/editor/readonly-page-editor.tsx b/apps/client/src/features/editor/readonly-page-editor.tsx index bd6b9f6b..c1352354 100644 --- a/apps/client/src/features/editor/readonly-page-editor.tsx +++ b/apps/client/src/features/editor/readonly-page-editor.tsx @@ -6,21 +6,19 @@ import { Document } from "@tiptap/extension-document"; import { Heading } from "@tiptap/extension-heading"; import { Text } from "@tiptap/extension-text"; import { Placeholder } from "@tiptap/extension-placeholder"; -import { useAtom } from "jotai/index"; -import { - pageEditorAtom, - readOnlyEditorAtom, -} from "@/features/editor/atoms/editor-atoms.ts"; -import { Editor } from "@tiptap/core"; +import { useAtom } from "jotai"; +import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; interface PageEditorProps { title: string; content: any; + pageId?: string; } export default function ReadonlyPageEditor({ title, content, + pageId, }: PageEditorProps) { const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom); @@ -56,6 +54,9 @@ export default function ReadonlyPageEditor({ content={content} onCreate={({ editor }) => { if (editor) { + if (pageId) { + editor.storage.pageId = pageId; + } // @ts-ignore setReadOnlyEditor(editor); } diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index cfd8b10a..64d03ddd 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -252,6 +252,7 @@ export function useGetSidebarPagesQuery( ): UseInfiniteQueryResult, unknown>> { return useInfiniteQuery({ queryKey: ["sidebar-pages", data], + enabled: !!data?.pageId || !!data?.spaceId, queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }), initialPageParam: 1, getPreviousPageParam: (firstPage) => diff --git a/apps/client/src/features/page/types/page.types.ts b/apps/client/src/features/page/types/page.types.ts index 052dda35..a5078564 100644 --- a/apps/client/src/features/page/types/page.types.ts +++ b/apps/client/src/features/page/types/page.types.ts @@ -60,7 +60,7 @@ export interface ICopyPageToSpace { } export interface SidebarPagesParams { - spaceId: string; + spaceId?: string; pageId?: string; page?: number; // pagination } diff --git a/apps/client/src/features/share/atoms/shared-page-atom.ts b/apps/client/src/features/share/atoms/shared-page-atom.ts new file mode 100644 index 00000000..813f5e61 --- /dev/null +++ b/apps/client/src/features/share/atoms/shared-page-atom.ts @@ -0,0 +1,6 @@ +import { atom } from "jotai"; +import { ISharedPageTree } from "@/features/share/types/share.types"; +import { SharedPageTreeNode } from "@/features/share/utils"; + +export const sharedPageTreeAtom = atom(null); +export const sharedTreeDataAtom = atom(null); \ No newline at end of file diff --git a/apps/client/src/features/share/components/share-shell.tsx b/apps/client/src/features/share/components/share-shell.tsx index 8550f59f..af207f4c 100644 --- a/apps/client/src/features/share/components/share-shell.tsx +++ b/apps/client/src/features/share/components/share-shell.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useMemo } from "react"; import { ActionIcon, Affix, @@ -14,8 +14,10 @@ import SharedTree from "@/features/share/components/shared-tree.tsx"; import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx"; import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; import { ThemeToggle } from "@/components/theme-toggle.tsx"; -import { useAtomValue } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { useAtom } from "jotai"; +import { sharedPageTreeAtom, sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom"; +import { buildSharedPageTree } from "@/features/share/utils"; import { desktopSidebarAtom, mobileSidebarAtom, @@ -59,6 +61,20 @@ export default function ShareShell({ const { shareId } = useParams(); const { data } = useGetSharedPageTreeQuery(shareId); const readOnlyEditor = useAtomValue(readOnlyEditorAtom); + + const setSharedPageTree = useSetAtom(sharedPageTreeAtom); + const setSharedTreeData = useSetAtom(sharedTreeDataAtom); + + // Build and set the tree data when it changes + const treeData = useMemo(() => { + if (!data?.pageTree) return null; + return buildSharedPageTree(data.pageTree); + }, [data?.pageTree]); + + useEffect(() => { + setSharedPageTree(data || null); + setSharedTreeData(treeData); + }, [data, treeData, setSharedPageTree, setSharedTreeData]); return ( { + if (!treeData || !pageId) return []; + + function findSubpages(nodes: SharedPageTreeNode[]): SharedPageTreeNode[] { + for (const node of nodes) { + if (node.value === pageId || node.slugId === pageId) { + return node.children || []; + } + if (node.children && node.children.length > 0) { + const subpages = findSubpages(node.children); + if (subpages.length > 0) { + return subpages; + } + } + } + return []; + } + + return findSubpages(treeData); + }, [treeData, pageId]); +} diff --git a/apps/client/src/pages/share/shared-page.tsx b/apps/client/src/pages/share/shared-page.tsx index 50e5837d..274b76cb 100644 --- a/apps/client/src/pages/share/shared-page.tsx +++ b/apps/client/src/pages/share/shared-page.tsx @@ -52,6 +52,7 @@ export default function SharedPage() { key={data.page.id} title={data.page.title} content={data.page.content} + pageId={data.page.id} /> diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 099d615e..37645f44 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -32,6 +32,7 @@ import { Excalidraw, Embed, Mention, + Subpages, } from '@docmost/editor-ext'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateHTML } from '../common/helpers/prosemirror/html'; @@ -79,6 +80,7 @@ export const tiptapExtensions = [ Excalidraw, Embed, Mention, + Subpages, ] as any; export function jsonToHtml(tiptapJson: any) { diff --git a/apps/server/src/core/page/dto/sidebar-page.dto.ts b/apps/server/src/core/page/dto/sidebar-page.dto.ts index 4ea2bb20..012f64b0 100644 --- a/apps/server/src/core/page/dto/sidebar-page.dto.ts +++ b/apps/server/src/core/page/dto/sidebar-page.dto.ts @@ -1,7 +1,11 @@ -import { IsOptional, IsString } from 'class-validator'; +import { IsOptional, IsString, IsUUID } from 'class-validator'; import { SpaceIdDto } from './page.dto'; -export class SidebarPageDto extends SpaceIdDto { +export class SidebarPageDto { + @IsOptional() + @IsUUID() + spaceId: string; + @IsOptional() @IsString() pageId: string; diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 2f6dcf60..450874f7 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -254,21 +254,28 @@ export class PageController { @Body() pagination: PaginationOptions, @AuthUser() user: User, ) { - const ability = await this.spaceAbility.createForUser(user, dto.spaceId); + if (!dto.spaceId && !dto.pageId) { + throw new BadRequestException( + 'Either spaceId or pageId must be provided', + ); + } + let spaceId = dto.spaceId; + + if (dto.pageId) { + const page = await this.pageRepo.findById(dto.pageId); + if (!page) { + throw new ForbiddenException(); + } + + spaceId = page.spaceId; + } + + const ability = await this.spaceAbility.createForUser(user, spaceId); if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } - let pageId = null; - if (dto.pageId) { - const page = await this.pageRepo.findById(dto.pageId); - if (page.spaceId !== dto.spaceId) { - throw new ForbiddenException(); - } - pageId = page.id; - } - - return this.pageService.getSidebarPages(dto.spaceId, pagination, pageId); + return this.pageService.getSidebarPages(spaceId, pagination, dto.pageId); } @HttpCode(HttpStatus.OK) diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index d3e1d53d..a0efaa1b 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -19,3 +19,4 @@ export * from "./lib/mention"; export * from "./lib/markdown"; export * from "./lib/search-and-replace"; export * from "./lib/embed-provider"; +export * from "./lib/subpages"; diff --git a/packages/editor-ext/src/lib/subpages/index.ts b/packages/editor-ext/src/lib/subpages/index.ts new file mode 100644 index 00000000..27a974a6 --- /dev/null +++ b/packages/editor-ext/src/lib/subpages/index.ts @@ -0,0 +1,2 @@ +export { Subpages } from "./subpages"; +export type { SubpagesAttributes, SubpagesOptions } from "./subpages"; diff --git a/packages/editor-ext/src/lib/subpages/subpages.ts b/packages/editor-ext/src/lib/subpages/subpages.ts new file mode 100644 index 00000000..620f0342 --- /dev/null +++ b/packages/editor-ext/src/lib/subpages/subpages.ts @@ -0,0 +1,68 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; + +export interface SubpagesOptions { + HTMLAttributes: Record; + view: any; +} + +export interface SubpagesAttributes {} + +declare module "@tiptap/core" { + interface Commands { + subpages: { + insertSubpages: (attributes?: SubpagesAttributes) => ReturnType; + }; + } +} + +export const Subpages = Node.create({ + name: "subpages", + + addOptions() { + return { + HTMLAttributes: {}, + view: null, + }; + }, + + group: "block", + atom: true, + draggable: false, + + parseHTML() { + return [ + { + tag: `div[data-type="${this.name}"]`, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes( + { "data-type": this.name }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + ]; + }, + + addCommands() { + return { + insertSubpages: + (attributes) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: attributes, + }); + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(this.options.view); + }, +});