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,