diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index a4dd886b..6e511334 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -1287,5 +1287,12 @@
"Analytics / tracker": "Analytics / tracker",
"Injected verbatim into the
of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.",
"Go to login page": "Go to login page",
- "Move to space": "Move to space"
+ "Move to space": "Move to space",
+ "Switch to tree": "Switch to tree",
+ "Switch to flat list": "Switch to flat list",
+ "Toggle subpages display mode": "Toggle subpages display mode",
+ "Page tree (child pages, recursive)": "Page tree (child pages, recursive)",
+ "Render the full nested tree of all descendant pages": "Render the full nested tree of all descendant pages",
+ "Showing {{count}} subpages_one": "Showing {{count}} subpage",
+ "Showing {{count}} subpages_other": "Showing {{count}} subpages"
}
diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json
index ca14b406..3800ebb3 100644
--- a/apps/client/public/locales/ru-RU/translation.json
+++ b/apps/client/public/locales/ru-RU/translation.json
@@ -1137,5 +1137,13 @@
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
"Dictation language": "Язык диктовки",
"Auto-detect": "Автоопределение",
- "Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью."
+ "Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
+ "Switch to tree": "Переключить на дерево",
+ "Switch to flat list": "Переключить на плоский список",
+ "Toggle subpages display mode": "Переключить режим отображения подстраниц",
+ "Page tree (child pages, recursive)": "Дерево страниц (дочерние, рекурсивно)",
+ "Render the full nested tree of all descendant pages": "Показать полное вложенное дерево всех дочерних страниц",
+ "Showing {{count}} subpages_one": "Показано {{count}} подстраница",
+ "Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
+ "Showing {{count}} subpages_many": "Показано {{count}} подстраниц"
}
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 51af1edd..05f05ea6 100644
--- a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx
+++ b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx
@@ -1,7 +1,7 @@
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, useEffect, useState } from "react";
+import React, { useCallback } from "react";
import { ActionIcon, Group, Tooltip } from "@mantine/core";
import { IconTrash, IconList, IconSitemap } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
@@ -64,24 +64,14 @@ export const SubpagesMenu = React.memo(
.run();
}, [editor]);
- // The component is memoized on `editor` (a stable reference), so reading the
- // attribute at render time would leave the mode icon/tooltip stale right
- // after toggling. Track it in state synced on editor transactions; setState
- // bails when the value is unchanged, so this does not re-render per keystroke.
- const [isRecursive, setIsRecursive] = useState(
- () => editor.getAttributes("subpages")?.recursive ?? false,
- );
- useEffect(() => {
- const sync = () => {
- const value = editor.getAttributes("subpages")?.recursive ?? false;
- setIsRecursive((prev) => (prev === value ? prev : value));
- };
- sync();
- editor.on("transaction", sync);
- return () => {
- editor.off("transaction", sync);
- };
- }, [editor]);
+ // Subscribe to the live `recursive` attribute the standard way (as the
+ // sibling bubble menus do): useEditorState re-renders only when the selected
+ // value actually changes, so the mode icon/tooltip stay current after a
+ // toggle without re-rendering on every keystroke.
+ const isRecursive = useEditorState({
+ editor,
+ selector: (ctx) => ctx.editor?.getAttributes("subpages")?.recursive ?? false,
+ });
return (
(
- pages.map((p) => [
- p.id,
- {
- id: p.id,
- slugId: p.slugId,
- title: p.title,
- icon: p.icon,
- position: p.position,
- children: [],
- },
- ]),
- );
-
- for (const p of pages) {
- const node = byId.get(p.id);
- const parent = p.parentPageId ? byId.get(p.parentPageId) : undefined;
- // Guard against cycles / self-parenting: never attach a node to itself or
- // to the root, and only attach when the parent is actually present.
- if (node && parent && p.id !== rootId) {
- parent.children.push(node);
- }
- }
-
- const sortRecursive = (nodes: SubpageNodeWithPos[]) => {
- const sorted = sortPositionKeys(nodes) as SubpageNodeWithPos[];
- sorted.forEach((n) => sortRecursive(n.children));
- return sorted;
- };
-
- const root = byId.get(rootId);
- return root ? sortRecursive(root.children) : [];
-}
-
-// Map shared-tree nodes (already nested) onto the normalized SubpageNode shape.
-function mapSharedNodes(nodes: SharedPageTreeNode[]): SubpageNode[] {
- return nodes.map((node) => ({
- id: node.value,
- slugId: node.slugId,
- title: node.name,
- icon: node.icon,
- children: node.children ? mapSharedNodes(node.children) : [],
- }));
-}
-
-// Count every descendant in a normalized subtree.
-function countNodes(nodes: SubpageNode[]): number {
- return nodes.reduce((acc, n) => acc + 1 + countNodes(n.children), 0);
-}
-
interface TreeNodeProps {
node: SubpageNode;
depth: number;
@@ -194,7 +130,7 @@ interface SubpagesVariantProps {
currentPageId: string;
shareId?: string;
spaceSlug?: string;
- t: (key: string) => string;
+ t: (key: string, options?: Record) => string;
}
function FlatSubpages({
@@ -368,7 +304,7 @@ function RecursiveSubpages({
{total > LARGE_TREE_THRESHOLD && (
- {t("Showing")} {total} {t("subpages")}
+ {t("Showing {{count}} subpages", { count: total })}
)}
diff --git a/apps/client/src/features/editor/components/subpages/subpages-view.utils.test.ts b/apps/client/src/features/editor/components/subpages/subpages-view.utils.test.ts
new file mode 100644
index 00000000..17760f47
--- /dev/null
+++ b/apps/client/src/features/editor/components/subpages/subpages-view.utils.test.ts
@@ -0,0 +1,114 @@
+import { describe, it, expect } from "vitest";
+import {
+ buildSubtree,
+ countNodes,
+ mapSharedNodes,
+ SubpageNode,
+} from "./subpages-view.utils";
+import { IPage } from "@/features/page/types/page.types";
+
+// Minimal IPage fixture — buildSubtree only reads id/slugId/title/icon/position/
+// parentPageId. `position` keys are fractional-indexing strings (lexicographic).
+const page = (p: Partial & { id: string }): IPage =>
+ ({
+ slugId: `slug-${p.id}`,
+ title: `Title ${p.id}`,
+ icon: undefined,
+ position: "a0",
+ parentPageId: null,
+ ...p,
+ }) as IPage;
+
+const ids = (nodes: SubpageNode[]): string[] => nodes.map((n) => n.id);
+
+describe("buildSubtree", () => {
+ it("nests children under the root and excludes the root itself", () => {
+ const pages = [
+ page({ id: "root" }),
+ page({ id: "a", parentPageId: "root", position: "a0" }),
+ page({ id: "b", parentPageId: "root", position: "a1" }),
+ page({ id: "a1", parentPageId: "a", position: "a0" }),
+ ];
+ const tree = buildSubtree(pages, "root");
+ // Root is not rendered; only its descendants.
+ expect(ids(tree)).toEqual(["a", "b"]);
+ expect(ids(tree[0].children)).toEqual(["a1"]);
+ expect(tree[1].children).toEqual([]);
+ });
+
+ it("sorts each level by position", () => {
+ const pages = [
+ page({ id: "root" }),
+ page({ id: "z", parentPageId: "root", position: "a2" }),
+ page({ id: "x", parentPageId: "root", position: "a0" }),
+ page({ id: "y", parentPageId: "root", position: "a1" }),
+ ];
+ expect(ids(buildSubtree(pages, "root"))).toEqual(["x", "y", "z"]);
+ });
+
+ it("returns [] when the root is absent from the page set", () => {
+ const pages = [page({ id: "a", parentPageId: "missing-root" })];
+ expect(buildSubtree(pages, "missing-root")).toEqual([]);
+ });
+
+ it("silently drops a node whose parent is absent (unreachable parent)", () => {
+ const pages = [
+ page({ id: "root" }),
+ page({ id: "ok", parentPageId: "root" }),
+ page({ id: "orphan", parentPageId: "ghost" }), // parent not in the set
+ ];
+ expect(ids(buildSubtree(pages, "root"))).toEqual(["ok"]);
+ });
+
+ it("guards against self-parenting / attaching the root", () => {
+ const pages = [
+ // A (defensive) self-parented root must not attach to itself.
+ page({ id: "root", parentPageId: "root" }),
+ page({ id: "a", parentPageId: "root" }),
+ ];
+ const tree = buildSubtree(pages, "root");
+ expect(ids(tree)).toEqual(["a"]);
+ });
+
+ it("returns [] for empty input", () => {
+ expect(buildSubtree([], "root")).toEqual([]);
+ });
+});
+
+describe("countNodes", () => {
+ it("counts every descendant across all levels", () => {
+ const tree: SubpageNode[] = [
+ {
+ id: "a",
+ slugId: "s",
+ title: "A",
+ children: [
+ { id: "a1", slugId: "s", title: "A1", children: [] },
+ { id: "a2", slugId: "s", title: "A2", children: [] },
+ ],
+ },
+ { id: "b", slugId: "s", title: "B", children: [] },
+ ];
+ expect(countNodes(tree)).toBe(4);
+ expect(countNodes([])).toBe(0);
+ });
+});
+
+describe("mapSharedNodes", () => {
+ it("remaps value->id / name->title and keeps nested children", () => {
+ const shared = [
+ {
+ value: "p1",
+ slugId: "s1",
+ name: "Parent",
+ icon: "📁",
+ children: [
+ { value: "c1", slugId: "sc1", name: "Child", children: [] },
+ ],
+ },
+ ] as any;
+ const mapped = mapSharedNodes(shared);
+ expect(mapped[0]).toMatchObject({ id: "p1", slugId: "s1", title: "Parent", icon: "📁" });
+ expect(mapped[0].children[0]).toMatchObject({ id: "c1", title: "Child" });
+ });
+});
diff --git a/apps/client/src/features/editor/components/subpages/subpages-view.utils.ts b/apps/client/src/features/editor/components/subpages/subpages-view.utils.ts
new file mode 100644
index 00000000..97843600
--- /dev/null
+++ b/apps/client/src/features/editor/components/subpages/subpages-view.utils.ts
@@ -0,0 +1,83 @@
+import { sortPositionKeys } from "@/features/page/tree/utils/utils";
+import { IPage } from "@/features/page/types/page.types";
+import { SharedPageTreeNode } from "@/features/share/utils";
+
+// Normalized node shared by the flat and recursive subpages renderers so the
+// same link/icon markup works for both API pages and shared-tree nodes.
+export interface SubpageNode {
+ id: string;
+ slugId: string;
+ title: string;
+ icon?: string;
+ children: SubpageNode[];
+}
+
+// Subpage node carrying `position` so each level can be sorted in place.
+export type SubpageNodeWithPos = SubpageNode & {
+ position: string;
+ children: SubpageNodeWithPos[];
+};
+
+/**
+ * Build a nested subtree (the current page's descendants) from the flat `IPage[]`
+ * the `/pages/tree` endpoint returns. Attaches each node to its parent by
+ * `parentPageId`, drops the root itself, and sorts every level by `position`.
+ *
+ * Guards only against SELF-PARENTING and attaching the root (`p.id !== rootId`) —
+ * NOT against multi-node `parentPageId` cycles. Those cannot occur here: the
+ * server rejects cyclic moves, and the recursive `getPageAndDescendants` CTE that
+ * produces this list would itself loop before reaching the client, so the flat
+ * input is acyclic by construction. A node whose `parentPageId` points outside
+ * the result set (an unreachable parent) is silently dropped — it is, by
+ * definition, not a descendant of the root being rendered.
+ */
+export function buildSubtree(pages: IPage[], rootId: string): SubpageNode[] {
+ const byId = new Map(
+ pages.map((p) => [
+ p.id,
+ {
+ id: p.id,
+ slugId: p.slugId,
+ title: p.title,
+ icon: p.icon,
+ position: p.position,
+ children: [],
+ },
+ ]),
+ );
+
+ for (const p of pages) {
+ const node = byId.get(p.id);
+ const parent = p.parentPageId ? byId.get(p.parentPageId) : undefined;
+ if (node && parent && p.id !== rootId) {
+ parent.children.push(node);
+ }
+ }
+
+ const sortRecursive = (
+ nodes: SubpageNodeWithPos[],
+ ): SubpageNodeWithPos[] => {
+ const sorted = sortPositionKeys(nodes) as SubpageNodeWithPos[];
+ sorted.forEach((n) => sortRecursive(n.children));
+ return sorted;
+ };
+
+ const root = byId.get(rootId);
+ return root ? sortRecursive(root.children) : [];
+}
+
+// Map shared-tree nodes (already nested) onto the normalized SubpageNode shape.
+export function mapSharedNodes(nodes: SharedPageTreeNode[]): SubpageNode[] {
+ return nodes.map((node) => ({
+ id: node.value,
+ slugId: node.slugId,
+ title: node.name,
+ icon: node.icon,
+ children: node.children ? mapSharedNodes(node.children) : [],
+ }));
+}
+
+// Count every descendant in a normalized subtree.
+export function countNodes(nodes: SubpageNode[]): number {
+ return nodes.reduce((acc, n) => acc + 1 + countNodes(n.children), 0);
+}