From 2e3fdbbff453fbf9265123ef75ea8db42d692287 Mon Sep 17 00:00:00 2001
From: Salihu <91833785+salihudickson@users.noreply.github.com>
Date: Fri, 9 Oct 2026 14:49:47 +0100
Subject: [PATCH] feat: subpages component improvements (#2488)
* expand subpages node functionality
* minor fixes
* minor fix
* refactor: redesign subpages view
* fix link color
---------
Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
---
.../public/locales/en-US/translation.json | 6 +
.../components/subpages/subpage-item.tsx | 138 ++++++++++++
.../components/subpages/subpages-menu.tsx | 2 +-
.../components/subpages/subpages-view.tsx | 196 ++++++------------
.../components/subpages/subpages.module.css | 174 +++++++++++++++-
.../subpages/subpages.utils.test.ts | 80 +++++++
.../components/subpages/subpages.utils.ts | 38 ++++
.../components/subpages/use-subpages.test.tsx | 181 ++++++++++++++++
.../components/subpages/use-subpages.ts | 55 +++++
.../components/docs/docs.module.css | 2 +-
.../share/hooks/use-shared-page-subpages.ts | 13 --
apps/client/src/features/share/utils.ts | 2 +
apps/client/src/lib/time.ts | 25 +++
.../src/core/page/services/page.service.ts | 1 +
.../src/database/repos/page/page.repo.ts | 6 +
packages/editor-ext/src/lib/subpages/index.ts | 2 +-
.../editor-ext/src/lib/subpages/subpages.ts | 19 +-
17 files changed, 786 insertions(+), 154 deletions(-)
create mode 100644 apps/client/src/features/editor/components/subpages/subpage-item.tsx
create mode 100644 apps/client/src/features/editor/components/subpages/subpages.utils.test.ts
create mode 100644 apps/client/src/features/editor/components/subpages/subpages.utils.ts
create mode 100644 apps/client/src/features/editor/components/subpages/use-subpages.test.tsx
create mode 100644 apps/client/src/features/editor/components/subpages/use-subpages.ts
delete mode 100644 apps/client/src/features/share/hooks/use-shared-page-subpages.ts
diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 3dc11c1b..d270c877 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -626,6 +626,10 @@
"Restore this base to view its contents.": "Restore this base to view its contents.",
"Subpages": "Subpages",
"Failed to load subpages": "Failed to load subpages",
+ "Sort subpages": "Sort subpages",
+ "Manual": "Manual",
+ "A–Z": "A–Z",
+ "Recent": "Recent",
"No subpages": "No subpages",
"Subpages (Child pages)": "Subpages (Child pages)",
"List all subpages of the current page": "List all subpages of the current page",
@@ -978,6 +982,8 @@
"Page menu": "Page menu",
"Expand": "Expand",
"Collapse": "Collapse",
+ "Expand {{name}}": "Expand {{name}}",
+ "Collapse {{name}}": "Collapse {{name}}",
"Comment menu": "Comment menu",
"Group menu": "Group menu",
"Show hidden breadcrumbs": "Show hidden breadcrumbs",
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 00000000..d5353689
--- /dev/null
+++ b/apps/client/src/features/editor/components/subpages/subpage-item.tsx
@@ -0,0 +1,138 @@
+import { ActionIcon, Collapse } from "@mantine/core";
+import { IconChevronRight, IconFileDescription } from "@tabler/icons-react";
+import { useState } from "react";
+import { Link } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import clsx from "clsx";
+import type { SubpagesSortBy } from "@docmost/editor-ext";
+import {
+ buildPageUrl,
+ buildPublicSpaceUrl,
+ buildSharedPageUrl,
+} from "@/features/page/page.utils";
+import { formattedDate, shortTimeAgo } from "@/lib/time";
+import { useSubpages } from "./use-subpages";
+import type { SubpageListItem } from "./subpages.utils";
+import classes from "./subpages.module.css";
+
+type SubpageItemProps = {
+ page: SubpageListItem;
+ depth: number;
+ sortBy: SubpagesSortBy;
+ showUpdatedAt: boolean;
+ isPublicSpaceRoute: boolean;
+ shareId?: string;
+ spaceSlug?: string;
+};
+
+export default function SubpageItem({
+ page,
+ depth,
+ sortBy,
+ showUpdatedAt,
+ isPublicSpaceRoute,
+ shareId,
+ spaceSlug,
+}: SubpageItemProps) {
+ const { t } = useTranslation();
+ const [opened, setOpened] = useState(false);
+ const { subpages: children, isLoading } = useSubpages(
+ page.id,
+ sortBy,
+ opened,
+ );
+
+ const title = page.title || t("untitled");
+ const updatedAt =
+ showUpdatedAt && page.updatedAt ? new Date(page.updatedAt) : null;
+
+ const pageUrl = shareId
+ ? buildSharedPageUrl({
+ shareId,
+ pageSlugId: page.slugId,
+ pageTitle: page.title,
+ })
+ : isPublicSpaceRoute
+ ? buildPublicSpaceUrl({
+ spaceSlug,
+ pageSlugId: page.slugId,
+ pageTitle: page.title,
+ })
+ : buildPageUrl(spaceSlug, page.slugId, page.title);
+
+ return (
+
+
+ {page.hasChildren ? (
+
setOpened((value) => !value)}
+ aria-label={
+ opened
+ ? t("Collapse {{name}}", { name: title })
+ : t("Expand {{name}}", { name: title })
+ }
+ aria-expanded={opened}
+ >
+
+
+ ) : (
+
+ )}
+
+
+
+ {page.icon || }
+
+
{title}
+ {updatedAt && (
+
+ )}
+
+
+
+ {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 19089360..2a29a321 100644
--- a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx
+++ b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx
@@ -67,7 +67,7 @@ export const SubpagesMenu = React.memo(
updateDelay={0}
shouldShow={shouldShow}
>
-
+
ctx.editor.isEditable,
+ });
const publicSpaceTreeData = useAtomValue(publicSpaceTreeDataAtom);
@@ -33,7 +32,7 @@ export default function SubpagesView(props: NodeViewProps) {
const routePageId = extractPageSlugId(pageSlug);
let currentPageId = storagePageId;
- if (shareId){
+ if (isShareRoute){
currentPageId = routePageId;
}
@@ -45,127 +44,60 @@ export default function SubpagesView(props: NodeViewProps) {
currentPageId = routePageId ?? publicSpaceTreeData?.[0]?.slugId;
}
- // Get subpages from shared tree if we're in a shared context
- const sharedSubpages = useSharedPageSubpages(currentPageId);
- const publicSpaceSubpages = useMemo(
- () => findSubpagesInTree(publicSpaceTreeData, currentPageId),
- [publicSpaceTreeData, currentPageId],
- );
+ const sortBy: SubpagesSortBy = node.attrs.sortBy || "default";
+ const { subpages, isLoading, error } = useSubpages(currentPageId, sortBy);
- const isPublicView = Boolean(shareId) || isPublicSpaceRoute;
-
- const { data, isLoading, error } = useGetSidebarPagesQuery(
- isPublicView ? null : { 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,
- }));
- }
-
- if (isPublicSpaceRoute) {
- return publicSpaceSubpages.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,
- isPublicSpaceRoute,
- publicSpaceSubpages,
- ]);
-
- if (isLoading && !isPublicView) {
+ if (isLoading) {
return null;
}
- if (error && !isPublicView) {
- return (
-
-
- {t("Failed to load subpages")}
-
-
- );
- }
-
- if (subpages.length === 0) {
- return (
-
-
-
- {t("No subpages")}
-
-
-
- );
- }
-
return (
-
-
- {subpages.map((page) => (
-
- {page?.icon ? (
- {page.icon}
- ) : (
-
-
-
- )}
+
+
+
+ {t("Subpages")}
+ {subpages.length > 0 && (
+ {subpages.length}
+ )}
+
-
- {page?.title || t("untitled")}
-
-
- ))}
-
+ {isEditable && subpages.length > 1 && (
+
+ size="xs"
+ className={classes.sortControl}
+ value={sortBy}
+ onChange={(value) => updateAttributes({ sortBy: value })}
+ data={[
+ { label: t("Manual"), value: "default" },
+ { label: t("A–Z"), value: "title-asc" },
+ { label: t("Recent"), value: "recent" },
+ ]}
+ aria-label={t("Sort subpages")}
+ />
+ )}
+
+
+ {subpages.length > 0 ? (
+
+ {subpages.map((page) => (
+
+ ))}
+
+ ) : (
+
+ {error ? t("Failed to load subpages") : t("No subpages")}
+
+ )}
);
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 fb43eaf6..f3e4f4f8 100644
--- a/apps/client/src/features/editor/components/subpages/subpages.module.css
+++ b/apps/client/src/features/editor/components/subpages/subpages.module.css
@@ -1,9 +1,173 @@
-.container {
- margin: 0;
- padding-left: 4px;
+.card {
+ /* lighter than dimmed but still AA on the header */
+ --subpages-dimmed: light-dark(
+ #6b7280,
+ color-mix(in srgb, var(--mantine-color-dark-1), var(--mantine-color-dark-2))
+ );
+ --subpages-icon: light-dark(
+ var(--mantine-color-gray-6),
+ var(--mantine-color-dark-2)
+ );
+ border: 1px solid
+ light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
+ border-radius: var(--mantine-radius-md);
+ overflow: hidden;
user-select: none;
- a {
- border: none !important;
+ &:hover .sortControl,
+ &:focus-within .sortControl {
+ opacity: 1;
}
}
+
+/* round the selection outline to match the card */
+:global(.ProseMirror .node-subpages) {
+ border-radius: var(--mantine-radius-md);
+}
+
+:global(.ProseMirror-selectednode) .sortControl {
+ opacity: 1;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: var(--mantine-spacing-sm);
+ min-height: rem(44px);
+ padding-block: rem(6px);
+ padding-inline: rem(16px) rem(8px);
+ background-color: light-dark(
+ var(--mantine-color-gray-0),
+ var(--mantine-color-dark-6)
+ );
+ border-bottom: 1px solid
+ light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
+}
+
+.heading {
+ display: flex;
+ align-items: baseline;
+ gap: rem(8px);
+ font-size: var(--mantine-font-size-sm);
+ font-weight: 600;
+ line-height: 1.4;
+}
+
+.count {
+ font-weight: 500;
+ color: var(--subpages-dimmed);
+}
+
+.sortControl {
+ margin-inline-start: auto;
+ opacity: 0;
+ transition: opacity 150ms ease;
+
+ @media (hover: none) {
+ opacity: 1;
+ }
+}
+
+.empty {
+ padding: rem(12px) rem(16px);
+ font-size: var(--mantine-font-size-sm);
+ color: var(--subpages-dimmed);
+}
+
+.list {
+ padding: rem(4px);
+}
+
+.row {
+ display: flex;
+ align-items: center;
+ gap: rem(4px);
+ min-height: rem(36px);
+ padding-inline-end: rem(12px);
+ border-radius: var(--mantine-radius-sm);
+
+ &:hover {
+ background-color: light-dark(
+ var(--mantine-color-gray-0),
+ var(--mantine-color-dark-6)
+ );
+ }
+}
+
+.toggle {
+ flex: none;
+}
+
+.togglePlaceholder {
+ flex: none;
+ width: rem(22px);
+}
+
+.chevron {
+ color: var(--subpages-icon);
+ transition: transform 150ms ease;
+}
+
+.toggle:hover .chevron {
+ color: inherit;
+}
+
+.chevronOpened {
+ transform: rotate(90deg);
+}
+
+.link {
+ display: flex;
+ align-items: center;
+ align-self: stretch;
+ gap: rem(8px);
+ flex: 1;
+ min-width: 0;
+ padding-inline-start: rem(4px);
+}
+
+/* beat the editor's link styles */
+.card .row .link {
+ font-weight: inherit;
+ border-bottom: none;
+}
+
+.icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: none;
+ width: rem(20px);
+ font-size: rem(17px);
+ line-height: 1;
+ color: var(--subpages-icon);
+}
+
+.title {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: rem(15px);
+ line-height: 1.5;
+}
+
+.updatedAt {
+ flex: none;
+ padding-inline-start: rem(12px);
+ font-size: var(--mantine-font-size-xs);
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ color: var(--subpages-dimmed);
+}
+
+.loading {
+ display: flex;
+ align-items: center;
+ min-height: rem(36px);
+ font-size: var(--mantine-font-size-sm);
+ color: var(--subpages-dimmed);
+}
diff --git a/apps/client/src/features/editor/components/subpages/subpages.utils.test.ts b/apps/client/src/features/editor/components/subpages/subpages.utils.test.ts
new file mode 100644
index 00000000..36690168
--- /dev/null
+++ b/apps/client/src/features/editor/components/subpages/subpages.utils.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from "vitest";
+import { sortSubpages, type SubpageListItem } from "./subpages.utils";
+
+// input order differs from every expected order
+const pages: SubpageListItem[] = [
+ {
+ id: "a",
+ slugId: "a",
+ title: "Alpha",
+ position: "a3",
+ hasChildren: false,
+ updatedAt: "2026-10-02T09:00:00.000Z",
+ },
+ { id: "c", slugId: "c", title: "charlie", position: "a2", hasChildren: false },
+ {
+ id: "d",
+ slugId: "d",
+ title: "Delta",
+ position: "a1",
+ hasChildren: true,
+ updatedAt: "2026-10-01T09:00:00.000Z",
+ },
+ {
+ id: "b",
+ slugId: "b",
+ title: "beta",
+ position: "a0",
+ hasChildren: false,
+ updatedAt: "2026-10-03T09:00:00.000Z",
+ },
+];
+
+const ids = (items: SubpageListItem[]) => items.map((item) => item.id);
+
+describe("sortSubpages", () => {
+ it("keeps the manual position order by default", () => {
+ expect(ids(sortSubpages(pages, "default"))).toEqual(["b", "d", "c", "a"]);
+ });
+
+ it("sorts titles A to Z ignoring case", () => {
+ expect(ids(sortSubpages(pages, "title-asc"))).toEqual(["a", "b", "c", "d"]);
+ });
+
+ it("sorts numbers inside titles by value", () => {
+ const chapters = ["Chapter 10", "Chapter 2", "Chapter 1"].map(
+ (title, index) => ({
+ id: title,
+ slugId: title,
+ title,
+ position: `a${index}`,
+ hasChildren: false,
+ }),
+ );
+
+ expect(ids(sortSubpages(chapters, "title-asc"))).toEqual([
+ "Chapter 1",
+ "Chapter 2",
+ "Chapter 10",
+ ]);
+ });
+
+ it("puts the most recently updated first and undated pages last", () => {
+ expect(ids(sortSubpages(pages, "recent"))).toEqual(["b", "a", "d", "c"]);
+ });
+
+ it("falls back to the manual order for an unknown sort value", () => {
+ expect(ids(sortSubpages(pages, "title-desc" as never))).toEqual([
+ "b",
+ "d",
+ "c",
+ "a",
+ ]);
+ });
+
+ it("does not reorder the input array", () => {
+ sortSubpages(pages, "recent");
+ sortSubpages(pages, "default");
+ expect(ids(pages)).toEqual(["a", "c", "d", "b"]);
+ });
+});
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 00000000..cd94e558
--- /dev/null
+++ b/apps/client/src/features/editor/components/subpages/subpages.utils.ts
@@ -0,0 +1,38 @@
+import type { SubpagesSortBy } from "@docmost/editor-ext";
+import { sortPositionKeys } from "@/features/page/tree/utils/utils";
+
+export type SubpageListItem = {
+ id: string;
+ slugId: string;
+ title: string;
+ icon?: string;
+ position: string;
+ hasChildren: boolean;
+ updatedAt?: Date | string;
+};
+
+export function sortSubpages(
+ items: SubpageListItem[],
+ sortBy: SubpagesSortBy,
+): SubpageListItem[] {
+ const sortedItems = [...items];
+
+ if (sortBy === "title-asc") {
+ return sortedItems.sort((a, b) =>
+ (a.title || "").localeCompare(b.title || "", undefined, {
+ sensitivity: "base",
+ numeric: true,
+ }),
+ );
+ }
+
+ if (sortBy === "recent") {
+ return sortedItems.sort(
+ (a, b) =>
+ new Date(b.updatedAt ?? 0).getTime() -
+ new Date(a.updatedAt ?? 0).getTime(),
+ );
+ }
+
+ return sortPositionKeys(sortedItems);
+}
diff --git a/apps/client/src/features/editor/components/subpages/use-subpages.test.tsx b/apps/client/src/features/editor/components/subpages/use-subpages.test.tsx
new file mode 100644
index 00000000..930cab45
--- /dev/null
+++ b/apps/client/src/features/editor/components/subpages/use-subpages.test.tsx
@@ -0,0 +1,181 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createStore, Provider as JotaiProvider } from "jotai";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import type { ReactNode } from "react";
+import type { SubpagesSortBy } from "@docmost/editor-ext";
+import { getSidebarPages } from "@/features/page/services/page-service";
+import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom";
+import { publicSpaceTreeDataAtom } from "@/features/public-space/atoms/public-space-atoms.ts";
+import { buildSharedPageTree } from "@/features/share/utils";
+import { useSubpages } from "./use-subpages";
+
+vi.mock("@/main.tsx", async () => ({
+ queryClient: new (await import("@tanstack/react-query")).QueryClient(),
+}));
+
+vi.mock("@/features/page/services/page-service", () => ({
+ getSidebarPages: vi.fn(),
+}));
+
+const sidebarPage = (
+ id: string,
+ title: string,
+ position: string,
+ updatedAt: string,
+ hasChildren: boolean,
+) => ({
+ id,
+ slugId: `slug-${id}`,
+ title,
+ icon: null,
+ position,
+ parentPageId: "p1",
+ spaceId: "space-1",
+ creatorId: "user-1",
+ isBase: false,
+ deletedAt: null,
+ hasChildren,
+ canEdit: true,
+ updatedAt,
+});
+
+const treePage = (
+ id: string,
+ title: string,
+ position: string,
+ parentPageId: string | null,
+ updatedAt: string,
+) => ({
+ id,
+ slugId: `slug-${id}`,
+ title,
+ icon: null,
+ position,
+ parentPageId,
+ spaceId: "space-1",
+ workspaceId: "workspace-1",
+ isRestricted: false,
+ updatedAt,
+});
+
+const treePages = [
+ treePage("p1", "Parent", "a0", null, "2026-10-01T00:00:00.000Z"),
+ treePage("c1", "Older child", "a0", "p1", "2026-10-02T00:00:00.000Z"),
+ treePage("c2", "Newer child", "a1", "p1", "2026-10-05T00:00:00.000Z"),
+ treePage("g1", "Grandchild", "a0", "c1", "2026-10-03T00:00:00.000Z"),
+];
+
+function renderSubpages(
+ path: string,
+ pageId: string,
+ sortBy: SubpagesSortBy,
+ { enabled = true, store = createStore() } = {},
+) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ return renderHook(() => useSubpages(pageId, sortBy, enabled), { wrapper });
+}
+
+const ids = (items: { id: string }[]) => items.map((item) => item.id);
+
+describe("useSubpages", () => {
+ beforeEach(() => {
+ vi.mocked(getSidebarPages).mockReset();
+ });
+
+ it("loads children of the page from the sidebar API in the app", async () => {
+ vi.mocked(getSidebarPages).mockResolvedValue({
+ items: [
+ sidebarPage("c1", "Older child", "a0", "2026-10-02T00:00:00.000Z", true),
+ sidebarPage("c2", "Newer child", "a1", "2026-10-05T00:00:00.000Z", false),
+ ],
+ meta: {
+ limit: 100,
+ hasNextPage: false,
+ hasPrevPage: false,
+ nextCursor: null,
+ prevCursor: null,
+ },
+ } as never);
+
+ const { result } = renderSubpages("/s/general/p/parent-p1", "p1", "recent");
+
+ await waitFor(() => expect(ids(result.current.subpages)).toEqual(["c2", "c1"]));
+ expect(getSidebarPages).toHaveBeenCalledWith(
+ expect.objectContaining({ pageId: "p1" }),
+ );
+ });
+
+ it("does not request children until enabled", () => {
+ const { result } = renderSubpages("/s/general/p/parent-p1", "p1", "default", {
+ enabled: false,
+ });
+
+ expect(result.current.subpages).toEqual([]);
+ expect(getSidebarPages).not.toHaveBeenCalled();
+ });
+
+ it("reads children from the share tree without calling the API", () => {
+ const store = createStore();
+ store.set(sharedTreeDataAtom, buildSharedPageTree(treePages as never));
+
+ const { result } = renderSubpages("/share/abc/p/parent-slug-p1", "slug-p1", "recent", {
+ store,
+ });
+
+ expect(ids(result.current.subpages)).toEqual(["c2", "c1"]);
+ expect(getSidebarPages).not.toHaveBeenCalled();
+ });
+
+ it("treats a share link without a share key as a share", () => {
+ const store = createStore();
+ store.set(sharedTreeDataAtom, buildSharedPageTree(treePages as never));
+
+ const { result } = renderSubpages("/share/p/parent-slug-p1", "slug-p1", "default", {
+ store,
+ });
+
+ expect(ids(result.current.subpages)).toEqual(["c1", "c2"]);
+ expect(getSidebarPages).not.toHaveBeenCalled();
+ });
+
+ it("reads children from the public docs tree without calling the API", () => {
+ const store = createStore();
+ store.set(publicSpaceTreeDataAtom, buildSharedPageTree(treePages as never));
+
+ const { result } = renderSubpages("/docs/handbook/parent-slug-p1", "p1", "default", {
+ store,
+ });
+
+ expect(
+ result.current.subpages.map(({ id, slugId, title, hasChildren }) => ({
+ id,
+ slugId,
+ title,
+ hasChildren,
+ })),
+ ).toEqual([
+ { id: "c1", slugId: "slug-c1", title: "Older child", hasChildren: true },
+ { id: "c2", slugId: "slug-c2", title: "Newer child", hasChildren: false },
+ ]);
+ expect(getSidebarPages).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/client/src/features/editor/components/subpages/use-subpages.ts b/apps/client/src/features/editor/components/subpages/use-subpages.ts
new file mode 100644
index 00000000..4ba4ba13
--- /dev/null
+++ b/apps/client/src/features/editor/components/subpages/use-subpages.ts
@@ -0,0 +1,55 @@
+import { useMemo } from "react";
+import { useLocation } from "react-router-dom";
+import { useAtomValue } from "jotai";
+import type { SubpagesSortBy } from "@docmost/editor-ext";
+import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
+import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom";
+import { publicSpaceTreeDataAtom } from "@/features/public-space/atoms/public-space-atoms.ts";
+import { findSubpagesInTree } from "@/features/share/utils";
+import { sortSubpages } from "./subpages.utils";
+
+export function useSubpages(
+ pageId: string | undefined,
+ sortBy: SubpagesSortBy,
+ enabled = true,
+) {
+ const { pathname } = useLocation();
+ const isShareRoute = pathname.startsWith("/share/");
+ const isPublicView = isShareRoute || pathname.startsWith("/docs/");
+
+ const sharedTreeData = useAtomValue(sharedTreeDataAtom);
+ const publicSpaceTreeData = useAtomValue(publicSpaceTreeDataAtom);
+
+ const { data, isLoading, error } = useGetSidebarPagesQuery(
+ !isPublicView && enabled ? { pageId } : null,
+ );
+
+ const subpages = useMemo(() => {
+ if (isPublicView) {
+ const tree = isShareRoute ? sharedTreeData : publicSpaceTreeData;
+ const nodes = findSubpagesInTree(tree, pageId).map((node) => ({
+ id: node.value,
+ slugId: node.slugId,
+ title: node.name,
+ icon: node.icon,
+ position: node.position,
+ hasChildren: node.hasChildren,
+ updatedAt: node.updatedAt,
+ }));
+ return sortSubpages(nodes, sortBy);
+ }
+
+ const pages = data?.pages.flatMap((page) => page.items) ?? [];
+ return sortSubpages(pages, sortBy);
+ }, [
+ isPublicView,
+ isShareRoute,
+ sharedTreeData,
+ publicSpaceTreeData,
+ pageId,
+ data,
+ sortBy,
+ ]);
+
+ return { subpages, isLoading, error };
+}
diff --git a/apps/client/src/features/public-space/components/docs/docs.module.css b/apps/client/src/features/public-space/components/docs/docs.module.css
index d9582464..d8fada6b 100644
--- a/apps/client/src/features/public-space/components/docs/docs.module.css
+++ b/apps/client/src/features/public-space/components/docs/docs.module.css
@@ -813,7 +813,7 @@
color: var(--docs-accent);
}
-/* Internal page links (mentions, subpages lists) follow the accent like any
+/* Internal page links (mentions) follow the accent like any
* other link; the app skin pins them to ink with !important, hence the
* counter-!important, and the underline tint softens to match. */
.root :global(.ProseMirror) a[class*="pageMentionLink"] {
diff --git a/apps/client/src/features/share/hooks/use-shared-page-subpages.ts b/apps/client/src/features/share/hooks/use-shared-page-subpages.ts
deleted file mode 100644
index 76b17912..00000000
--- a/apps/client/src/features/share/hooks/use-shared-page-subpages.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { useMemo } from "react";
-import { useAtomValue } from "jotai";
-import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom";
-import { findSubpagesInTree } from "@/features/share/utils";
-
-export function useSharedPageSubpages(pageId: string | undefined) {
- const treeData = useAtomValue(sharedTreeDataAtom);
-
- return useMemo(
- () => findSubpagesInTree(treeData, pageId),
- [treeData, pageId],
- );
-}
diff --git a/apps/client/src/features/share/utils.ts b/apps/client/src/features/share/utils.ts
index 334cc786..4b9bd8c4 100644
--- a/apps/client/src/features/share/utils.ts
+++ b/apps/client/src/features/share/utils.ts
@@ -9,6 +9,7 @@ export type SharedPageTreeNode = {
position: string;
spaceId: string;
parentPageId: string;
+ updatedAt?: Date;
hasChildren: boolean;
children: SharedPageTreeNode[];
label: string;
@@ -32,6 +33,7 @@ export function buildSharedPageTree(
hasChildren: false,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
+ updatedAt: page.updatedAt,
label: page.title || "untitled",
value: page.id,
children: [],
diff --git a/apps/client/src/lib/time.ts b/apps/client/src/lib/time.ts
index a6056dd3..aa12ab34 100644
--- a/apps/client/src/lib/time.ts
+++ b/apps/client/src/lib/time.ts
@@ -9,6 +9,31 @@ export function timeAgo(date: Date) {
});
}
+const RELATIVE_TIME_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
+ ["year", 365 * 86400],
+ ["month", 30 * 86400],
+ ["week", 7 * 86400],
+ ["day", 86400],
+ ["hour", 3600],
+ ["minute", 60],
+];
+
+export function shortTimeAgo(date: Date) {
+ const seconds = (Date.now() - new Date(date).getTime()) / 1000;
+ const unit = RELATIVE_TIME_UNITS.find(([, unitSeconds]) => seconds >= unitSeconds);
+
+ if (!unit) {
+ return new Intl.RelativeTimeFormat(i18n.language, {
+ numeric: "auto",
+ }).format(0, "second");
+ }
+
+ const [name, unitSeconds] = unit;
+ return new Intl.RelativeTimeFormat(i18n.language, {
+ style: "narrow",
+ }).format(-Math.floor(seconds / unitSeconds), name);
+}
+
export function formattedDate(date: Date) {
const locale = getDateFnsLocale();
if (isToday(date)) {
diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts
index fbe8a4d5..6fe9ef5c 100644
--- a/apps/server/src/core/page/services/page.service.ts
+++ b/apps/server/src/core/page/services/page.service.ts
@@ -312,6 +312,7 @@ export class PageService {
'spaceId',
'creatorId',
'isBase',
+ 'updatedAt',
'deletedAt',
])
.select((eb) => this.pageRepo.withHasChildren(eb))
diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts
index bd6f1ee8..1ce93701 100644
--- a/apps/server/src/database/repos/page/page.repo.ts
+++ b/apps/server/src/database/repos/page/page.repo.ts
@@ -607,6 +607,7 @@ export class PageRepo {
'pages.parentPageId',
'pages.spaceId',
'pages.workspaceId',
+ 'pages.updatedAt',
sql`page_access.id IS NOT NULL`.as('isRestricted'),
])
.$if(opts?.includeContent, (qb) => qb.select('pages.content'))
@@ -626,6 +627,7 @@ export class PageRepo {
'p.parentPageId',
'p.spaceId',
'p.workspaceId',
+ 'p.updatedAt',
sql`page_access.id IS NOT NULL`.as('isRestricted'),
])
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
@@ -644,6 +646,7 @@ export class PageRepo {
'parentPageId',
'spaceId',
'workspaceId',
+ 'updatedAt',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
// Filter out restricted pages from the result
@@ -671,6 +674,7 @@ export class PageRepo {
'pages.parentPageId',
'pages.spaceId',
'pages.workspaceId',
+ 'pages.updatedAt',
sql`page_access.id IS NOT NULL`.as('isRestricted'),
])
.where('pages.spaceId', '=', spaceId)
@@ -690,6 +694,7 @@ export class PageRepo {
'p.parentPageId',
'p.spaceId',
'p.workspaceId',
+ 'p.updatedAt',
sql`page_access.id IS NOT NULL`.as('isRestricted'),
])
.where('p.deletedAt', 'is', null)
@@ -706,6 +711,7 @@ export class PageRepo {
'parentPageId',
'spaceId',
'workspaceId',
+ 'updatedAt',
])
.where('isRestricted', '=', false)
.execute();
diff --git a/packages/editor-ext/src/lib/subpages/index.ts b/packages/editor-ext/src/lib/subpages/index.ts
index 27a974a6..55f9390e 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 6f5c1062..d21afb0b 100644
--- a/packages/editor-ext/src/lib/subpages/subpages.ts
+++ b/packages/editor-ext/src/lib/subpages/subpages.ts
@@ -6,7 +6,11 @@ export interface SubpagesOptions {
view: any;
}
-export interface SubpagesAttributes {}
+export type SubpagesSortBy = "default" | "title-asc" | "recent";
+
+export interface SubpagesAttributes {
+ sortBy?: SubpagesSortBy;
+}
declare module "@tiptap/core" {
interface Commands {
@@ -26,6 +30,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,