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 (
+
{
// 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 });
+ },
};
},