diff --git a/apps/client/src/features/editor/components/subpages/subpage-item.tsx b/apps/client/src/features/editor/components/subpages/subpage-item.tsx new file mode 100644 index 000000000..e207e734e --- /dev/null +++ b/apps/client/src/features/editor/components/subpages/subpage-item.tsx @@ -0,0 +1,160 @@ +import { Collapse, Anchor, ActionIcon, Stack } from "@mantine/core"; +import { + IconChevronDown, + IconChevronRight, + IconFileDescription, +} from "@tabler/icons-react"; +import { useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query"; +import { buildPageUrl, buildPublicSpaceUrl, buildSharedPageUrl } from "@/features/page/page.utils"; +import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages"; +import { useTranslation } from "react-i18next"; +import styles from "../mention/mention.module.css"; +import classes from "./subpages.module.css"; +import { + sortSubpages, + type SubpageListItem, + type SubpagesSortBy, +} from "./subpages.utils"; + +interface SubpageItemProps { + page: SubpageListItem; + depth: number; + sortBy: SubpagesSortBy; + isPublicSpaceRoute: boolean; + shareId?: string; + spaceSlug?: string; +} + +export default function SubpageItem({ + page, + depth, + sortBy, + shareId, + spaceSlug, + isPublicSpaceRoute +}: SubpageItemProps) { + const { t } = useTranslation(); + const [opened, setOpened] = useState(false); + + const sharedSubpages = useSharedPageSubpages( + shareId && opened ? page.id : undefined + ); + const { data, isLoading } = useGetSidebarPagesQuery( + !shareId && opened ? { pageId: page.id } : undefined + ); + + const children = useMemo(() => { + if (shareId) { + return sortSubpages( + sharedSubpages.map((node) => ({ + id: node.value, + slugId: node.slugId, + title: node.name, + icon: node.icon, + position: node.position, + hasChildren: node.hasChildren, + })), + sortBy + ); + } + + const pages = data?.pages.flatMap((result) => result.items) || []; + return sortSubpages(pages, sortBy); + }, [data, shareId, sharedSubpages, sortBy]); + + const toggleOpened = () => setOpened((value) => !value); + + return ( +
+
+ {page.hasChildren ? ( + + {opened ? ( + + ) : ( + + )} + + ) : ( + + )} + + + {page.icon ? ( + {page.icon} + ) : ( + + + + )} + + {page.title || t("untitled")} + + +
+ + {page.hasChildren && ( + + + {isLoading ? ( +
+ ...{t("loading")} +
+ ) : ( + children.map((child) => ( + + )) + )} +
+
+ )} +
+ ); +} diff --git a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx index 190893607..489c115c4 100644 --- a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx +++ b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx @@ -1,12 +1,13 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; -import { posToDOMRect, findParentNode } from "@tiptap/react"; +import { posToDOMRect, findParentNode, useEditorState } from "@tiptap/react"; import { Node as PMNode } from "@tiptap/pm/model"; import React, { useCallback, type JSX } from "react"; -import { ActionIcon, Tooltip } from "@mantine/core"; -import { IconTrash } from "@tabler/icons-react"; +import { ActionIcon, Menu, Tooltip } from "@mantine/core"; +import { IconArrowsSort, IconCheck, IconTrash } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { Editor } from "@tiptap/core"; import { isEditorReady } from "@docmost/editor-ext"; +import type { SubpagesSortBy } from "./subpages.utils"; interface SubpagesMenuProps { editor: Editor; @@ -57,6 +58,30 @@ export const SubpagesMenu = React.memo( .run(); }, [editor]); + const editorState = useEditorState({ + editor, + selector: (ctx) => { + if (!ctx.editor) return { sortBy: "default" }; + return { + sortBy: (ctx.editor.getAttributes("subpages").sortBy || + "default"), + }; + }, + }); + + const sortBy = editorState?.sortBy || "default"; + + const updateSort = useCallback( + (value: SubpagesSortBy) => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setSubpagesSortBy(value) + .run(); + }, + [editor], + ); + return ( + + + + + + + + + + {t("Sort by")} + : null} + onClick={() => updateSort("default")} + > + {t("default")} + + : null} + onClick={() => updateSort("title-asc")} + > + {t("Sort A → Z")} + + : null} + onClick={() => updateSort("title-desc")} + > + {t("Sort Z → A")} + + + { // If we're in a shared context, use the shared subpages if (shareId && sharedSubpages) { - return sharedSubpages.map((node) => ({ + return sortSubpages(sharedSubpages.map((node) => ({ id: node.value, slugId: node.slugId, title: node.name, icon: node.icon, position: node.position, - })); + hasChildren: node.hasChildren, + })), sortBy); } if (isPublicSpaceRoute) { @@ -83,14 +83,8 @@ export default function SubpagesView(props: NodeViewProps) { // Otherwise use the API data if (!data?.pages) return []; const allPages = data.pages.flatMap((page) => page.items); - return sortPositionKeys(allPages); - }, [ - data, - shareId, - sharedSubpages, - isPublicSpaceRoute, - publicSpaceSubpages, - ]); + return sortSubpages(allPages, sortBy); + }, [data, shareId, sharedSubpages, isPublicSpaceRoute, publicSpaceSubpages, sortBy]); if (isLoading && !isPublicView) { return null; @@ -121,49 +115,17 @@ export default function SubpagesView(props: NodeViewProps) { return (
- - {subpages.map((page) => ( - + {subpages.map((page: SubpageListItem) => ( + - {page?.icon ? ( - {page.icon} - ) : ( - - - - )} - - - {page?.title || t("untitled")} - - + page={page} + depth={0} + sortBy={sortBy} + shareId={shareId} + spaceSlug={spaceSlug} + isPublicSpaceRoute={isPublicSpaceRoute} + /> ))}
diff --git a/apps/client/src/features/editor/components/subpages/subpages.module.css b/apps/client/src/features/editor/components/subpages/subpages.module.css index fb43eaf6a..f37000ab6 100644 --- a/apps/client/src/features/editor/components/subpages/subpages.module.css +++ b/apps/client/src/features/editor/components/subpages/subpages.module.css @@ -7,3 +7,27 @@ border: none !important; } } + +.item { + width: 100%; +} + +.row { + display: flex; + align-items: center; + min-height: 28px; +} + +.toggle { + flex: 0 0 24px; + margin-right: 2px; +} + +.togglePlaceholder { + display: inline-block; + flex: 0 0 26px; +} + +.icon { + margin-right: 4px; +} diff --git a/apps/client/src/features/editor/components/subpages/subpages.utils.ts b/apps/client/src/features/editor/components/subpages/subpages.utils.ts new file mode 100644 index 000000000..dddecd32e --- /dev/null +++ b/apps/client/src/features/editor/components/subpages/subpages.utils.ts @@ -0,0 +1,34 @@ +import { sortPositionKeys } from "@/features/page/tree/utils/utils"; + +export type SubpagesSortBy = "default" | "title-asc" | "title-desc"; + +export type SubpageListItem = { + id: string; + slugId: string; + title: string; + icon?: string; + position: string; + hasChildren: boolean; +}; + +export function sortSubpages( + items: SubpageListItem[], + sortBy: SubpagesSortBy = "default" +) { + const sortedItems = [...items]; + + if (sortBy === "default") { + return sortPositionKeys(sortedItems); + } + + return sortedItems.sort((a, b) => { + const result = (a.title || "untitled").localeCompare( + b.title || "untitled", + undefined, + { + sensitivity: "base", + } + ); + return sortBy === "title-desc" ? -result : result; + }); +} diff --git a/packages/editor-ext/src/lib/subpages/index.ts b/packages/editor-ext/src/lib/subpages/index.ts index 27a974a67..55f9390e0 100644 --- a/packages/editor-ext/src/lib/subpages/index.ts +++ b/packages/editor-ext/src/lib/subpages/index.ts @@ -1,2 +1,2 @@ export { Subpages } from "./subpages"; -export type { SubpagesAttributes, SubpagesOptions } from "./subpages"; +export type { SubpagesAttributes, SubpagesOptions, SubpagesSortBy } from "./subpages"; diff --git a/packages/editor-ext/src/lib/subpages/subpages.ts b/packages/editor-ext/src/lib/subpages/subpages.ts index 6f5c10620..74330247e 100644 --- a/packages/editor-ext/src/lib/subpages/subpages.ts +++ b/packages/editor-ext/src/lib/subpages/subpages.ts @@ -6,12 +6,17 @@ export interface SubpagesOptions { view: any; } -export interface SubpagesAttributes {} +export type SubpagesSortBy = "default" | "title-asc" | "title-desc"; + +export interface SubpagesAttributes { + sortBy?: SubpagesSortBy; +} declare module "@tiptap/core" { interface Commands { subpages: { insertSubpages: (attributes?: SubpagesAttributes) => ReturnType; + setSubpagesSortBy: (sortBy: SubpagesSortBy) => ReturnType; }; } } @@ -26,6 +31,19 @@ export const Subpages = Node.create({ }; }, + addAttributes() { + return { + sortBy: { + default: "default", + parseHTML: (element: HTMLElement) => + element.getAttribute("data-sort-by") || "default", + renderHTML: (attributes: SubpagesAttributes) => ({ + "data-sort-by": attributes.sortBy || "default", + }), + }, + }; + }, + group: "block", atom: true, draggable: true, @@ -60,6 +78,19 @@ export const Subpages = Node.create({ attrs: attributes, }); }, + + setSubpagesSortBy: + (sortBy) => + ({ commands }) => { + if ( + sortBy !== 'default' && + sortBy !== 'title-asc' && + sortBy !== 'title-desc' + ) { + return; + } + return commands.updateAttributes(this.name, { sortBy }); + }, }; },