feat(beta): public spaces (#2473)

This commit is contained in:
Philip Okugbe
2026-09-05 11:52:10 +01:00
committed by GitHub
parent f4796c982e
commit 876f3da1b2
117 changed files with 7592 additions and 715 deletions
@@ -1,3 +0,0 @@
import { atom } from "jotai";
export const openSharedTreeNodesAtom = atom<Record<string, boolean>>({});
@@ -1,11 +1,10 @@
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { ISharedPageTree } from "@/features/share/types/share.types";
import { SharedPageTreeNode } from "@/features/share/utils";
export const sharedPageTreeAtom = atom<ISharedPageTree | null>(null);
export const sharedTreeDataAtom = atom<SharedPageTreeNode[] | null>(null);
export const sharedPageFullWidthAtom = atomWithStorage<boolean>(
"sharedPageFullWidth",
false,
);
export const sharedPageTreeAtom = atom<ISharedPageTree | null>(
null as ISharedPageTree | null,
);
export const sharedTreeDataAtom = atom<SharedPageTreeNode[] | null>(
null as SharedPageTreeNode[] | null,
);
@@ -1,9 +0,0 @@
import { atomWithWebStorage } from "@/lib/jotai-helper.ts";
import { atom } from 'jotai';
export const tableOfContentAsideAtom = atomWithWebStorage<boolean>(
"showTOC",
true,
);
export const mobileTableOfContentAsideAtom = atom<boolean>(false);
@@ -1,16 +0,0 @@
import { Affix, Button } from "@mantine/core";
export default function ShareBranding() {
return (
<Affix position={{ bottom: 20, right: 20 }}>
<Button
variant="default"
component="a"
target="_blank"
href="https://docmost.com?ref=public-share"
>
Powered by Docmost
</Button>
</Affix>
);
}
@@ -1,10 +1,67 @@
import { Outlet } from "react-router-dom";
import ShareShell from "@/features/share/components/share-shell.tsx";
import "@fontsource-variable/inter";
import "@/styles/public-typography.css";
import { useEffect, useMemo } from "react";
import { Outlet, useParams } from "react-router-dom";
import { useSetAtom } from "jotai";
import { useGetSharedPageTreeQuery } from "@/features/share/queries/share-query.ts";
import { buildSharedPageTree } from "@/features/share/utils.ts";
import {
sharedPageTreeAtom,
sharedTreeDataAtom,
} from "@/features/share/atoms/shared-page-atom.ts";
import DocsShell from "@/features/public-space/components/docs/docs-shell.tsx";
import { DocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx";
import { useDocsAccent } from "@/features/public-space/theme/docs-theme.ts";
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import { ShareSearchSpotlight } from "@/features/search/components/share-search-spotlight.tsx";
import { shareSearchSpotlight } from "@/features/search/constants";
export default function ShareLayout() {
const { shareId } = useParams();
const { data } = useGetSharedPageTreeQuery(shareId);
// Shares have no appearance settings; apply the default docs accent.
useDocsAccent(undefined);
const setSharedPageTree = useSetAtom(sharedPageTreeAtom);
const setSharedTreeData = useSetAtom(sharedTreeDataAtom);
const treeData = useMemo(() => {
if (!data?.pageTree) return null;
return buildSharedPageTree(data.pageTree);
}, [data?.pageTree]);
useEffect(() => {
setSharedPageTree(data || null);
setSharedTreeData(treeData);
}, [data, treeData, setSharedPageTree, setSharedTreeData]);
const surface = useMemo<DocsSurface>(
() => ({
treeData,
hasSidebar: (data?.pageTree?.length ?? 0) > 1,
getNodeUrl: (node) =>
buildSharedPageUrl({
shareId,
pageSlugId: node.slugId,
pageTitle: node.name,
}),
showBranding: Boolean(data),
showEditPage: true,
brandingRef: "public-share",
}),
[data, treeData, shareId],
);
return (
<ShareShell>
<DocsShell
surface={surface}
onSearchOpen={shareId ? shareSearchSpotlight.open : undefined}
searchSpotlight={
shareId ? <ShareSearchSpotlight shareId={shareId} /> : undefined
}
>
<Outlet />
</ShareShell>
</DocsShell>
);
}
@@ -1,272 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActionIcon,
AppShell,
Group,
ScrollArea,
Tooltip,
} from "@mantine/core";
import { useGetSharedPageTreeQuery } from "@/features/share/queries/share-query.ts";
import { useParams } from "react-router-dom";
import SharedTree from "@/features/share/components/shared-tree.tsx";
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { ThemeToggle } from "@/components/theme-toggle.tsx";
import { useAtomValue, useSetAtom } from "jotai";
import { useAtom } from "jotai";
import {
sharedPageFullWidthAtom,
sharedPageTreeAtom,
sharedTreeDataAtom,
} from "@/features/share/atoms/shared-page-atom";
import { buildSharedPageTree } from "@/features/share/utils";
import {
desktopSidebarAtom,
mobileSidebarAtom,
sidebarWidthAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
import { useTranslation } from "react-i18next";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import {
mobileTableOfContentAsideAtom,
tableOfContentAsideAtom,
} from "@/features/share/atoms/sidebar-atom.ts";
import { IconArrowsHorizontal, IconList } from "@tabler/icons-react";
import { useToggleToc } from "@/features/share/hooks/use-toggle-toc.ts";
import classes from "./share.module.css";
import {
SearchControl,
SearchMobileControl,
} from "@/features/search/components/search-control.tsx";
import { ShareSearchSpotlight } from "@/features/search/components/share-search-spotlight.tsx";
import { shareSearchSpotlight } from "@/features/search/constants";
import ShareBranding from '@/features/share/components/share-branding.tsx';
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
const MemoizedSharedTree = React.memo(SharedTree);
export default function ShareShell({
children,
}: {
children: React.ReactNode;
}) {
const { t } = useTranslation();
const [mobileOpened] = useAtom(mobileSidebarAtom);
const [desktopOpened] = useAtom(desktopSidebarAtom);
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
const [tocOpened] = useAtom(tableOfContentAsideAtom);
const [mobileTocOpened] = useAtom(mobileTableOfContentAsideAtom);
const toggleTocMobile = useToggleToc(mobileTableOfContentAsideAtom);
const toggleToc = useToggleToc(tableOfContentAsideAtom);
const [fullWidth, setFullWidth] = useAtom(sharedPageFullWidthAtom);
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null);
const startResizing = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
}, []);
const stopResizing = useCallback(() => {
setIsResizing(false);
}, []);
const resize = useCallback(
(e: MouseEvent) => {
if (!isResizing || !sidebarRef.current) return;
const newWidth =
e.clientX - sidebarRef.current.getBoundingClientRect().left;
if (newWidth < 220) {
setSidebarWidth(220);
return;
}
if (newWidth > 600) {
setSidebarWidth(600);
return;
}
setSidebarWidth(newWidth);
},
[isResizing, setSidebarWidth],
);
useEffect(() => {
window.addEventListener("mousemove", resize);
window.addEventListener("mouseup", stopResizing);
return () => {
window.removeEventListener("mousemove", resize);
window.removeEventListener("mouseup", stopResizing);
};
}, [resize, stopResizing]);
const { shareId } = useParams();
const { data } = useGetSharedPageTreeQuery(shareId);
const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
// @ts-ignore
const setSharedPageTree = useSetAtom(sharedPageTreeAtom);
// @ts-ignore
const setSharedTreeData = useSetAtom(sharedTreeDataAtom);
// Build and set the tree data when it changes
const treeData = useMemo(() => {
if (!data?.pageTree) return null;
return buildSharedPageTree(data.pageTree);
}, [data?.pageTree]);
useEffect(() => {
setSharedPageTree(data || null);
setSharedTreeData(treeData);
}, [data, treeData, setSharedPageTree, setSharedTreeData]);
return (
<>
<SkipToMain />
<AppShell
header={{ height: 50 }}
{...(data?.pageTree?.length > 1 && {
navbar: {
width: sidebarWidth,
breakpoint: "sm",
collapsed: {
mobile: !mobileOpened,
desktop: !desktopOpened,
},
},
})}
aside={{
width: 300,
breakpoint: "sm",
collapsed: {
mobile: !mobileTocOpened,
desktop: !tocOpened,
},
}}
padding="md"
>
<AppShell.Header>
<Group wrap="nowrap" justify="space-between" py="sm" px="xl">
<Group wrap="nowrap">
{data?.pageTree?.length > 1 && (
<>
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label={t("Sidebar toggle")}
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
</Tooltip>
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label={t("Sidebar toggle")}
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom="sm"
size="sm"
/>
</Tooltip>
</>
)}
</Group>
{shareId && (
<Group visibleFrom="sm">
<SearchControl onClick={shareSearchSpotlight.open} />
</Group>
)}
<Group>
<>
{shareId && (
<Group hiddenFrom="sm">
<SearchMobileControl onSearch={shareSearchSpotlight.open} />
</Group>
)}
<Tooltip label={t("Table of contents")} withArrow>
<ActionIcon
variant="default"
style={{ border: "none" }}
onClick={toggleTocMobile}
hiddenFrom="sm"
size="sm"
aria-label={t("Table of contents")}
>
<IconList size={20} stroke={2} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Table of contents")} withArrow>
<ActionIcon
variant="default"
style={{ border: "none" }}
aria-label={t("Table of contents")}
onClick={toggleToc}
visibleFrom="sm"
size="sm"
>
<IconList size={20} stroke={2} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Full width")} withArrow>
<ActionIcon
variant={fullWidth ? "light" : "default"}
style={fullWidth ? undefined : { border: "none" }}
aria-label={t("Full width")}
aria-pressed={fullWidth}
onClick={() => setFullWidth((v) => !v)}
visibleFrom="sm"
size="sm"
>
<IconArrowsHorizontal size={20} stroke={2} />
</ActionIcon>
</Tooltip>
</>
<ThemeToggle />
</Group>
</Group>
</AppShell.Header>
{data?.pageTree?.length > 1 && (
<AppShell.Navbar p="md" className={classes.navbar} ref={sidebarRef}>
<div
className={classes.resizeHandle}
onMouseDown={startResizing}
/>
<MemoizedSharedTree sharedPageTree={data} />
</AppShell.Navbar>
)}
<AppShell.Main id={MAIN_CONTENT_ID} tabIndex={-1}>
{children}
{data && shareId && !(data.features?.length > 0) && <ShareBranding />}
</AppShell.Main>
<AppShell.Aside
p="md"
withBorder={mobileTocOpened}
className={classes.aside}
>
<ScrollArea style={{ height: "80vh" }} scrollbarSize={5} type="scroll">
<div style={{ paddingBottom: "50px" }}>
{readOnlyEditor && (
<TableOfContents isShare={true} editor={readOnlyEditor} />
)}
</div>
</ScrollArea>
</AppShell.Aside>
<ShareSearchSpotlight shareId={shareId} />
</AppShell>
</>
);
}
@@ -6,39 +6,3 @@
border-bottom: 0.05em solid var(--mantine-color-dark-2);
}
}
.treeNode {
text-decoration: none;
user-select: none;
padding-bottom: 0;
}
.navbar,
.aside {
@media (max-width: $mantine-breakpoint-sm) {
width: 350px;
}
}
.resizeHandle {
width: 3px;
cursor: col-resize;
position: absolute;
right: 0;
top: 0;
bottom: 0;
z-index: 1;
&:hover,
&:active {
width: 5px;
background: light-dark(
var(--mantine-color-gray-4),
var(--mantine-color-dark-5)
);
}
@media (max-width: $mantine-breakpoint-sm) {
display: none;
}
}
@@ -1,220 +0,0 @@
import { ISharedPageTree } from "@/features/share/types/share.types.ts";
import {
buildSharedPageTree,
SharedPageTreeNode,
} from "@/features/share/utils.ts";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Link, useParams } from "react-router-dom";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import clsx from "clsx";
import {
IconChevronDown,
IconChevronRight,
IconFileDescription,
IconPointFilled,
} from "@tabler/icons-react";
import { ActionIcon, Box } from "@mantine/core";
import { extractPageSlugId } from "@/lib";
import classes from "@/features/page/tree/styles/tree.module.css";
import styles from "./share.module.css";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import {
DocTree,
type DocTreeApi,
type RenderRowProps,
} from "@/features/page/tree/components/doc-tree";
import { openSharedTreeNodesAtom } from "@/features/share/atoms/open-shared-tree-nodes-atom";
interface SharedTreeProps {
sharedPageTree: ISharedPageTree;
}
export default function SharedTree({ sharedPageTree }: SharedTreeProps) {
const { t } = useTranslation();
const treeRef = useRef<DocTreeApi | null>(null);
const { pageSlug } = useParams();
const [openTreeNodes, setOpenTreeNodes] = useAtom(openSharedTreeNodesAtom);
const currentNodeId = extractPageSlugId(pageSlug);
const treeData: SharedPageTreeNode[] = useMemo(() => {
if (!sharedPageTree?.pageTree) return [] as SharedPageTreeNode[];
return buildSharedPageTree(sharedPageTree.pageTree);
}, [sharedPageTree?.pageTree]);
const openIds = useMemo(
() =>
new Set(
Object.keys(openTreeNodes).filter((k) => openTreeNodes[k]),
),
[openTreeNodes],
);
useEffect(() => {
// Auto-open the first level of the shared tree on initial load.
const root = treeData?.[0];
if (!root) return;
setOpenTreeNodes((prev) => {
if (prev[root.slugId]) return prev;
const next = { ...prev, [root.slugId]: true };
for (const child of root.children ?? []) {
next[child.slugId] = true;
}
return next;
});
}, [treeData, setOpenTreeNodes]);
useEffect(() => {
if (currentNodeId) {
treeRef.current?.select(currentNodeId, { scrollIntoView: true });
}
}, [currentNodeId, treeData]);
// Stable callbacks so memo(DocTreeRow) actually saves work — see I2 in the
// post-implementation code review.
const handleToggle = useCallback(
(id: string, isOpen: boolean) =>
setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen })),
[setOpenTreeNodes],
);
const getDragLabel = useCallback(
(n: SharedPageTreeNode) => n.name || "untitled",
[],
);
if (!sharedPageTree || !sharedPageTree?.pageTree) {
return null;
}
return (
<div className={classes.treeContainer}>
<DocTree<SharedPageTreeNode>
readOnly
ref={treeRef}
data={treeData}
openIds={openIds}
selectedId={currentNodeId}
renderRow={SharedTreeRow}
onMove={noopMove}
onToggle={handleToggle}
getDragLabel={getDragLabel}
aria-label={t("Pages")}
/>
</div>
);
}
// Module-scope noop so it's a stable reference across renders.
const noopMove = () => {};
function SharedTreeRow({
node,
isOpen,
hasChildren,
isSelected,
rowRef,
tabIndex,
treeItemProps,
toggleOpen,
}: RenderRowProps<SharedPageTreeNode>) {
const { shareId } = useParams();
const { t } = useTranslation();
const [, setMobileSidebarState] = useAtom(mobileSidebarAtom);
const pageUrl = buildSharedPageUrl({
shareId: shareId,
pageSlugId: node.slugId,
pageTitle: node.name,
});
return (
<Box
ref={rowRef as React.Ref<HTMLAnchorElement>}
tabIndex={tabIndex}
{...treeItemProps}
data-selected={isSelected || undefined}
className={clsx(classes.node, styles.treeNode)}
component={Link}
to={pageUrl}
onClick={() => {
setMobileSidebarState(false);
}}
>
<SharedPageArrow
isOpen={isOpen}
hasChildren={hasChildren}
onToggle={toggleOpen}
/>
<div style={{ marginRight: "4px" }}>
<EmojiPicker
onEmojiSelect={() => {}}
icon={
node.icon ? (
node.icon
) : (
<IconFileDescription size="18" />
)
}
readOnly={true}
removeEmojiAction={() => {}}
actionIconProps={{ tabIndex: -1 }}
/>
</div>
<span className={classes.text}>{node.name || t("untitled")}</span>
</Box>
);
}
interface SharedPageArrowProps {
isOpen: boolean;
hasChildren: boolean;
onToggle: () => void;
}
function SharedPageArrow({
isOpen,
hasChildren,
onToggle,
}: SharedPageArrowProps) {
if (!hasChildren) {
return (
<span
aria-hidden
style={{
width: 20,
height: 20,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
color: "var(--mantine-color-gray-6)",
flexShrink: 0,
}}
>
<IconPointFilled size={4} />
</span>
);
}
return (
<ActionIcon
size={20}
variant="subtle"
c="gray"
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggle();
}}
>
{isOpen ? (
<IconChevronDown stroke={2} size={16} />
) : (
<IconChevronRight stroke={2} size={16} />
)}
</ActionIcon>
);
}
@@ -1,29 +1,13 @@
import { useMemo } from "react";
import { useAtomValue } from "jotai";
import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom";
import { SharedPageTreeNode } from "@/features/share/utils";
import { findSubpagesInTree } from "@/features/share/utils";
export function useSharedPageSubpages(pageId: string | undefined) {
const treeData = useAtomValue(sharedTreeDataAtom);
return useMemo(() => {
if (!treeData || !pageId) return [];
function findSubpages(nodes: SharedPageTreeNode[]): SharedPageTreeNode[] {
for (const node of nodes) {
if (node.value === pageId || node.slugId === pageId) {
return node.children || [];
}
if (node.children && node.children.length > 0) {
const subpages = findSubpages(node.children);
if (subpages.length > 0) {
return subpages;
}
}
}
return [];
}
return findSubpages(treeData);
}, [treeData, pageId]);
return useMemo(
() => findSubpagesInTree(treeData, pageId),
[treeData, pageId],
);
}
@@ -1,8 +0,0 @@
import { useAtom } from "jotai";
export function useToggleToc(tocAtom: any) {
const [tocState, setTocState] = useAtom(tocAtom);
return () => {
setTocState(!tocState);
}
}
+21
View File
@@ -68,6 +68,27 @@ export function buildSharedPageTree(
}
// Returns the children of the node matching the page id or slugId.
export function findSubpagesInTree(
tree: SharedPageTreeNode[] | null | undefined,
pageId: string | undefined,
): SharedPageTreeNode[] {
if (!tree || !pageId) return [];
for (const node of tree) {
if (node.value === pageId || node.slugId === pageId) {
return node.children || [];
}
if (node.children && node.children.length > 0) {
const subpages = findSubpagesInTree(node.children, pageId);
if (subpages.length > 0) {
return subpages;
}
}
}
return [];
}
// Recursively checks if a page exists in the shared page tree.
export function isPageInTree(
tree: SharedPageTreeNode[],