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>
This commit is contained in:
Salihu 2026-10-09 14:49:47 +01:00 committed by GitHub
parent 42e07d8019
commit 2e3fdbbff4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 786 additions and 154 deletions

View file

@ -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",

View file

@ -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 (
<div role="listitem">
<div
className={classes.row}
style={{ paddingInlineStart: 8 + depth * 24 }}
>
{page.hasChildren ? (
<ActionIcon
variant="subtle"
color="gray"
size={22}
className={classes.toggle}
onClick={() => setOpened((value) => !value)}
aria-label={
opened
? t("Collapse {{name}}", { name: title })
: t("Expand {{name}}", { name: title })
}
aria-expanded={opened}
>
<IconChevronRight
size={16}
className={clsx(classes.chevron, opened && classes.chevronOpened)}
/>
</ActionIcon>
) : (
<span className={classes.togglePlaceholder} />
)}
<Link to={pageUrl} className={classes.link} draggable={false}>
<span className={classes.icon} aria-hidden>
{page.icon || <IconFileDescription size={18} stroke={1.5} />}
</span>
<span className={classes.title}>{title}</span>
{updatedAt && (
<time
className={classes.updatedAt}
dateTime={updatedAt.toISOString()}
title={formattedDate(updatedAt)}
>
{shortTimeAgo(updatedAt)}
</time>
)}
</Link>
</div>
{page.hasChildren && (
<Collapse expanded={opened} keepMounted={false}>
{isLoading ? (
<div
className={classes.loading}
style={{ paddingInlineStart: 8 + (depth + 1) * 24 }}
>
{t("Loading…")}
</div>
) : (
<div role="list">
{children.map((child) => (
<SubpageItem
key={child.id}
page={child}
depth={depth + 1}
sortBy={sortBy}
showUpdatedAt={showUpdatedAt}
isPublicSpaceRoute={isPublicSpaceRoute}
shareId={shareId}
spaceSlug={spaceSlug}
/>
))}
</div>
)}
</Collapse>
)}
</div>
);
}

View file

@ -67,7 +67,7 @@ export const SubpagesMenu = React.memo(
updateDelay={0}
shouldShow={shouldShow}
>
<Tooltip position="top" label={t("Delete")}>
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
<ActionIcon
onClick={deleteNode}
variant="default"

View file

@ -1,30 +1,29 @@
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, useLocation, useParams } from "react-router-dom";
import classes from "./subpages.module.css";
import styles from "../mention/mention.module.css";
import {
buildPageUrl,
buildPublicSpaceUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { NodeViewProps, NodeViewWrapper, useEditorState } from "@tiptap/react";
import { SegmentedControl } from "@mantine/core";
import { useId } from "react";
import { useLocation, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
import { useAtomValue } from "jotai";
import type { SubpagesSortBy } from "@docmost/editor-ext";
import { publicSpaceTreeDataAtom } from "@/features/public-space/atoms/public-space-atoms.ts";
import { findSubpagesInTree } from "@/features/share/utils";
import { extractPageSlugId } from "@/lib";
import SubpageItem from "./subpage-item";
import { useSubpages } from "./use-subpages";
import classes from "./subpages.module.css";
export default function SubpagesView(props: NodeViewProps) {
const { editor } = props;
const { editor, node, updateAttributes } = props;
const { spaceSlug, shareId, pageSlug } = useParams();
const { t } = useTranslation();
const location = useLocation();
const headingId = useId();
const isShareRoute = location.pathname.startsWith("/share/");
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
const isPublicView = isShareRoute || isPublicSpaceRoute;
const isEditable = useEditorState({
editor,
selector: (ctx) => 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 (
<NodeViewWrapper data-drag-handle>
<Text c="dimmed" size="md" py="md">
{t("Failed to load subpages")}
</Text>
</NodeViewWrapper>
);
}
if (subpages.length === 0) {
return (
<NodeViewWrapper data-drag-handle>
<div className={classes.container}>
<Text c="dimmed" size="md" py="md">
{t("No subpages")}
</Text>
</div>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper data-drag-handle>
<div className={classes.container}>
<Stack gap={5}>
{subpages.map((page) => (
<Anchor
key={page.id}
component={Link}
fw={500}
to={
shareId
? buildSharedPageUrl({
shareId,
pageSlugId: page.slugId,
pageTitle: page.title,
})
: isPublicSpaceRoute
? buildPublicSpaceUrl({
spaceSlug,
pageSlugId: page.slugId,
pageTitle: page.title,
})
: buildPageUrl(spaceSlug, page.slugId, page.title)
}
underline="never"
className={styles.pageMentionLink}
draggable={false}
>
{page?.icon ? (
<span style={{ marginRight: "4px" }}>{page.icon}</span>
) : (
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
<div className={classes.card}>
<div className={classes.header}>
<div id={headingId} className={classes.heading}>
{t("Subpages")}
{subpages.length > 0 && (
<span className={classes.count}>{subpages.length}</span>
)}
</div>
<span className={styles.pageMentionText}>
{page?.title || t("untitled")}
</span>
</Anchor>
))}
</Stack>
{isEditable && subpages.length > 1 && (
<SegmentedControl<SubpagesSortBy>
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")}
/>
)}
</div>
{subpages.length > 0 ? (
<div role="list" aria-labelledby={headingId} className={classes.list}>
{subpages.map((page) => (
<SubpageItem
key={page.id}
page={page}
depth={0}
sortBy={sortBy}
showUpdatedAt={!isPublicView}
isPublicSpaceRoute={isPublicSpaceRoute}
shareId={shareId}
spaceSlug={spaceSlug}
/>
))}
</div>
) : (
<div className={classes.empty}>
{error ? t("Failed to load subpages") : t("No subpages")}
</div>
)}
</div>
</NodeViewWrapper>
);

View file

@ -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);
}

View file

@ -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"]);
});
});

View file

@ -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);
}

View file

@ -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 }) => (
<QueryClientProvider client={queryClient}>
<JotaiProvider store={store}>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/s/:spaceSlug/p/:pageSlug" element={children} />
<Route path="/share/:shareId/p/:pageSlug" element={children} />
<Route path="/share/p/:pageSlug" element={children} />
<Route path="/docs/:spaceSlug/:pageSlug" element={children} />
</Routes>
</MemoryRouter>
</JotaiProvider>
</QueryClientProvider>
);
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();
});
});

View file

@ -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 };
}

View file

@ -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"] {

View file

@ -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],
);
}

View file

@ -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: [],

View file

@ -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)) {

View file

@ -312,6 +312,7 @@ export class PageService {
'spaceId',
'creatorId',
'isBase',
'updatedAt',
'deletedAt',
])
.select((eb) => this.pageRepo.withHasChildren(eb))

View file

@ -607,6 +607,7 @@ export class PageRepo {
'pages.parentPageId',
'pages.spaceId',
'pages.workspaceId',
'pages.updatedAt',
sql<boolean>`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<boolean>`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<boolean>`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<boolean>`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();

View file

@ -1,2 +1,2 @@
export { Subpages } from "./subpages";
export type { SubpagesAttributes, SubpagesOptions } from "./subpages";
export type { SubpagesAttributes, SubpagesOptions, SubpagesSortBy } from "./subpages";

View file

@ -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<ReturnType> {
@ -26,6 +30,19 @@ export const Subpages = Node.create<SubpagesOptions>({
};
},
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,