From a12bf420789e81557b10c9801c426e8721402a96 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:25:50 +0100 Subject: [PATCH] feat(beta): public spaces --- apps/client/package.json | 1 + .../public/locales/en-US/translation.json | 79 +- apps/client/src/App.tsx | 18 + apps/client/src/ee/features.ts | 1 + .../space-public-sharing-toggle.tsx | 1 + .../space-viewer-comments-toggle.tsx | 1 + .../editor/components/audio/audio-menu.tsx | 3 + .../components/callout/callout-menu.tsx | 4 +- .../components/columns/columns-menu.tsx | 3 + .../editor/components/drawio/drawio-menu.tsx | 3 + .../editor/components/image/image-menu.tsx | 3 + .../editor/components/link/link-view.tsx | 85 +- .../components/mention/mention-view.tsx | 75 +- .../editor/components/pdf/pdf-menu.tsx | 3 + .../components/subpages/subpages-menu.tsx | 3 + .../components/subpages/subpages-view.tsx | 56 +- .../table-of-contents/table-of-contents.tsx | 2 +- .../transclusion-lookup-context.tsx | 17 +- .../editor/components/video/video-menu.tsx | 3 + .../features/editor/readonly-page-editor.tsx | 28 +- .../page/components/header/page-header.tsx | 51 +- apps/client/src/features/page/page.utils.ts | 13 + .../page/tree/components/doc-tree-row.tsx | 5 +- .../page/tree/components/doc-tree.tsx | 43 +- .../public-space/atoms/public-space-atoms.ts | 17 + .../components/appearance-settings.module.css | 51 + .../components/appearance-settings.tsx | 165 +++ .../components/docs/docs-breadcrumbs.tsx | 114 ++ .../components/docs/docs-copy-page.tsx | 45 + .../components/docs/docs-edit-page.tsx | 31 + .../components/docs/docs-footer-branding.tsx | 23 + .../components/docs/docs-hub.module.css | 485 ++++++++ .../components/docs/docs-page-nav.tsx | 70 ++ .../components/docs/docs-search-button.tsx | 22 + .../components/docs/docs-shell.tsx | 182 +++ .../components/docs/docs-sidebar-tree.tsx | 198 +++ .../components/docs/docs-surface-context.tsx | 27 + .../components/docs/docs-theme-toggle.tsx | 35 + .../public-space/components/docs/docs-toc.tsx | 116 ++ .../components/docs/docs.module.css | 902 ++++++++++++++ .../components/public-space-layout.tsx | 69 ++ .../components/publish-space-settings.tsx | 283 +++++ .../components/published-spaces-list.tsx | 210 ++++ .../components/space-public-notice.tsx | 47 + .../hooks/use-authenticated-user.ts | 14 + .../hooks/use-docs-current-page.ts | 23 + .../queries/public-space-query.ts | 109 ++ .../services/public-space-service.ts | 73 ++ .../features/public-space/theme/docs-theme.ts | 91 ++ .../public-space/types/public-space.types.ts | 105 ++ .../features/public-space/utils/docs-tree.ts | 39 + .../public-space/utils/public-space-access.ts | 8 + .../public-space-search-spotlight.tsx | 112 ++ apps/client/src/features/search/constants.ts | 3 + .../features/search/queries/search-query.ts | 11 + .../search/services/search-service.ts | 10 + .../atoms/open-shared-tree-nodes-atom.ts | 3 - .../features/share/atoms/shared-page-atom.ts | 13 +- .../src/features/share/atoms/sidebar-atom.ts | 9 - .../share/components/share-branding.tsx | 16 - .../share/components/share-layout.tsx | 65 +- .../features/share/components/share-shell.tsx | 272 ---- .../share/components/share.module.css | 36 - .../features/share/components/shared-tree.tsx | 220 ---- .../share/hooks/use-shared-page-subpages.ts | 26 +- .../features/share/hooks/use-toggle-toc.ts | 8 - apps/client/src/features/share/utils.ts | 21 + .../space/components/settings-modal.tsx | 31 +- .../components/sidebar/space-sidebar.tsx | 3 +- .../space/components/sidebar/switch-space.tsx | 22 +- .../src/features/space/types/space.types.ts | 1 + .../transclusion/services/transclusion-api.ts | 8 + .../components/allow-public-spaces.tsx | 129 ++ .../workspace/types/workspace.types.ts | 8 + apps/client/src/lib/api-client.ts | 7 + apps/client/src/lib/config.ts | 4 + .../public-space-directory-page.tsx | 235 ++++ .../pages/public-space/public-space-page.tsx | 125 ++ .../src/pages/settings/shares/shares.tsx | 48 +- .../settings/workspace/workspace-settings.tsx | 10 +- apps/client/src/pages/share/shared-page.tsx | 47 +- apps/client/src/pages/space/space-home.tsx | 2 + apps/client/src/styles/a11y-overrides.css | 10 + apps/client/src/styles/public-typography.css | 39 + apps/client/vite.config.ts | 2 + apps/server/src/common/features.ts | 1 + apps/server/src/core/core.module.ts | 2 + .../core/public-space/dto/public-space.dto.ts | 79 ++ .../public-space-seo.controller.ts | 173 +++ .../public-space/public-space.controller.ts | 249 ++++ .../core/public-space/public-space.module.ts | 14 + .../public-space/public-space.service.spec.ts | 1103 +++++++++++++++++ .../core/public-space/public-space.service.ts | 397 ++++++ apps/server/src/core/search/dto/search.dto.ts | 6 + .../src/core/search/search.controller.spec.ts | 75 ++ .../src/core/search/search.controller.ts | 45 +- apps/server/src/core/search/search.module.ts | 2 + apps/server/src/core/search/search.service.ts | 12 +- .../src/core/share/share-seo.controller.ts | 4 +- apps/server/src/core/share/share.service.ts | 64 +- .../workspace/dto/update-workspace.dto.ts | 8 + .../services/workspace.service.spec.ts | 18 - .../workspace/services/workspace.service.ts | 45 + apps/server/src/database/database.module.ts | 3 + .../20260904T171920-public-spaces.ts | 38 + .../src/database/repos/page/page.repo.ts | 79 ++ .../repos/public-space/public-space.repo.ts | 196 +++ .../src/database/repos/space/space.repo.ts | 15 + .../repos/workspace/workspace.repo.ts | 20 + apps/server/src/database/types/db.d.ts | 13 + .../server/src/database/types/entity.types.ts | 6 + apps/server/src/ee | 2 +- .../environment/environment.service.ts | 7 + .../src/integrations/static/static.module.ts | 1 + apps/server/src/main.ts | 6 +- .../src/lib/markdown/utils/marked.utils.ts | 7 +- pnpm-lock.yaml | 8 + 117 files changed, 7593 insertions(+), 716 deletions(-) create mode 100644 apps/client/src/features/public-space/atoms/public-space-atoms.ts create mode 100644 apps/client/src/features/public-space/components/appearance-settings.module.css create mode 100644 apps/client/src/features/public-space/components/appearance-settings.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-breadcrumbs.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-copy-page.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-edit-page.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-footer-branding.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-hub.module.css create mode 100644 apps/client/src/features/public-space/components/docs/docs-page-nav.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-search-button.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-shell.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-sidebar-tree.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-surface-context.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-theme-toggle.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-toc.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs.module.css create mode 100644 apps/client/src/features/public-space/components/public-space-layout.tsx create mode 100644 apps/client/src/features/public-space/components/publish-space-settings.tsx create mode 100644 apps/client/src/features/public-space/components/published-spaces-list.tsx create mode 100644 apps/client/src/features/public-space/components/space-public-notice.tsx create mode 100644 apps/client/src/features/public-space/hooks/use-authenticated-user.ts create mode 100644 apps/client/src/features/public-space/hooks/use-docs-current-page.ts create mode 100644 apps/client/src/features/public-space/queries/public-space-query.ts create mode 100644 apps/client/src/features/public-space/services/public-space-service.ts create mode 100644 apps/client/src/features/public-space/theme/docs-theme.ts create mode 100644 apps/client/src/features/public-space/types/public-space.types.ts create mode 100644 apps/client/src/features/public-space/utils/docs-tree.ts create mode 100644 apps/client/src/features/public-space/utils/public-space-access.ts create mode 100644 apps/client/src/features/search/components/public-space-search-spotlight.tsx delete mode 100644 apps/client/src/features/share/atoms/open-shared-tree-nodes-atom.ts delete mode 100644 apps/client/src/features/share/atoms/sidebar-atom.ts delete mode 100644 apps/client/src/features/share/components/share-branding.tsx delete mode 100644 apps/client/src/features/share/components/share-shell.tsx delete mode 100644 apps/client/src/features/share/components/shared-tree.tsx delete mode 100644 apps/client/src/features/share/hooks/use-toggle-toc.ts create mode 100644 apps/client/src/features/workspace/components/settings/components/allow-public-spaces.tsx create mode 100644 apps/client/src/pages/public-space/public-space-directory-page.tsx create mode 100644 apps/client/src/pages/public-space/public-space-page.tsx create mode 100644 apps/client/src/styles/public-typography.css create mode 100644 apps/server/src/core/public-space/dto/public-space.dto.ts create mode 100644 apps/server/src/core/public-space/public-space-seo.controller.ts create mode 100644 apps/server/src/core/public-space/public-space.controller.ts create mode 100644 apps/server/src/core/public-space/public-space.module.ts create mode 100644 apps/server/src/core/public-space/public-space.service.spec.ts create mode 100644 apps/server/src/core/public-space/public-space.service.ts delete mode 100644 apps/server/src/core/workspace/services/workspace.service.spec.ts create mode 100644 apps/server/src/database/migrations/20260904T171920-public-spaces.ts create mode 100644 apps/server/src/database/repos/public-space/public-space.repo.ts diff --git a/apps/client/package.json b/apps/client/package.json index d836d5db6..188aabde3 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -21,6 +21,7 @@ "@docmost/base-formula": "workspace:*", "@docmost/editor-ext": "workspace:*", "@excalidraw/excalidraw": "0.18.0-3a5ef40", + "@fontsource-variable/inter": "5.3.0", "@mantine/core": "9.3.2", "@mantine/dates": "9.3.2", "@mantine/form": "9.3.2", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 3a2736f4b..288d01b5d 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1337,5 +1337,82 @@ "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.", "Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode", "Title only": "Title only", - "you": "you" + "you": "you", + "Allow public spaces": "Allow public spaces", + "Space admins can publish their spaces to the web.": "Space admins can publish their spaces to the web.", + "Toggle allow public spaces": "Toggle allow public spaces", + "Publish space to the web": "Publish space to the web", + "Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?": "Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?", + "Make this space publicly readable by anyone on the internet.": "Make this space publicly readable by anyone on the internet.", + "Toggle publish space to the web": "Toggle publish space to the web", + "Allow search engines to index": "Allow search engines to index", + "Let public pages in this space appear in search engine results.": "Let public pages in this space appear in search engine results.", + "Toggle search engine indexing": "Toggle search engine indexing", + "Public space link": "Public space link", + "Copy public space link": "Copy public space link", + "Renaming the space slug will break public links.": "Renaming the space slug will break public links.", + "Failed to update space": "Failed to update space", + "This space is public": "This space is public", + "Anyone on the internet can read the pages in this space, except restricted pages.": "Anyone on the internet can read the pages in this space, except restricted pages.", + "Open public site": "Open public site", + "Public": "Public", + "This space has no public pages yet.": "This space has no public pages yet.", + "On this page": "On this page", + "Previous": "Previous", + "Next": "Next", + "Show hidden pages": "Show hidden pages", + "Page navigation": "Page navigation", + "Toggle sidebar": "Toggle sidebar", + "Toggle table of contents": "Toggle table of contents", + "Appearance": "Appearance", + "Forest": "Forest", + "Violet": "Violet", + "Light mode color": "Light mode color", + "Dark mode color": "Dark mode color", + "Choose the primary color of the public docs site.": "Choose the primary color of the public docs site.", + "Show page author": "Show page author", + "Display the page creator's name on public pages.": "Display the page creator's name on public pages.", + "Toggle show page author": "Toggle show page author", + "Show last updated": "Show last updated", + "Display when each page was last updated.": "Display when each page was last updated.", + "Toggle show last updated": "Toggle show last updated", + "Open public page": "Open public page", + "Toggle color scheme": "Toggle color scheme", + "Ember": "Ember", + "Rose": "Rose", + "Documentation": "Documentation", + "All published spaces.": "All published spaces.", + "No public spaces yet.": "No public spaces yet.", + "Show public directory": "Show public directory", + "List published spaces at /docs for anyone to browse.": "List published spaces at /docs for anyone to browse.", + "Toggle show public directory": "Toggle show public directory", + "Show in public directory": "Show in public directory", + "List this space in the public directory at /docs.": "List this space in the public directory at /docs.", + "Toggle show in public directory": "Toggle show in public directory", + "Open public space link": "Open public space link", + "Disable public spaces": "Disable public spaces", + "Space admins will be able to make their spaces publicly readable by anyone on the internet. Are you sure?": "Space admins will be able to make their spaces publicly readable by anyone on the internet. Are you sure?", + "This will immediately unpublish every published space. Re-enabling later will not republish them. Are you sure?": "This will immediately unpublish every published space. Re-enabling later will not republish them. Are you sure?", + "Allow": "Allow", + "Shared pages": "Shared pages", + "Published spaces": "Published spaces", + "Spaces published to the web will appear here": "Spaces published to the web will appear here", + "No published spaces": "No published spaces", + "Published by": "Published by", + "Published at": "Published at", + "Open space": "Open space", + "Unpublish": "Unpublish", + "Unpublish space": "Unpublish space", + "This space will no longer be publicly accessible. Are you sure?": "This space will no longer be publicly accessible. Are you sure?", + "More options for {{name}}": "More options for {{name}}", + "Edit page": "Edit page", + "Sign in": "Sign in", + "Open app": "Open app", + "No spaces match your search.": "No spaces match your search.", + "Welcome to our documentation": "Welcome to our documentation", + "Guides, references and answers across all our published spaces.": "Guides, references and answers across all our published spaces.", + "Guides, references and answers across all our spaces.": "Guides, references and answers across all our spaces.", + "Search documentation...": "Search documentation...", + "1 published space": "1 published space", + "{{count}} published spaces": "{{count}} published spaces" } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 4207c3006..f39440802 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -44,6 +44,15 @@ const ShareLayout = lazy( () => import("@/features/share/components/share-layout.tsx"), ); const ShareRedirect = lazy(() => import("@/pages/share/share-redirect.tsx")); +const PublicSpacePage = lazy( + () => import("@/pages/public-space/public-space-page.tsx"), +); +const PublicSpaceLayout = lazy( + () => import("@/features/public-space/components/public-space-layout.tsx"), +); +const PublicSpaceDirectoryPage = lazy( + () => import("@/pages/public-space/public-space-directory-page.tsx"), +); const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx")); const MfaChallengePage = lazy(() => import("@/ee/mfa/pages/mfa-challenge-page").then((m) => ({ @@ -119,6 +128,15 @@ export default function App() { } /> + } /> + }> + } /> + } + /> + + } /> } /> } /> diff --git a/apps/client/src/ee/features.ts b/apps/client/src/ee/features.ts index 043849b49..a36067926 100644 --- a/apps/client/src/ee/features.ts +++ b/apps/client/src/ee/features.ts @@ -25,4 +25,5 @@ export const Feature = { OAUTH: 'oauth', AI_CONTROLS: 'ai:controls', MCP_CONTROLS: 'mcp:controls', + PUBLIC_SPACE_APPEARANCE: 'public-space:appearance', } as const; diff --git a/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx b/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx index 2b8b008fc..dc2b779d3 100644 --- a/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx +++ b/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx @@ -82,6 +82,7 @@ export default function SpacePublicSharingToggle({ checked={checked} onChange={handleChange} disabled={isDisabled} + size={"xs"} aria-label={t("Toggle space public sharing")} /> diff --git a/apps/client/src/ee/security/components/space-viewer-comments-toggle.tsx b/apps/client/src/ee/security/components/space-viewer-comments-toggle.tsx index 88fac67fa..6699eafa0 100644 --- a/apps/client/src/ee/security/components/space-viewer-comments-toggle.tsx +++ b/apps/client/src/ee/security/components/space-viewer-comments-toggle.tsx @@ -53,6 +53,7 @@ export default function SpaceViewerCommentsToggle({ checked={checked} onChange={handleChange} disabled={isDisabled} + size={"xs"} aria-label={t("Toggle viewer comments")} /> diff --git a/apps/client/src/features/editor/components/audio/audio-menu.tsx b/apps/client/src/features/editor/components/audio/audio-menu.tsx index eadc1afe5..382bc9afd 100644 --- a/apps/client/src/features/editor/components/audio/audio-menu.tsx +++ b/apps/client/src/features/editor/components/audio/audio-menu.tsx @@ -85,6 +85,9 @@ export function AudioMenu({ editor }: EditorMenuProps) { { + if (element) element.style.zIndex = "99"; + }} updateDelay={0} getReferencedVirtualElement={getReferencedVirtualElement} options={{ diff --git a/apps/client/src/features/editor/components/callout/callout-menu.tsx b/apps/client/src/features/editor/components/callout/callout-menu.tsx index 3ce022dae..df64950b8 100644 --- a/apps/client/src/features/editor/components/callout/callout-menu.tsx +++ b/apps/client/src/features/editor/components/callout/callout-menu.tsx @@ -121,12 +121,14 @@ export function CalloutMenu({ editor }: EditorMenuProps) { { + if (element) element.style.zIndex = "99"; + }} updateDelay={0} getReferencedVirtualElement={getReferencedVirtualElement} options={{ placement: "bottom", // offset: 233, // // offset: [0, 10], - // zIndex: 99, flip: false, }} shouldShow={shouldShow} diff --git a/apps/client/src/features/editor/components/columns/columns-menu.tsx b/apps/client/src/features/editor/components/columns/columns-menu.tsx index ce01324fd..d88d9f6ae 100644 --- a/apps/client/src/features/editor/components/columns/columns-menu.tsx +++ b/apps/client/src/features/editor/components/columns/columns-menu.tsx @@ -257,6 +257,9 @@ export function ColumnsMenu({ editor }: EditorMenuProps) { { + if (element) element.style.zIndex = "99"; + }} updateDelay={0} getReferencedVirtualElement={getReferencedVirtualElement} options={{ diff --git a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx index 418bd4de1..0872116af 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx @@ -273,6 +273,9 @@ export function DrawioMenu({ editor }: EditorMenuProps) { { + if (element) element.style.zIndex = "99"; + }} updateDelay={0} getReferencedVirtualElement={getReferencedVirtualElement} options={{ diff --git a/apps/client/src/features/editor/components/image/image-menu.tsx b/apps/client/src/features/editor/components/image/image-menu.tsx index d6d9c9925..252e506f0 100644 --- a/apps/client/src/features/editor/components/image/image-menu.tsx +++ b/apps/client/src/features/editor/components/image/image-menu.tsx @@ -156,6 +156,9 @@ export function ImageMenu({ editor }: EditorMenuProps) { { + if (element) element.style.zIndex = "99"; + }} updateDelay={0} getReferencedVirtualElement={getReferencedVirtualElement} options={{ diff --git a/apps/client/src/features/editor/components/link/link-view.tsx b/apps/client/src/features/editor/components/link/link-view.tsx index daa5bb5de..39de1d667 100644 --- a/apps/client/src/features/editor/components/link/link-view.tsx +++ b/apps/client/src/features/editor/components/link/link-view.tsx @@ -26,7 +26,12 @@ import { INTERNAL_LINK_REGEX } from "@/lib/constants"; import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx"; import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { useSharePageQuery } from "@/features/share/queries/share-query.ts"; -import { buildSharedPageUrl } from "@/features/page/page.utils.ts"; +import { usePublicSpacePageQuery } from "@/features/public-space/queries/public-space-query.ts"; +import { + buildPageUrl, + buildPublicSpaceUrl, + buildSharedPageUrl, +} from "@/features/page/page.utils.ts"; import { extractPageSlugId } from "@/lib"; import { sanitizeUrl, copyToClipboard, isEditorReady } from "@docmost/editor-ext"; import { normalizeUrl } from "@/lib/utils"; @@ -60,9 +65,10 @@ export default function LinkView(props: MarkViewProps) { const href = mark.attrs.href as string; const navigate = useNavigate(); const location = useLocation(); - const { shareId, pageSlug } = useParams(); + const { shareId, spaceSlug, pageSlug } = useParams(); const { t } = useTranslation(); const isShareRoute = location.pathname.startsWith("/share"); + const isPublicSpaceRoute = location.pathname.startsWith("/docs/"); const [popoverState, setPopoverState] = useState< "closed" | "preview" | "edit" @@ -84,16 +90,33 @@ export default function LinkView(props: MarkViewProps) { const activeView = isPopoverVisible ? popoverState : lastOpenState.current; const { data: linkedPage } = usePageQuery({ - pageId: isPopoverVisible && slugId && !isShareRoute ? slugId : null, + pageId: + isPopoverVisible && slugId && !isShareRoute && !isPublicSpaceRoute + ? slugId + : null, }); const { data: sharedPageData } = useSharePageQuery({ pageId: isPopoverVisible && slugId && isShareRoute ? slugId : null, }); - const pageTitle = isShareRoute - ? sharedPageData?.page?.title - : linkedPage?.title; + // Resolved eagerly (not gated on the popover): an unresolvable target must + // render as inert text rather than a link that dead-ends at /login. + const { data: publicSpacePageData } = usePublicSpacePageQuery({ + spaceSlug: isPublicSpaceRoute && slugId ? spaceSlug : undefined, + pageSlugId: slugId, + contentless: true, + }); + + const isUnresolvedPublicLink = + isPublicSpaceRoute && isInternal && !publicSpacePageData?.page && !slugId; + + let pageTitle = linkedPage?.title; + if (isShareRoute) { + pageTitle = sharedPageData?.page?.title; + } else if (isPublicSpaceRoute) { + pageTitle = publicSpacePageData?.page?.title; + } const pendingTitleRef = useRef(null); const titleInputRef = useRef(null); @@ -260,6 +283,27 @@ export default function LinkView(props: MarkViewProps) { anchorId: anchor || undefined, }); navigate(sharedUrl); + } else if (isPublicSpaceRoute) { + if (slugId && publicSpacePageData?.page) { + navigate( + buildPublicSpaceUrl({ + // cross-space targets resolve to their own space's public URL + spaceSlug: publicSpacePageData.space?.slug ?? spaceSlug, + pageSlugId: slugId, + pageTitle: pageTitle, + anchorId: anchor || undefined, + }), + ); + } else if (slugId) { + // no public URL: the /p/ resolver redirects members straight to the + // page and funnels anonymous visitors through login first; a new tab + // keeps the docs tab's history intact through that redirect chain + window.open( + buildPageUrl(undefined, slugId, pageTitle, anchor || undefined), + "_blank", + "noopener,noreferrer", + ); + } } else { navigate(anchor ? `${targetPath}#${anchor}` : targetPath); } @@ -276,8 +320,11 @@ export default function LinkView(props: MarkViewProps) { location.pathname, isInternal, isShareRoute, + isPublicSpaceRoute, slugId, shareId, + spaceSlug, + publicSpacePageData, pageTitle, pageSlug, ]); @@ -319,12 +366,18 @@ export default function LinkView(props: MarkViewProps) { setPopoverState("closed"); }, [editor]); + const internalHref = () => { + if (isShareRoute && slugId) { + return buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle }); + } + if (isPublicSpaceRoute && slugId && publicSpacePageData?.page) { + return buildPublicSpaceUrl({ spaceSlug, pageSlugId: slugId, pageTitle }); + } + return href; + }; + const displayHref = sanitizeUrl( - isInternal - ? isShareRoute && slugId - ? buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle }) - : href - : normalizeUrl(href), + isInternal ? internalHref() : normalizeUrl(href), ); const linkTitleInput = ( @@ -374,6 +427,16 @@ export default function LinkView(props: MarkViewProps) { ); + // Targets outside the published space have no public URL, so the label is + // rendered as inert text instead of a link that dead-ends at /login. + if (isUnresolvedPublicLink) { + return ( + + + + ); + } + return ( )} - {isPageMention && !isShareRoute && isError && ( + {isPageMention && isPublicSpaceRoute && publicPageData?.page && ( + + + + + + {publicPageData.page.title || label} + + + )} + + {/* No public URL: the /p/ resolver redirects members to the page and + funnels anonymous visitors through login first. New tab, so the + redirect chain never rewrites the docs tab's history. */} + {isPageMention && isPublicSpaceRoute && !publicPageData?.page && ( + + + + + {label} + + )} + + {isPageMention && !isShareRoute && !isPublicSpaceRoute && isError && ( )} - {isPageMention && !isShareRoute && !isError && ( + {isPageMention && !isShareRoute && !isPublicSpaceRoute && !isError && ( { + if (element) element.style.zIndex = "99"; + }} updateDelay={0} getReferencedVirtualElement={getReferencedVirtualElement} options={{ 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 776568037..190893607 100644 --- a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx +++ b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx @@ -61,6 +61,9 @@ export const SubpagesMenu = React.memo( { + if (element) element.style.zIndex = "99"; + }} updateDelay={0} shouldShow={shouldShow} > diff --git a/apps/client/src/features/editor/components/subpages/subpages-view.tsx b/apps/client/src/features/editor/components/subpages/subpages-view.tsx index a50207aa0..0683a22e1 100644 --- a/apps/client/src/features/editor/components/subpages/subpages-view.tsx +++ b/apps/client/src/features/editor/components/subpages/subpages-view.tsx @@ -3,22 +3,30 @@ 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, useParams } from "react-router-dom"; +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 { 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 { publicSpaceTreeDataAtom } from "@/features/public-space/atoms/public-space-atoms.ts"; +import { findSubpagesInTree } from "@/features/share/utils"; import { extractPageSlugId } from "@/lib"; export default function SubpagesView(props: NodeViewProps) { const { editor } = props; const { spaceSlug, shareId, pageSlug } = useParams(); const { t } = useTranslation(); + const location = useLocation(); + const isPublicSpaceRoute = location.pathname.startsWith("/docs/"); + + const publicSpaceTreeData = useAtomValue(publicSpaceTreeDataAtom); // @ts-ignore const storagePageId = editor.storage.pageId; @@ -29,11 +37,25 @@ export default function SubpagesView(props: NodeViewProps) { currentPageId = routePageId; } + // Public docs must resolve the page from the route, not editor storage: + // storage.pageId is set after this view's first render and is not reactive, + // which froze the list at "No subpages" until something re-rendered it. The + // space home renders at the bare URL, so it falls back to the first root. + if (isPublicSpaceRoute) { + 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 isPublicView = Boolean(shareId) || isPublicSpaceRoute; const { data, isLoading, error } = useGetSidebarPagesQuery( - shareId ? null : { pageId: currentPageId }, + isPublicView ? null : { pageId: currentPageId }, ); const subpages = useMemo(() => { @@ -48,17 +70,33 @@ export default function SubpagesView(props: NodeViewProps) { })); } + 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]); + }, [ + data, + shareId, + sharedSubpages, + isPublicSpaceRoute, + publicSpaceSubpages, + ]); - if (isLoading && !shareId) { + if (isLoading && !isPublicView) { return null; } - if (error && !shareId) { + if (error && !isPublicView) { return ( @@ -96,7 +134,13 @@ export default function SubpagesView(props: NodeViewProps) { pageSlugId: page.slugId, pageTitle: page.title, }) - : buildPageUrl(spaceSlug, page.slugId, page.title) + : isPublicSpaceRoute + ? buildPublicSpaceUrl({ + spaceSlug, + pageSlugId: page.slugId, + pageTitle: page.title, + }) + : buildPageUrl(spaceSlug, page.slugId, page.title) } underline="never" className={styles.pageMentionLink} diff --git a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx index ba2bed40e..c4a082db0 100644 --- a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx +++ b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx @@ -18,7 +18,7 @@ export type HeadingLink = { position: number; }; -const recalculateLinks = (nodePos: NodePos[]) => { +export const recalculateLinks = (nodePos: NodePos[]) => { const nodes: HTMLElement[] = []; const links: HeadingLink[] = Array.from(nodePos).reduce( diff --git a/apps/client/src/features/editor/components/transclusion/transclusion-lookup-context.tsx b/apps/client/src/features/editor/components/transclusion/transclusion-lookup-context.tsx index ec44a5f20..6e3a3c708 100644 --- a/apps/client/src/features/editor/components/transclusion/transclusion-lookup-context.tsx +++ b/apps/client/src/features/editor/components/transclusion/transclusion-lookup-context.tsx @@ -9,6 +9,7 @@ import React, { } from "react"; import { lookupTransclusion, + lookupTransclusionForPublicSpace, lookupTransclusionForShare, } from "@/features/transclusion/services/transclusion-api"; import type { TransclusionLookup } from "@/features/transclusion/types/transclusion.types"; @@ -38,6 +39,7 @@ const TransclusionLookupContext = createContext(null); export function TransclusionLookupProvider({ children, shareId, + spaceSlug, }: { children: React.ReactNode; /** @@ -47,6 +49,11 @@ export function TransclusionLookupProvider({ * app, where personal permissions gate access. */ shareId?: string; + /** + * When set, lookups go through the public-space endpoint and are gated by + * the published space. Used by the public docs viewer. + */ + spaceSlug?: string; }) { const subscribersRef = useRef(new Map()); const queueRef = useRef(new Set()); @@ -55,6 +62,8 @@ export function TransclusionLookupProvider({ // memoized callbacks (and thus doesn't re-render every consumer). const shareIdRef = useRef(shareId); shareIdRef.current = shareId; + const spaceSlugRef = useRef(spaceSlug); + spaceSlugRef.current = spaceSlug; // Last looked-up value for each key. Re-subscribers (e.g. when the editor // remounts after switching from static to live) get this immediately // instead of triggering a duplicate fetch. @@ -91,12 +100,18 @@ export function TransclusionLookupProvider({ try { const activeShareId = shareIdRef.current; + const activeSpaceSlug = spaceSlugRef.current; const { items } = activeShareId ? await lookupTransclusionForShare({ shareId: activeShareId, references, }) - : await lookupTransclusion({ references }); + : activeSpaceSlug + ? await lookupTransclusionForPublicSpace({ + spaceSlug: activeSpaceSlug, + references, + }) + : await lookupTransclusion({ references }); for (const r of items) { const key = `${r.sourcePageId}::${r.transclusionId}`; resultCacheRef.current.set(key, r); diff --git a/apps/client/src/features/editor/components/video/video-menu.tsx b/apps/client/src/features/editor/components/video/video-menu.tsx index 0e01fe8ba..9cf8e3fd0 100644 --- a/apps/client/src/features/editor/components/video/video-menu.tsx +++ b/apps/client/src/features/editor/components/video/video-menu.tsx @@ -132,6 +132,9 @@ export function VideoMenu({ editor }: EditorMenuProps) { { + if (element) element.style.zIndex = "99"; + }} updateDelay={0} getReferencedVirtualElement={getReferencedVirtualElement} options={{ diff --git a/apps/client/src/features/editor/readonly-page-editor.tsx b/apps/client/src/features/editor/readonly-page-editor.tsx index df2f6e470..f99bfc04a 100644 --- a/apps/client/src/features/editor/readonly-page-editor.tsx +++ b/apps/client/src/features/editor/readonly-page-editor.tsx @@ -35,6 +35,16 @@ interface PageEditorProps { * that isn't itself shared. */ shareId?: string; + /** + * When rendering inside a public space, pass the space slug. Transclusion + * lookups then resolve against the published space instead of the viewer's + * personal permissions. + */ + spaceSlug?: string; + /** Rendered between the title and the content. */ + byline?: React.ReactNode; + /** Set false when the consumer renders its own end matter (e.g. prev/next). */ + trailingSpace?: boolean; } export default function ReadonlyPageEditor({ @@ -43,12 +53,16 @@ export default function ReadonlyPageEditor({ pageId, printMode = false, shareId, + spaceSlug, + byline, + trailingSpace = true, }: PageEditorProps) { const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom); const [lightboxRequest, setLightboxRequest] = useAtom(lightboxRequestAtom); const [contentEditor, setContentEditor] = useState(null); const isComponentMounted = useRef(false); const editorCreated = useRef(false); + const isPublicView = Boolean(shareId || spaceSlug); const canScroll = useCallback( () => isComponentMounted.current && editorCreated.current, @@ -64,9 +78,9 @@ export default function ReadonlyPageEditor({ }, []); useEffect(() => { - if (!shareId) return; + if (!isPublicView) return; setLightboxRequest(null); - }, [pageId, shareId]); + }, [pageId, isPublicView]); const extensions = useMemo(() => { const excludedExtensions = new Set([ @@ -99,7 +113,7 @@ export default function ReadonlyPageEditor({ ]; return ( - +
+ {byline} + { const request = getLightboxClickRequest(node); @@ -144,7 +160,7 @@ export default function ReadonlyPageEditor({ } }} > - {shareId && contentEditor && ( + {isPublicView && contentEditor && ( setLightboxRequest(null)} /> )} -
+ {trailingSpace &&
}
); } diff --git a/apps/client/src/features/page/components/header/page-header.tsx b/apps/client/src/features/page/components/header/page-header.tsx index 0614cf0bd..aec7038a8 100644 --- a/apps/client/src/features/page/components/header/page-header.tsx +++ b/apps/client/src/features/page/components/header/page-header.tsx @@ -1,16 +1,63 @@ import classes from "./page-header.module.css"; import PageHeaderMenu from "@/features/page/components/header/page-header-menu.tsx"; -import { Group } from "@mantine/core"; +import { Badge, Group, Tooltip } from "@mantine/core"; +import { IconExternalLink, IconWorld } from "@tabler/icons-react"; import Breadcrumb from "@/features/page/components/breadcrumbs/breadcrumb.tsx"; +import { useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; +import { usePageQuery } from "@/features/page/queries/page-query.ts"; +import { extractPageSlugId } from "@/lib"; +import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts"; +import { isBetaPublicSpaces } from "@/lib/config.ts"; interface Props { readOnly?: boolean; } export default function PageHeader({ readOnly }: Props) { + const { t } = useTranslation(); + const { spaceSlug, pageSlug } = useParams(); + const { data: space } = useGetSpaceBySlugQuery(spaceSlug); + const { data: page } = usePageQuery({ + pageId: extractPageSlugId(pageSlug), + }); + + // Restricted pages are never publicly reachable, so the chip only shows on + // pages the public site actually serves. + const showPublicBadge = + isBetaPublicSpaces() && + space?.isPublished && + page && + page.permissions?.hasRestriction !== true; + return (
- + + + + {showPublicBadge && ( + + } + rightSection={} + style={{ flexShrink: 0, cursor: "pointer" }} + > + {t("Public")} + + + )} + diff --git a/apps/client/src/features/page/page.utils.ts b/apps/client/src/features/page/page.utils.ts index 8b0111a28..638ba506a 100644 --- a/apps/client/src/features/page/page.utils.ts +++ b/apps/client/src/features/page/page.utils.ts @@ -55,3 +55,16 @@ export const buildSharedPageUrl = (opts: { } return anchorId ? `${url}#${anchorId}` : url; }; + +export const buildPublicSpaceUrl = (opts: { + spaceSlug: string; + pageSlugId?: string; + pageTitle?: string; + anchorId?: string; +}): string => { + const { spaceSlug, pageSlugId, pageTitle, anchorId } = opts; + const url = pageSlugId + ? `/docs/${spaceSlug}/${buildPageSlug(pageSlugId, pageTitle)}` + : `/docs/${spaceSlug}`; + return anchorId ? `${url}#${anchorId}` : url; +}; diff --git a/apps/client/src/features/page/tree/components/doc-tree-row.tsx b/apps/client/src/features/page/tree/components/doc-tree-row.tsx index e3aebe9e9..7c9df04b9 100644 --- a/apps/client/src/features/page/tree/components/doc-tree-row.tsx +++ b/apps/client/src/features/page/tree/components/doc-tree-row.tsx @@ -41,6 +41,7 @@ type Props = { activeId?: string; renderRow: (props: RenderRowProps) => ReactNode; indentPerLevel: number; + rowClassName?: string; onMove: (sourceId: string, op: DropOp) => void | Promise; onToggle: (id: string, isOpen: boolean) => void; readOnly: boolean; @@ -68,6 +69,7 @@ function DocTreeRowInner(props: Props) { activeId, renderRow, indentPerLevel, + rowClassName, onMove, onToggle, readOnly, @@ -323,7 +325,7 @@ function DocTreeRowInner(props: Props) { style={{ paddingLeft: level * indentPerLevel }} >
( if (prev.readOnly !== next.readOnly) return false; if (prev.contextId !== next.contextId) return false; if (prev.indentPerLevel !== next.indentPerLevel) return false; + if (prev.rowClassName !== next.rowClassName) return false; if (prev.renderRow !== next.renderRow) return false; if (prev.onMove !== next.onMove) return false; if (prev.onToggle !== next.onToggle) return false; diff --git a/apps/client/src/features/page/tree/components/doc-tree.tsx b/apps/client/src/features/page/tree/components/doc-tree.tsx index 3dbbfda16..299797333 100644 --- a/apps/client/src/features/page/tree/components/doc-tree.tsx +++ b/apps/client/src/features/page/tree/components/doc-tree.tsx @@ -56,6 +56,18 @@ export type DocTreeProps = { indentPerLevel?: number; rowHeight?: number; emptyState?: ReactNode; + // Extra class for the engine's row wrapper (the div carrying data-selected / + // data-dragging), letting consumers restyle hover/selected states. + rowClassName?: string; + // Extra vertical space above a row (e.g. to separate top-level groups). + // Added to the row's virtualized slot and applied as padding above it. + rowGap?: (node: TreeNode, level: number, index: number) => number; + // Measure real row heights so rows may wrap to multiple lines; rowHeight + // then only seeds the estimate. Off by default (fixed-slot fast path). + dynamicRowHeight?: boolean; + // Rendered inside the scroll container after the last row, so it flows + // with the tree content instead of pinning to the container edge. + footer?: ReactNode; onMove: (sourceId: string, op: DropOp) => void | Promise; onToggle: (id: string, isOpen: boolean) => void; @@ -123,6 +135,9 @@ function DocTreeInner( renderRow, indentPerLevel = 16, rowHeight = 32, + rowClassName, + rowGap, + dynamicRowHeight = false, onMove, onToggle, onSelect, @@ -132,6 +147,7 @@ function DocTreeInner( getDragLabel, uniqueContextId, emptyState, + footer, 'aria-label': ariaLabel, } = props; @@ -197,10 +213,20 @@ function DocTreeInner( return flat[0]?.node.id; }, [activeId, selectedId, flatIds, flat]); + // Keyed by node id so measured sizes follow their rows across + // expand/collapse instead of sticking to positions. Never reset the + // measurement cache wholesale: ResizeObserver only re-reports elements + // whose size changed, so unchanged mounted rows would be left positioned + // by the estimate and overlap their neighbors. const virtualizer = useVirtualizer({ count: flat.length, getScrollElement: () => scrollRef.current, - estimateSize: () => rowHeight, + getItemKey: (index) => flat[index].node.id, + estimateSize: (index) => { + const row = flat[index]; + const gap = row && rowGap ? rowGap(row.node, row.level, index) : 0; + return rowHeight + gap; + }, overscan: 10, }); @@ -471,7 +497,12 @@ function DocTreeInner( ); if (data.length === 0 && emptyState) { - return
{emptyState}
; + return ( +
+ {emptyState} + {footer} +
+ ); } const virtualItems = virtualizer.getVirtualItems(); @@ -494,6 +525,9 @@ function DocTreeInner( > {virtualItems.map((virtualItem) => { const row = flat[virtualItem.index]; + const gap = rowGap + ? rowGap(row.node, row.level, virtualItem.index) + : 0; return (
  • ( // (the row's ), so screen readers announce "treeitem" on // navigation. The
  • is just layout glue. role="none" + ref={dynamicRowHeight ? virtualizer.measureElement : undefined} + data-index={dynamicRowHeight ? virtualItem.index : undefined} style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${virtualItem.start}px)`, + ...(gap > 0 ? { paddingTop: gap } : {}), }} > ( activeId={effectiveActiveId} renderRow={renderRow} indentPerLevel={indentPerLevel} + rowClassName={rowClassName} onMove={onMove} onToggle={onToggle} readOnly={readOnly} @@ -532,6 +570,7 @@ function DocTreeInner( ); })} + {footer}
  • ); } diff --git a/apps/client/src/features/public-space/atoms/public-space-atoms.ts b/apps/client/src/features/public-space/atoms/public-space-atoms.ts new file mode 100644 index 000000000..8388d7c2b --- /dev/null +++ b/apps/client/src/features/public-space/atoms/public-space-atoms.ts @@ -0,0 +1,17 @@ +import { atom } from "jotai"; +import { IPublicSpaceTree } from "@/features/public-space/types/public-space.types.ts"; +import { SharedPageTreeNode } from "@/features/share/utils.ts"; + +export const publicSpaceTreeAtom = atom( + null as IPublicSpaceTree | null, +); + +export const publicSpaceTreeDataAtom = atom( + null as SharedPageTreeNode[] | null, +); + +export const openPublicSpaceTreeNodesAtom = atom>({}); + +export const docsMobileSidebarAtom = atom(false); + +export const docsMobileTocAtom = atom(false); diff --git a/apps/client/src/features/public-space/components/appearance-settings.module.css b/apps/client/src/features/public-space/components/appearance-settings.module.css new file mode 100644 index 000000000..c37de65ab --- /dev/null +++ b/apps/client/src/features/public-space/components/appearance-settings.module.css @@ -0,0 +1,51 @@ +.presetRow { + display: flex; + flex-wrap: wrap; + gap: rem(8px); +} + +.presetCard { + display: flex; + flex-direction: column; + align-items: center; + gap: rem(6px); + min-width: rem(72px); + padding: rem(10px) rem(12px); + border: 1px solid var(--mantine-color-default-border); + border-radius: rem(8px); + + @media (hover: hover) { + &:hover { + background-color: var(--mantine-color-default-hover); + } + } + + &:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: 1px; + } +} + +.presetCard[data-selected="true"] { + border-color: var(--mantine-primary-color-filled); + background-color: var(--mantine-primary-color-light); +} + +.presetSwatch { + width: rem(22px); + height: rem(22px); + border-radius: 50%; + border: 1px solid var(--mantine-color-default-border); + flex-shrink: 0; +} + +.customSwatch { + display: inline-flex; + align-items: center; + justify-content: center; + width: rem(22px); + height: rem(22px); + border-radius: 50%; + border: 1px dashed var(--mantine-color-default-border); + color: var(--mantine-color-dimmed); +} diff --git a/apps/client/src/features/public-space/components/appearance-settings.tsx b/apps/client/src/features/public-space/components/appearance-settings.tsx new file mode 100644 index 000000000..8bf4ea604 --- /dev/null +++ b/apps/client/src/features/public-space/components/appearance-settings.tsx @@ -0,0 +1,165 @@ +import { + ColorInput, + Group, + Text, + Tooltip, + UnstyledButton, +} from "@mantine/core"; +import { IconColorPicker } from "@tabler/icons-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + DEFAULT_DOCS_PRESET, + DOCS_THEME_PRESETS, + isValidDocsColor, + matchDocsPreset, +} from "@/features/public-space/theme/docs-theme.ts"; +import { IPublicSpaceAppearance } from "@/features/public-space/types/public-space.types.ts"; +import { usePublishSpaceMutation } from "@/features/public-space/queries/public-space-query.ts"; +import { useHasFeature } from "@/ee/hooks/use-feature.ts"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts"; +import { Feature } from "@/ee/features.ts"; +import classes from "./appearance-settings.module.css"; + +type AppearanceSettingsProps = { + spaceId: string; + appearance?: IPublicSpaceAppearance; +}; + +export default function AppearanceSettings({ + spaceId, + appearance, +}: AppearanceSettingsProps) { + const { t } = useTranslation(); + const publishMutation = usePublishSpaceMutation(); + const hasAppearance = useHasFeature(Feature.PUBLIC_SPACE_APPEARANCE); + const upgradeLabel = useUpgradeLabel(); + + const matchedPreset = matchDocsPreset(appearance); + const [customOpen, setCustomOpen] = useState(matchedPreset === null); + const [customLight, setCustomLight] = useState( + appearance?.primaryColorLight ?? DEFAULT_DOCS_PRESET.light, + ); + const [customDark, setCustomDark] = useState( + appearance?.primaryColorDark ?? DEFAULT_DOCS_PRESET.dark, + ); + + useEffect(() => { + setCustomOpen(matchDocsPreset(appearance) === null); + setCustomLight(appearance?.primaryColorLight ?? DEFAULT_DOCS_PRESET.light); + setCustomDark(appearance?.primaryColorDark ?? DEFAULT_DOCS_PRESET.dark); + }, [appearance?.primaryColorLight, appearance?.primaryColorDark]); + + const saveAppearance = (payload: { + primaryColorLight: string | null; + primaryColorDark: string | null; + }) => { + if (!hasAppearance) return; + publishMutation.mutate({ spaceId, enabled: true, appearance: payload }); + }; + + const selectPreset = (presetId: string) => { + setCustomOpen(false); + const preset = DOCS_THEME_PRESETS.find((item) => item.id === presetId); + if (!preset) return; + if (preset.id === DEFAULT_DOCS_PRESET.id) { + saveAppearance({ primaryColorLight: null, primaryColorDark: null }); + return; + } + saveAppearance({ + primaryColorLight: preset.light, + primaryColorDark: preset.dark, + }); + }; + + const commitCustom = (light: string, dark: string) => { + if (!isValidDocsColor(light) || !isValidDocsColor(dark)) return; + saveAppearance({ primaryColorLight: light, primaryColorDark: dark }); + }; + + const swatches = DOCS_THEME_PRESETS.flatMap((preset) => [ + preset.light, + preset.dark, + ]); + + return ( +
    + + {t("Appearance")} + + + {t("Choose the primary color of the public docs site.")} + + + +
    + {DOCS_THEME_PRESETS.map((preset) => { + const selected = !customOpen && matchedPreset?.id === preset.id; + return ( + selectPreset(preset.id)} + > + + {t(preset.nameKey)} + + ); + })} + + setCustomOpen(true)} + > + + + + {t("Custom")} + +
    +
    + + {customOpen && hasAppearance && ( + + { + setCustomLight(value); + commitCustom(value, customDark); + }} + /> + { + setCustomDark(value); + commitCustom(customLight, value); + }} + /> + + )} +
    + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-breadcrumbs.tsx b/apps/client/src/features/public-space/components/docs/docs-breadcrumbs.tsx new file mode 100644 index 000000000..d2d194a40 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-breadcrumbs.tsx @@ -0,0 +1,114 @@ +import { Menu } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; +import { Link, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Fragment, useMemo, type ReactNode } from "react"; +import { useDocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx"; +import { findAncestorTrail } from "@/features/public-space/utils/docs-tree.ts"; +import { extractPageSlugId } from "@/lib"; +import styles from "./docs.module.css"; + +type Crumb = { + key: string; + name: string; + url: string; +}; + +export default function DocsBreadcrumbs() { + const { t } = useTranslation(); + const { pageSlug } = useParams(); + const { treeData, siteName, homeUrl, getNodeUrl } = useDocsSurface(); + const isMobile = useMediaQuery("(max-width: 48em)"); + + const crumbs = useMemo(() => { + if (!treeData?.length) return null; + + const currentSlugId = pageSlug + ? extractPageSlugId(pageSlug) + : treeData[0]?.slugId; + if (!currentSlugId) return null; + + const trail = findAncestorTrail(treeData, currentSlugId); + if (trail === null) return null; + + const siteCrumbs: Crumb[] = + siteName && homeUrl + ? [{ key: "site", name: siteName, url: homeUrl }] + : []; + + const list = [ + ...siteCrumbs, + ...trail.map((node) => ({ + key: node.slugId, + name: node.name || t("untitled"), + url: getNodeUrl(node), + })), + ]; + return list.length ? list : null; + }, [treeData, siteName, homeUrl, getNodeUrl, pageSlug, t]); + + if (!crumbs) return null; + + // Mobile keeps a single line (menu + last crumb, like the app header's + // breadcrumb); desktop collapses the middle beyond 4 crumbs. + const collapsed = crumbs.length > (isMobile ? 1 : 4); + const hidden = !collapsed + ? [] + : isMobile + ? crumbs.slice(0, crumbs.length - 1) + : crumbs.slice(1, crumbs.length - 1); + + const items: ReactNode[] = []; + if (collapsed && !isMobile) { + items.push( + + {crumbs[0].name} + , + ); + } + if (collapsed) { + items.push( + + + + + + {hidden.map((item) => ( + + {item.name} + + ))} + + , + ); + } + const trailing = collapsed ? [crumbs[crumbs.length - 1]] : crumbs; + for (const crumb of trailing) { + items.push( + + {crumb.name} + , + ); + } + + return ( + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-copy-page.tsx b/apps/client/src/features/public-space/components/docs/docs-copy-page.tsx new file mode 100644 index 000000000..119407dad --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-copy-page.tsx @@ -0,0 +1,45 @@ +import { Button } from "@mantine/core"; +import { useAtomValue } from "jotai"; +import { useTranslation } from "react-i18next"; +import { IconCheck, IconCopy } from "@tabler/icons-react"; +import { htmlToMarkdown } from "@docmost/editor-ext"; +import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; +import { useDocsCurrentPage } from "@/features/public-space/hooks/use-docs-current-page.ts"; +import { useClipboard } from "@/hooks/use-clipboard"; +import styles from "./docs.module.css"; + +export default function DocsCopyPage() { + const { t } = useTranslation(); + const editor = useAtomValue(readOnlyEditorAtom); + const page = useDocsCurrentPage(); + const clipboard = useClipboard(); + + if (!editor) { + return null; + } + + const handleCopy = () => { + if (editor.isDestroyed) return; + const markdown = htmlToMarkdown(editor.getHTML()); + const title = page?.name ? `# ${page.name}\n\n` : ""; + clipboard.copy(`${title}${markdown}`); + }; + + return ( + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-edit-page.tsx b/apps/client/src/features/public-space/components/docs/docs-edit-page.tsx new file mode 100644 index 000000000..77ef37c9b --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-edit-page.tsx @@ -0,0 +1,31 @@ +import { Link, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { IconPencil } from "@tabler/icons-react"; +import { useAuthenticatedUser } from "@/features/public-space/hooks/use-authenticated-user.ts"; +import { useDocsCurrentPage } from "@/features/public-space/hooks/use-docs-current-page.ts"; +import { buildPageUrl } from "@/features/page/page.utils.ts"; +import styles from "./docs.module.css"; + +export default function DocsEditPage() { + const { t } = useTranslation(); + const { spaceSlug } = useParams(); + const page = useDocsCurrentPage(); + + const { data: currentUser } = useAuthenticatedUser(); + + if (!currentUser?.user || !page) { + return null; + } + + return ( + + + {t("Edit page")} + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-footer-branding.tsx b/apps/client/src/features/public-space/components/docs/docs-footer-branding.tsx new file mode 100644 index 000000000..9e55ebba3 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-footer-branding.tsx @@ -0,0 +1,23 @@ +import clsx from "clsx"; +import styles from "./docs.module.css"; + +export default function DocsFooterBranding({ + className, + refSource = "public-space", +}: { + className?: string; + refSource?: string; +}) { + return ( +
    + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-hub.module.css b/apps/client/src/features/public-space/components/docs/docs-hub.module.css new file mode 100644 index 000000000..53a93362c --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-hub.module.css @@ -0,0 +1,485 @@ +/* Design tokens for the public docs hub, transcribed from the "1a solid brand + * band" direction of the Directory design spec (a fixed light look). Declared + * on :root like --docs-* so cover/brand customization can override them at + * runtime. */ +:root { + --docs-hub-max: 80rem; + --docs-hub-bg: #ffffff; + --docs-hub-fg: #111827; + --docs-hub-muted: #5b6270; + --docs-hub-nav-fg: #4b5563; + --docs-hub-footer-fg: #6b7280; + --docs-hub-border: #eceef2; + --docs-hub-brand: #12275c; + --docs-hub-brand-fg: #ffffff; + --docs-hub-hero-bg: var(--docs-hub-brand); + --docs-hub-hero-fg: #ffffff; + --docs-hub-hero-muted: rgba(255, 255, 255, 0.78); + --docs-hub-search-bg: #ffffff; + --docs-hub-search-fg: #111827; + --docs-hub-search-placeholder: #8a919e; + --docs-hub-search-shadow: 0 12px 32px rgba(0, 0, 0, 0.18); + --docs-hub-card-bg: #ffffff; + --docs-hub-card-border: #e6e7ea; + --docs-hub-card-radius: 14px; + --docs-hub-card-shadow: 0 4px 16px rgba(15, 23, 42, 0.06); + --docs-hub-card-border-hover: #c4c8d0; + --docs-hub-card-shadow-hover: 0 12px 32px rgba(15, 23, 42, 0.14); + --docs-hub-tile-fg: #ffffff; +} + +.root { + min-height: 100dvh; + background-color: var(--docs-hub-bg); + color: var(--docs-hub-fg); +} + +.container { + max-width: var(--docs-hub-max); + margin-inline: auto; +} + +/* ---------- Top bar ---------- */ + +.topBar { + border-bottom: 1px solid var(--docs-hub-border); + padding: 0 rem(48px); + + @media (max-width: $mantine-breakpoint-sm) { + padding: 0 rem(20px); + } +} + +.topBarInner { + height: rem(64px); + display: flex; + align-items: center; + justify-content: space-between; + + @media (max-width: $mantine-breakpoint-sm) { + height: rem(56px); + } +} + +.brand { + display: flex; + align-items: center; + gap: rem(10px); + min-width: 0; + color: var(--docs-hub-fg); + text-decoration: none; + font-size: rem(16px); + font-weight: 600; + border-radius: rem(8px); + + &:focus-visible { + outline: 2px solid var(--docs-hub-brand); + outline-offset: 4px; + } + + @media (max-width: $mantine-breakpoint-sm) { + gap: rem(8px); + font-size: rem(15px); + } +} + +.brandTile { + width: rem(28px); + height: rem(28px); + border-radius: rem(8px); + background-color: var(--docs-hub-brand); + color: var(--docs-hub-brand-fg); + display: grid; + place-items: center; + flex-shrink: 0; + font-weight: 700; + font-size: rem(13px); + + @media (max-width: $mantine-breakpoint-sm) { + width: rem(26px); + height: rem(26px); + border-radius: rem(7px); + font-size: rem(12px); + } +} + +.topActions { + display: flex; + align-items: center; + gap: rem(24px); + font-size: rem(14px); + color: var(--docs-hub-nav-fg); +} + +.signIn { + display: inline-block; + padding: rem(8px) rem(14px); + border-radius: rem(8px); + background-color: var(--docs-hub-brand); + color: var(--docs-hub-brand-fg); + text-decoration: none; + font-weight: 500; + white-space: nowrap; + + @media (hover: hover) { + &:hover { + filter: brightness(1.15); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-hub-brand); + outline-offset: 2px; + } + + @media (max-width: $mantine-breakpoint-sm) { + padding: rem(8px) rem(12px); + font-size: rem(13px); + } +} + +/* ---------- Hero band ---------- */ + +.hero { + background-color: var(--docs-hub-hero-bg); + color: var(--docs-hub-hero-fg); + padding: rem(72px) rem(48px) rem(80px); + + @media (max-width: $mantine-breakpoint-sm) { + padding: rem(40px) rem(20px) rem(44px); + } +} + +.heroInner { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: rem(16px); + + @media (max-width: $mantine-breakpoint-sm) { + gap: rem(10px); + } +} + +.heading { + margin: 0; + color: var(--docs-hub-hero-fg); + font-size: rem(48px); + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.15; + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(30px); + } +} + +.subtitle { + margin: 0; + color: var(--docs-hub-hero-muted); + font-size: rem(18px); + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(15px); + line-height: 1.45; + } +} + +.search { + margin-top: rem(16px); + width: rem(640px); + max-width: 100%; + height: rem(56px); + display: flex; + align-items: center; + padding: 0 rem(6px) 0 rem(22px); + border-radius: 999px; + background-color: var(--docs-hub-search-bg); + box-shadow: var(--docs-hub-search-shadow); + + &:focus-within { + outline: 2px solid var(--docs-hub-hero-fg); + outline-offset: 3px; + } + + @media (max-width: $mantine-breakpoint-sm) { + margin-top: rem(10px); + width: 100%; + height: rem(48px); + padding: 0 rem(5px) 0 rem(18px); + box-shadow: none; + } +} + +.searchInput { + flex: 1; + min-width: 0; + height: 100%; + border: 0; + background: transparent; + font: inherit; + font-size: rem(16px); + color: var(--docs-hub-search-fg); + text-align: left; + outline: none; + + &::placeholder { + color: var(--docs-hub-search-placeholder); + } + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(15px); + } +} + +.searchButton { + width: rem(44px); + height: rem(44px); + flex-shrink: 0; + border: 0; + border-radius: 999px; + background-color: var(--docs-hub-brand); + color: var(--docs-hub-brand-fg); + display: grid; + place-items: center; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--docs-hub-search-fg); + outline-offset: 2px; + } + + @media (max-width: $mantine-breakpoint-sm) { + width: rem(38px); + height: rem(38px); + } +} + +/* ---------- Spaces ---------- */ + +.main { + padding: rem(48px) rem(48px) rem(56px); + + @media (max-width: $mantine-breakpoint-sm) { + padding: rem(24px) rem(20px) rem(28px); + } +} + +.mainInner { + display: flex; + flex-direction: column; + gap: rem(24px); + + @media (max-width: $mantine-breakpoint-sm) { + gap: rem(16px); + } +} + +.sectionHeader { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: rem(16px); + border-bottom: 1px solid var(--docs-hub-border); + padding-bottom: rem(14px); + + @media (max-width: $mantine-breakpoint-sm) { + padding-bottom: rem(10px); + } +} + +.sectionTitle { + margin: 0; + font-size: rem(13px); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--docs-hub-fg); + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(12px); + } +} + +.sectionCount { + font-size: rem(14px); + color: var(--docs-hub-footer-fg); + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(13px); + } +} + +/* Phones flow one card per row; wider viewports fit two, then 3/4 columns. */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(rem(220px), 1fr)); + gap: rem(12px); + + @media (min-width: $mantine-breakpoint-sm) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: rem(20px); + } + + @media (min-width: 64em) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +.card { + display: flex; + flex-direction: column; + gap: rem(14px); + padding: rem(24px) rem(24px) rem(26px); + background-color: var(--docs-hub-card-bg); + border: 1px solid var(--docs-hub-card-border); + border-radius: var(--docs-hub-card-radius); + box-shadow: var(--docs-hub-card-shadow); + color: inherit; + text-decoration: none; + transition: + border-color 120ms ease, + box-shadow 160ms ease; + + @media (hover: hover) { + &:hover { + border-color: var(--docs-hub-card-border-hover); + box-shadow: var(--docs-hub-card-shadow-hover); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-hub-brand); + outline-offset: 2px; + } + + @media (max-width: $mantine-breakpoint-sm) { + gap: rem(12px); + padding: rem(18px) rem(18px) rem(20px); + } +} + +/* Tile and title share a row so titles get the card's full width. */ +.cardHeader { + display: flex; + align-items: center; + gap: rem(14px); + min-width: 0; + + @media (max-width: $mantine-breakpoint-sm) { + gap: rem(12px); + } +} + +.cardTile { + width: rem(40px); + height: rem(40px); + flex-shrink: 0; + border-radius: rem(10px); + display: grid; + place-items: center; + overflow: hidden; + color: var(--docs-hub-tile-fg); + font-weight: 700; + font-size: rem(15px); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + @media (max-width: $mantine-breakpoint-sm) { + width: rem(36px); + height: rem(36px); + border-radius: rem(9px); + font-size: rem(14px); + } +} + +.cardName { + min-width: 0; + font-size: rem(18px); + font-weight: 600; + line-height: 1.3; + color: var(--docs-hub-fg); + text-wrap: balance; + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(16px); + } +} + +.cardDescription { + font-size: rem(15px); + line-height: 1.5; + color: var(--docs-hub-muted); + text-wrap: pretty; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(14px); + } +} + +.empty { + margin: 0; + padding: rem(48px) 0; + text-align: center; + color: var(--docs-hub-muted); +} + +/* ---------- Footer ---------- */ + +.footer { + border-top: 1px solid var(--docs-hub-border); + padding: rem(24px) rem(48px); + font-size: rem(14px); + color: var(--docs-hub-footer-fg); + + @media (max-width: $mantine-breakpoint-sm) { + padding: rem(20px); + font-size: rem(13px); + } +} + +.footerInner { + display: flex; + align-items: center; + justify-content: flex-end; + + @media (max-width: $mantine-breakpoint-sm) { + flex-direction: column; + align-items: center; + justify-content: center; + gap: rem(12px); + } +} + +.footerBranding { + color: var(--docs-hub-fg); + font-weight: 600; + text-decoration: none; + + @media (hover: hover) { + &:hover { + color: var(--docs-hub-brand); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-hub-brand); + outline-offset: 2px; + border-radius: rem(2px); + } +} + +@media (prefers-reduced-motion: reduce) { + .card { + transition: none; + } +} diff --git a/apps/client/src/features/public-space/components/docs/docs-page-nav.tsx b/apps/client/src/features/public-space/components/docs/docs-page-nav.tsx new file mode 100644 index 000000000..f98d3ee93 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-page-nav.tsx @@ -0,0 +1,70 @@ +import { useMemo } from "react"; +import { Link, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react"; +import { useDocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx"; +import { flattenTreePreorder } from "@/features/public-space/utils/docs-tree.ts"; +import { extractPageSlugId } from "@/lib"; +import { SharedPageTreeNode } from "@/features/share/utils.ts"; +import styles from "./docs.module.css"; + +export default function DocsPageNav() { + const { t } = useTranslation(); + const { pageSlug } = useParams(); + const { treeData, getNodeUrl } = useDocsSurface(); + + const { prev, next } = useMemo(() => { + if (!treeData?.length) { + return { + prev: null as SharedPageTreeNode | null, + next: null as SharedPageTreeNode | null, + }; + } + const flat = flattenTreePreorder(treeData); + const currentSlugId = pageSlug + ? extractPageSlugId(pageSlug) + : treeData[0]?.slugId; + const index = flat.findIndex((node) => node.slugId === currentSlugId); + return { + prev: index > 0 ? flat[index - 1] : null, + next: index >= 0 && index < flat.length - 1 ? flat[index + 1] : null, + }; + }, [treeData, pageSlug]); + + if (!prev && !next) return null; + + return ( + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-search-button.tsx b/apps/client/src/features/public-space/components/docs/docs-search-button.tsx new file mode 100644 index 000000000..20fc8e778 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-search-button.tsx @@ -0,0 +1,22 @@ +import { IconSearch } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { platformModifierLabel } from "@/lib"; +import styles from "./docs.module.css"; + +type DocsSearchButtonProps = { + onClick: () => void; +}; + +export default function DocsSearchButton({ onClick }: DocsSearchButtonProps) { + const { t } = useTranslation(); + + return ( + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-shell.tsx b/apps/client/src/features/public-space/components/docs/docs-shell.tsx new file mode 100644 index 000000000..1b630644f --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-shell.tsx @@ -0,0 +1,182 @@ +import React from "react"; +import { ActionIcon, Drawer, Tooltip } from "@mantine/core"; +import { Link } from "react-router-dom"; +import { useAtom } from "jotai"; +import { useTranslation } from "react-i18next"; +import { IconList, IconMenu2 } from "@tabler/icons-react"; +import clsx from "clsx"; +import { + docsMobileSidebarAtom, + docsMobileTocAtom, +} from "@/features/public-space/atoms/public-space-atoms.ts"; +import { + DocsSurface, + DocsSurfaceProvider, +} from "@/features/public-space/components/docs/docs-surface-context.tsx"; +import DocsSidebarTree from "@/features/public-space/components/docs/docs-sidebar-tree.tsx"; +import DocsToc from "@/features/public-space/components/docs/docs-toc.tsx"; +import DocsEditPage from "@/features/public-space/components/docs/docs-edit-page.tsx"; +import DocsCopyPage from "@/features/public-space/components/docs/docs-copy-page.tsx"; +import DocsSearchButton from "@/features/public-space/components/docs/docs-search-button.tsx"; +import DocsThemeToggle from "@/features/public-space/components/docs/docs-theme-toggle.tsx"; +import DocsFooterBranding from "@/features/public-space/components/docs/docs-footer-branding.tsx"; +import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx"; +import { SearchMobileControl } from "@/features/search/components/search-control.tsx"; +import styles from "./docs.module.css"; + +const MemoizedDocsSidebarTree = React.memo(DocsSidebarTree); + +type DocsShellProps = { + surface: DocsSurface; + onSearchOpen?: () => void; + searchSpotlight?: React.ReactNode; + children: React.ReactNode; +}; + +export default function DocsShell({ + surface, + onSearchOpen, + searchSpotlight, + children, +}: DocsShellProps) { + const { t } = useTranslation(); + const { hasSidebar, siteName, homeUrl, showBranding, showEditPage } = surface; + + const [mobileSidebarOpen, setMobileSidebarOpen] = useAtom( + docsMobileSidebarAtom, + ); + const [mobileTocOpen, setMobileTocOpen] = useAtom(docsMobileTocAtom); + + return ( + +
    + + +
    +
    +
    + {hasSidebar && ( + + setMobileSidebarOpen((value) => !value)} + aria-label={t("Toggle sidebar")} + aria-expanded={mobileSidebarOpen} + > + + + + )} + + {!hasSidebar && siteName && homeUrl && ( + + {siteName} + + )} +
    + +
    + {onSearchOpen && ( +
    + +
    + )} +
    + +
    + {onSearchOpen && ( + + + + )} + + +
    +
    +
    + +
    + + +
    +
    +
    + + + + setMobileTocOpen(true)} + size="md" + aria-label={t("Table of contents")} + > + + + + +
    + {children} + {showBranding && ( + + )} +
    +
    + + +
    + + setMobileSidebarOpen(false)} + title={siteName} + size={300} + padding="sm" + > +
    + {hasSidebar && } +
    +
    + + setMobileTocOpen(false)} + position="right" + size={300} + padding="md" + > + + {showEditPage && } + + + {searchSpotlight} +
    +
    + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-sidebar-tree.tsx b/apps/client/src/features/public-space/components/docs/docs-sidebar-tree.tsx new file mode 100644 index 000000000..183fae8e9 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-sidebar-tree.tsx @@ -0,0 +1,198 @@ +import { SharedPageTreeNode } from "@/features/share/utils.ts"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import { Link, useParams } from "react-router-dom"; +import { useAtom, useSetAtom } from "jotai"; +import { useTranslation } from "react-i18next"; +import { IconChevronRight } from "@tabler/icons-react"; +import { ActionIcon } from "@mantine/core"; +import { extractPageSlugId } from "@/lib"; +import { + DocTree, + type DocTreeApi, + type RenderRowProps, +} from "@/features/page/tree/components/doc-tree"; +import { + docsMobileSidebarAtom, + openPublicSpaceTreeNodesAtom, +} from "@/features/public-space/atoms/public-space-atoms.ts"; +import { useDocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx"; +import { findAncestorTrail } from "@/features/public-space/utils/docs-tree.ts"; +import styles from "./docs.module.css"; + +export default function DocsSidebarTree() { + const { t } = useTranslation(); + const treeRef = useRef(null); + const { pageSlug } = useParams(); + const { treeData, getNodeUrl } = useDocsSurface(); + const [openTreeNodes, setOpenTreeNodes] = useAtom( + openPublicSpaceTreeNodesAtom, + ); + + // The first root page is the surface home, served at the bare URL. + const firstRootSlugId = treeData?.[0]?.slugId; + + const currentNodeId = pageSlug ? extractPageSlugId(pageSlug) : firstRootSlugId; + + const openIds = useMemo( + () => new Set(Object.keys(openTreeNodes).filter((k) => openTreeNodes[k])), + [openTreeNodes], + ); + + useEffect(() => { + // Auto-open the first level of the 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]); + + // Reveal the current page: expand its ancestor trail (deep links land with + // everything collapsed otherwise) and the page itself when it has children. + useEffect(() => { + if (!currentNodeId || !treeData?.length) return; + const trail = findAncestorTrail(treeData, currentNodeId); + if (trail === null) return; + setOpenTreeNodes((prev) => { + const next = { ...prev }; + let changed = false; + for (const node of [...trail.map((n) => n.slugId), currentNodeId]) { + if (!next[node]) { + next[node] = true; + changed = true; + } + } + return changed ? next : prev; + }); + }, [currentNodeId, treeData, setOpenTreeNodes]); + + useEffect(() => { + if (currentNodeId) { + treeRef.current?.select(currentNodeId, { scrollIntoView: true }); + } + }, [currentNodeId, treeData]); + + const handleToggle = useCallback( + (id: string, isOpen: boolean) => + setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen })), + [setOpenTreeNodes], + ); + const getDragLabel = useCallback( + (n: SharedPageTreeNode) => n.name || "untitled", + [], + ); + + const renderRow = useCallback( + (props: RenderRowProps) => ( + + ), + [getNodeUrl], + ); + + if (!treeData?.length) { + return null; + } + + return ( + + readOnly + ref={treeRef} + data={treeData} + openIds={openIds} + selectedId={currentNodeId} + renderRow={renderRow} + indentPerLevel={INDENT_PER_LEVEL} + rowHeight={36} + dynamicRowHeight + rowClassName={styles.treeNodeChrome} + onMove={noopMove} + onToggle={handleToggle} + getDragLabel={getDragLabel} + aria-label={t("Pages")} + /> + ); +} + +// Module-scope noop so it's a stable reference across renders. +const noopMove = () => {}; + +const INDENT_PER_LEVEL = 16; + + +type DocsTreeRowProps = RenderRowProps & { + getNodeUrl: (node: Pick) => string; +}; + +function DocsTreeRow({ + node, + level, + isOpen, + hasChildren, + isSelected, + rowRef, + tabIndex, + treeItemProps, + toggleOpen, + getNodeUrl, +}: DocsTreeRowProps) { + const { t } = useTranslation(); + const setMobileSidebarOpen = useSetAtom(docsMobileSidebarAtom); + + return ( + } + tabIndex={tabIndex} + {...treeItemProps} + data-selected={isSelected || undefined} + data-open-parent={(level === 0 && isOpen && hasChildren) || undefined} + className={styles.treeRow} + to={getNodeUrl(node)} + onClick={() => { + setMobileSidebarOpen(false); + }} + > + {/* One segment per ancestor level; contiguous rows join into a rail. */} + {Array.from({ length: level }, (_, ancestor) => ( + + ))} + {node.icon && ( + + {node.icon} + + )} + {node.name || t("untitled")} + {hasChildren && ( + { + e.preventDefault(); + e.stopPropagation(); + toggleOpen(); + }} + > + + + )} + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-surface-context.tsx b/apps/client/src/features/public-space/components/docs/docs-surface-context.tsx new file mode 100644 index 000000000..db5c23651 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-surface-context.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext } from "react"; +import { SharedPageTreeNode } from "@/features/share/utils.ts"; + +// What varies between the public surfaces (/docs and /share) rendered by the +// docs shell; every shell component reads this contract instead of a feature. +export type DocsSurface = { + treeData: SharedPageTreeNode[] | null; + hasSidebar: boolean; + siteName?: string; + homeUrl?: string; + getNodeUrl: (node: Pick) => string; + showBranding: boolean; + showEditPage: boolean; + brandingRef?: string; +}; + +const DocsSurfaceContext = createContext(null); + +export const DocsSurfaceProvider = DocsSurfaceContext.Provider; + +export function useDocsSurface(): DocsSurface { + const surface = useContext(DocsSurfaceContext); + if (!surface) { + throw new Error("useDocsSurface must be used within DocsShell"); + } + return surface; +} diff --git a/apps/client/src/features/public-space/components/docs/docs-theme-toggle.tsx b/apps/client/src/features/public-space/components/docs/docs-theme-toggle.tsx new file mode 100644 index 000000000..fdc8d6523 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-theme-toggle.tsx @@ -0,0 +1,35 @@ +import { + ActionIcon, + Tooltip, + useComputedColorScheme, + useMantineColorScheme, +} from "@mantine/core"; +import { IconMoon, IconSun } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import styles from "./docs.module.css"; + +export default function DocsThemeToggle() { + const { t } = useTranslation(); + const { setColorScheme } = useMantineColorScheme(); + const computedColorScheme = useComputedColorScheme("light"); + + return ( + + + setColorScheme(computedColorScheme === "light" ? "dark" : "light") + } + aria-label={t("Toggle color scheme")} + > + {computedColorScheme === "light" ? ( + + ) : ( + + )} + + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-toc.tsx b/apps/client/src/features/public-space/components/docs/docs-toc.tsx new file mode 100644 index 000000000..ceb43641f --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-toc.tsx @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useState } from "react"; +import { TextSelection } from "@tiptap/pm/state"; +import { useAtomValue } from "jotai"; +import { useTranslation } from "react-i18next"; +import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; +import { + HeadingLink, + recalculateLinks, +} from "@/features/editor/components/table-of-contents/table-of-contents.tsx"; +import styles from "./docs.module.css"; + +function getHeaderOffset(): number { + const raw = getComputedStyle(document.documentElement).getPropertyValue( + "--docs-header-h", + ); + const parsed = parseInt(raw, 10); + return Number.isNaN(parsed) ? 56 : parsed; +} + +export default function DocsToc() { + const { t } = useTranslation(); + const editor = useAtomValue(readOnlyEditorAtom); + const [links, setLinks] = useState([]); + const [headingDOMNodes, setHeadingDOMNodes] = useState([]); + const [activeElement, setActiveElement] = useState(null); + + const handleUpdate = useCallback(() => { + if (!editor || editor.isDestroyed) return; + const result = recalculateLinks(editor.$nodes("heading")); + setLinks(result.links); + setHeadingDOMNodes(result.nodes); + }, [editor]); + + useEffect(() => { + // "create" repopulates once the editor view mounts after this component. + editor?.on("create", handleUpdate); + editor?.on("update", handleUpdate); + handleUpdate(); + + return () => { + editor?.off("create", handleUpdate); + editor?.off("update", handleUpdate); + }; + }, [editor, handleUpdate]); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setActiveElement(entry.target as HTMLElement); + } + }); + }, + { + rootMargin: `-${getHeaderOffset()}px 0px -85% 0px`, + threshold: 0, + root: null, + }, + ); + + headingDOMNodes.forEach((heading) => observer.observe(heading)); + return () => { + headingDOMNodes.forEach((heading) => observer.unobserve(heading)); + }; + }, [headingDOMNodes]); + + const handleScrollToHeading = (position: number) => { + if (!editor || editor.isDestroyed) return; + const { view } = editor; + + const { node } = view.domAtPos(position); + const element = node as HTMLElement; + const scrollPosition = + element.getBoundingClientRect().top + + window.scrollY - + getHeaderOffset() - + 16; + + window.scrollTo({ top: scrollPosition, behavior: "smooth" }); + + const tr = view.state.tr; + tr.setSelection(new TextSelection(tr.doc.resolve(position))); + view.dispatch(tr); + view.focus(); + }; + + if (!links.length) { + return null; + } + + const minLevel = Math.min(...links.map((link) => link.level)); + const effectiveActive = activeElement ?? links[0]?.element; + + return ( +
    + {t("On this page")} +
    + {links.map((item, idx) => ( + + ))} +
    +
    + ); +} 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 new file mode 100644 index 000000000..6f008a429 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs.module.css @@ -0,0 +1,902 @@ +/* Design tokens for the public docs surface. Declared on :root (not .root) + * because mobile drawers and the spotlight render in portals outside the shell + * subtree. The accent pair is overridden at runtime by docs-theme.ts. */ +/* light-dark() cannot live in a bare :root block (the transform emits a + * descendant selector that never matches :root), so dark values get their own + * attribute-qualified block; flipping semantics ride Mantine's own vars. */ +:root { + --docs-header-h: 56px; + --docs-sidebar-w: 280px; + --docs-toc-w: 240px; + --docs-site-max: 96rem; + --docs-content-max: 54rem; + --docs-radius: 6px; + + --docs-accent: #2b7af1; + --docs-accent-soft: color-mix(in srgb, var(--docs-accent) 10%, transparent); + + /* Cloudflare-style single-ink model: one foreground for headings, bold, and + * body on a just-off-white page; neither end of the scale is pure. */ + --docs-bg: oklch(99% 0 0); + --docs-fg: oklch(21% 0 0); + --docs-content-fg: var(--docs-fg); + --docs-nav-fg: oklch(47% 0 0); + --docs-muted: var(--mantine-color-dimmed); + --docs-hover: var(--mantine-color-default-hover); + --docs-faint: var(--mantine-color-gray-6); + --docs-border: var(--mantine-color-gray-2); + --docs-header-bg: color-mix(in srgb, var(--docs-bg) 78%, transparent); +} + +:root[data-mantine-color-scheme="dark"] { + --docs-bg: var(--mantine-color-body); + --docs-fg: oklch(90% 0 0); + --docs-nav-fg: oklch(72% 0 0); + --docs-faint: var(--mantine-color-dark-2); + --docs-border: var(--mantine-color-dark-5); +} + +.root { + min-height: 100dvh; + background-color: var(--docs-bg); + color: var(--docs-fg); +} + +/* ---------- Header ---------- */ + +.header { + position: sticky; + top: 0; + z-index: 90; + height: var(--docs-header-h); + padding-inline: rem(20px); + background-color: var(--docs-header-bg); + backdrop-filter: saturate(180%) blur(10px); + -webkit-backdrop-filter: saturate(180%) blur(10px); + border-bottom: 1px solid var(--docs-border); +} + +.headerInner { + max-width: var(--docs-site-max); + margin-inline: auto; + height: 100%; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: rem(16px); +} + +.headerLeft { + display: flex; + align-items: center; + gap: rem(10px); + min-width: 0; +} + +.headerCenter { + display: flex; + justify-content: center; + min-width: 0; +} + +.headerRight { + display: flex; + align-items: center; + justify-content: flex-end; + gap: rem(4px); +} + +.searchSlot { + @media (max-width: $mantine-breakpoint-sm) { + display: none; + } +} + +.mobileOnly { + display: inline-flex; + + @media (min-width: $mantine-breakpoint-sm) { + display: none; + } +} + +/* Plain space name in the header, used only when there is no sidebar to + * carry it. The brand slot is reserved for future org logo/name support. */ +.headerSpaceName { + font-size: rem(14.5px); + font-weight: 600; + letter-spacing: -0.011em; + color: var(--docs-fg); + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-radius: var(--docs-radius); + padding: rem(4px) rem(6px); + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 1px; + } +} + +/* Burger appears only once the sidebar column is collapsed. */ +@media (min-width: 64em) { + .sidebarToggle { + display: none !important; + } +} + +.headerAction { + color: var(--docs-faint); + border-radius: var(--docs-radius); + + @media (hover: hover) { + &:hover { + color: var(--docs-fg); + background-color: var(--docs-hover); + } + } +} + +/* ---------- Search ---------- */ + +.searchButton { + display: flex; + align-items: center; + gap: rem(8px); + width: rem(320px); + height: rem(34px); + padding-inline: rem(10px); + border: 1px solid var(--docs-border); + border-radius: rem(8px); + background-color: var(--docs-bg); + color: var(--docs-muted); + font-size: rem(13px); + cursor: pointer; + transition: border-color 120ms ease; + + @media (hover: hover) { + &:hover { + border-color: light-dark( + var(--mantine-color-gray-4), + var(--mantine-color-dark-3) + ); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 1px; + } + + @media (max-width: 62em) { + width: rem(220px); + } +} + +.searchLabel { + flex: 1; + text-align: left; +} + +.searchKbd { + font-size: rem(11px); + font-weight: 500; + color: var(--docs-faint); + border: 1px solid var(--docs-border); + border-radius: rem(4px); + padding: rem(1px) rem(5px); + line-height: 1.4; +} + +/* Quiet bordered chip in the docs palette instead of Mantine's full-ink default. */ +.copyPageButton { + color: var(--docs-muted); + border-color: var(--docs-border); + background-color: transparent; + font-size: rem(13px); + font-weight: 500; + border-radius: var(--docs-radius); + + @media (hover: hover) { + &:hover { + color: var(--docs-fg); + background-color: var(--docs-hover); + } + } +} + +/* Page actions pinned to the article's top-right corner; on small viewports + * they drop into flow above the content so breadcrumbs never run under them. */ +.articleActions { + position: absolute; + top: rem(30px); + right: rem(24px); + display: inline-flex; + align-items: center; + gap: rem(6px); + + @media (max-width: $mantine-breakpoint-sm) { + position: static; + display: flex; + justify-content: flex-end; + margin-bottom: rem(4px); + } +} + +/* Below the rail breakpoint the toc opens as a drawer overlay instead. */ +.tocOverlayControl { + display: inline-flex; + + @media (min-width: 75em) { + display: none; + } +} + +/* ---------- Body grid ---------- */ + +/* Contained site layout: rails anchor the edges of a centered max-width + * container, the article centers itself in the fixed middle track. Hiding a + * rail keeps its track, so toggling never moves the content column. */ +.body { + max-width: var(--docs-site-max); + margin-inline: auto; + display: grid; + grid-template-columns: + var(--docs-sidebar-w) + minmax(0, 1fr) + var(--docs-toc-w); + align-items: start; +} + +.sidebar { + grid-column: 1; + width: var(--docs-sidebar-w); + position: sticky; + top: var(--docs-header-h); + height: calc(100dvh - var(--docs-header-h)); + display: flex; + flex-direction: column; + padding: rem(20px) rem(10px) rem(16px) rem(20px); + opacity: 1; + transform: translateX(0); + transition: + opacity 160ms ease, + transform 160ms ease; +} + +.sidebar[data-hidden="true"] { + visibility: hidden; + opacity: 0; + transform: translateX(rem(-8px)); +} + +.sidebarTitle { + display: block; + font-size: rem(14px); + font-weight: 600; + letter-spacing: -0.011em; + color: var(--docs-fg); + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-radius: var(--docs-radius); + padding: rem(5px) rem(8px); + + @media (hover: hover) { + &:hover { + color: var(--docs-accent); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: -2px; + } +} + +.sidebarDivider { + height: 1px; + background-color: var(--docs-border); + margin: rem(10px) 0 rem(14px); + flex-shrink: 0; +} + +.sidebarScroll { + flex: 1; + min-height: 0; +} + +.main { + grid-column: 2; + min-width: 0; +} + +.article { + max-width: var(--docs-content-max); + margin-inline: auto; + padding: rem(36px) rem(32px) rem(72px); + position: relative; +} + +.toc { + grid-column: 3; + width: var(--docs-toc-w); + position: sticky; + top: var(--docs-header-h); + max-height: calc(100dvh - var(--docs-header-h)); + overflow-y: auto; + scrollbar-width: thin; + padding: rem(36px) rem(16px) rem(24px) rem(4px); +} + +@media (max-width: 75em) { + .body { + grid-template-columns: var(--docs-sidebar-w) minmax(0, 1fr); + } + + .toc { + display: none; + } +} + +/* Below 64em the sidebar collapses into the burger drawer. */ +@media (max-width: 64em) { + .body { + display: block; + } + + .sidebar { + display: none; + } +} + +@media (max-width: $mantine-breakpoint-sm) { + .article { + padding: rem(24px) rem(20px) rem(56px); + } +} + +@media (prefers-reduced-motion: reduce) { + .body, + .sidebar, + .toc, + .searchButton { + transition: none; + } +} + +/* ---------- Sidebar tree ---------- */ + +/* Applied through DocTree's rowClassName onto the engine wrapper that carries + * data-selected. Class doubled to outrank the engine's own module styles. */ +.treeNodeChrome.treeNodeChrome { + border-radius: var(--docs-radius); + color: var(--docs-nav-fg); + + @media (hover: hover) { + &:hover { + background-color: var(--docs-hover); + color: var(--docs-fg); + } + } +} + +.treeNodeChrome.treeNodeChrome[data-selected="true"] { + background-color: var(--docs-accent-soft); +} + +.treeRow { + position: relative; + display: flex; + align-items: flex-start; + gap: rem(8px); + width: 100%; + min-width: 0; + min-height: rem(32px); + padding: rem(6px) rem(4px) rem(6px) rem(8px); + text-decoration: none; + color: inherit; + font-size: rem(14px); + font-weight: 400; + line-height: 1.45; + border-radius: var(--docs-radius); + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: -2px; + } +} + +/* Expanded parents read as section headers, Cloudflare-style. */ +.treeRow[data-open-parent="true"] { + color: var(--docs-fg); + font-weight: 500; +} + +.treeRow[data-selected="true"] { + color: var(--docs-accent); + font-weight: 500; +} + +.treeIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: rem(18px); + font-size: rem(14px); + line-height: 1; + flex-shrink: 0; + margin-top: rem(2px); +} + +/* Names wrap instead of truncating: the sidebar has a fixed width, so an + * ellipsis would permanently hide the tail of long titles. */ +.treeText { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; +} + +/* Nesting rail: each row draws its ancestors' segments in the indent gutter; + * the -2px bleed covers the engine's row padding so segments connect. */ +.treeGuide { + position: absolute; + top: 0; + bottom: rem(-2px); + width: 1px; + background-color: var(--docs-border); + pointer-events: none; +} + +.treeChevron { + flex-shrink: 0; + color: var(--docs-faint); + transition: transform 140ms ease; +} + +.treeChevron[data-open="true"] { + transform: rotate(90deg); +} + +@media (prefers-reduced-motion: reduce) { + .treeChevron { + transition: none; + } +} + +/* ---------- Table of contents ---------- */ + +.tocLabel { + display: block; + font-size: rem(11px); + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--docs-muted); + margin-bottom: rem(10px); +} + +.tocList { + border-left: 1px solid var(--docs-border); +} + +.tocLink { + display: block; + width: 100%; + text-align: left; + background: none; + border: 0; + border-left: 2px solid transparent; + margin-left: -1px; + padding: rem(4px) rem(8px) rem(4px) rem(11px); + font-size: rem(13px); + line-height: 1.45; + color: var(--docs-muted); + cursor: pointer; + overflow-wrap: break-word; + + @media (hover: hover) { + &:hover { + color: var(--docs-fg); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: -2px; + border-radius: rem(2px); + } +} + +.tocLink[data-active="true"] { + color: var(--docs-accent); + border-left-color: var(--docs-accent); + font-weight: 500; +} + +/* ---------- Edit page (signed-in visitors) ---------- */ + +.editPageLink { + display: flex; + align-items: center; + gap: rem(6px); + margin-top: rem(16px); + padding-top: rem(14px); + border-top: 1px solid var(--docs-border); + font-size: rem(13px); + color: var(--docs-muted); + text-decoration: none; + + @media (hover: hover) { + &:hover { + color: var(--docs-fg); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 2px; + border-radius: rem(2px); + } +} + +/* ---------- Sidebar branding experiments (GitBook card / ReadMe line) ---------- */ + +/* ---------- Footer branding ---------- */ + +.footer { + margin-top: rem(40px); + padding-top: rem(16px); + border-top: 1px solid var(--docs-border); +} + +.footerBranding { + font-size: rem(14px); + color: var(--docs-muted); + text-decoration: none; + + @media (hover: hover) { + &:hover { + color: var(--docs-fg); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 2px; + border-radius: rem(2px); + } +} + +/* ---------- Breadcrumbs ---------- */ + +.breadcrumbs { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: rem(4px); + font-size: rem(13px); + color: var(--docs-muted); + margin-bottom: rem(6px); + /* keep long trails clear of the pinned page actions */ + padding-right: rem(160px); + + @media (max-width: $mantine-breakpoint-sm) { + padding-right: 0; + } +} + +.crumbLink { + color: var(--docs-muted); + text-decoration: none; + border-radius: rem(4px); + padding: rem(1px) rem(3px); + max-width: rem(220px); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + @media (hover: hover) { + &:hover { + color: var(--docs-accent); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 0; + } +} + +.crumbSeparator { + color: light-dark( + var(--mantine-color-gray-4), + var(--mantine-color-dark-3) + ); + user-select: none; +} + +.crumbEllipsis { + color: var(--docs-muted); + border: 0; + background: none; + cursor: pointer; + border-radius: rem(4px); + padding: rem(1px) rem(4px); + + @media (hover: hover) { + &:hover { + color: var(--docs-accent); + background-color: var(--docs-hover); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 0; + } +} + +/* ---------- Byline ---------- */ + +/* Pulled up into the title's own bottom margin so it reads as one block. */ +.byline { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: rem(8px); + margin-top: rem(-16px); + margin-bottom: rem(28px); + font-size: rem(13px); + color: var(--docs-muted); +} + +.bylineAuthor { + display: inline-flex; + align-items: center; + gap: rem(6px); +} + +.bylineDot { + color: var(--docs-faint); + user-select: none; +} + +/* ---------- Prev / next ---------- */ + +.pageNav { + display: grid; + grid-template-columns: 1fr 1fr; + gap: rem(12px); + margin-top: rem(48px); +} + +.pageNavCard { + display: flex; + flex-direction: column; + gap: rem(4px); + padding: rem(12px) rem(16px); + border: 1px solid var(--docs-border); + border-radius: rem(10px); + text-decoration: none; + min-width: 0; + transition: border-color 120ms ease; + + @media (hover: hover) { + &:hover { + border-color: var(--docs-accent); + } + + &:hover .pageNavTitle { + color: var(--docs-accent); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 1px; + } +} + +.pageNavCard[data-direction="next"] { + grid-column: 2; + align-items: flex-end; + text-align: right; +} + +.pageNavLabel { + display: inline-flex; + align-items: center; + gap: rem(4px); + font-size: rem(12px); + color: var(--docs-muted); +} + +.pageNavTitle { + font-size: rem(14px); + font-weight: 500; + color: var(--docs-fg); + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: color 120ms ease; +} + +@media (max-width: $mantine-breakpoint-sm) { + .pageNav { + grid-template-columns: 1fr; + } + + .pageNavCard[data-direction="next"] { + grid-column: auto; + } +} + +/* ---------- Content ---------- */ + +/* Reading typography: body copy softens to a blue-gray with relaxed leading + * while headings and bold keep full contrast. Class doubled to outrank the + * shared .public-typography metrics regardless of stylesheet order. */ +.root.root :global(.ProseMirror) { + color: var(--docs-content-fg); + line-height: 1.75; + letter-spacing: normal; + /* The article owns horizontal spacing here; the editor's 3rem gutter would + * misalign content with the breadcrumb, and its own background bands + * against the off-white page. */ + padding-left: 0; + padding-right: 0; + background-color: transparent; +} + +/* Wide columns bleed into the editor gutter that no longer exists here. */ +.root.root :global(div[data-type="columns"][data-width-mode="wide"]) { + margin-left: 0; + margin-right: 0; + width: 100%; +} + +/* One ink by inheritance: Mantine's baseline gives headings an explicit + * color, which forks them from the body. Neutralize instead of re-declaring, + * so changing the single content foreground repaints all text. */ +.root.root :global(.ProseMirror) h1, +.root.root :global(.ProseMirror) h2, +.root.root :global(.ProseMirror) h3, +.root.root :global(.ProseMirror) h4, +.root.root :global(.ProseMirror) h5, +.root.root :global(.ProseMirror) h6, +.root.root :global(.ProseMirror) strong { + color: inherit; +} + +/* Modest semibold heading scale (Cloudflare-style); class doubled to outrank + * the shared editor and .public-typography rules. */ +.root.root :global(.ProseMirror) h1 { + font-size: 2.1875rem; + font-weight: 600; + letter-spacing: -0.025em; + line-height: 1.25; +} + +.root.root :global(.ProseMirror) h2 { + font-size: 1.3rem; + font-weight: 600; + letter-spacing: -0.015em; + line-height: 1.4; +} + +.root.root :global(.ProseMirror) h3 { + font-size: 1.1rem; + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.45; +} + +.root.root :global(.ProseMirror) h4, +.root.root :global(.ProseMirror) h5, +.root.root :global(.ProseMirror) h6 { + font-size: 1rem; + font-weight: 600; + line-height: 1.5; +} + +.root.root :global(.ProseMirror) strong { + font-weight: 600; +} + +.root.root :global(.page-title .ProseMirror) h1 { + font-size: 2.1875rem; + font-weight: 600; + letter-spacing: -0.025em; + line-height: 1.25; +} + +.root :global(.ProseMirror) a { + color: var(--docs-accent); +} + +/* Internal page links (mentions, subpages lists) 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"] { + color: var(--docs-accent) !important; +} + +.root :global(.ProseMirror) [class*="pageMentionText"] { + border-bottom-color: color-mix(in srgb, var(--docs-accent) 40%, transparent); +} + +.root :global(.ProseMirror) h1, +.root :global(.ProseMirror) h2, +.root :global(.ProseMirror) h3, +.root :global(.ProseMirror) h4 { + scroll-margin-top: calc(var(--docs-header-h) + rem(16px)); +} + +/* ---------- Content tables ---------- */ + +/* The wrapper carries the rounded outer border so border-collapse never + * fights border-radius; its overflow-x clips the corners. */ +.root.root :global(.ProseMirror .tableWrapper) { + border: 1px solid var(--docs-border); + border-radius: rem(10px); +} + +.root.root :global(.ProseMirror table td), +.root.root :global(.ProseMirror table th) { + border: 0; + border-bottom: 1px solid var(--docs-border); + border-right: 1px solid var(--docs-border); + padding: rem(10px) rem(14px); +} + +.root.root :global(.ProseMirror table :is(td, th):last-child) { + border-right: 0; +} + +.root.root :global(.ProseMirror table tr:last-child td), +.root.root :global(.ProseMirror table tr:last-child th) { + border-bottom: 0; +} + +.root.root :global(.ProseMirror table th) { + background-color: var(--docs-hover); + color: var(--docs-fg); + font-weight: 600; +} + +/* Round the corner cells too for the no-overflow (pinned header) variant, + * where the wrapper does not clip. */ +.root.root :global(.ProseMirror table tr:first-child :is(th, td):first-child) { + border-top-left-radius: rem(9px); +} + +.root.root :global(.ProseMirror table tr:first-child :is(th, td):last-child) { + border-top-right-radius: rem(9px); +} + +.root.root :global(.ProseMirror table tr:last-child :is(th, td):first-child) { + border-bottom-left-radius: rem(9px); +} + +.root.root :global(.ProseMirror table tr:last-child :is(th, td):last-child) { + border-bottom-right-radius: rem(9px); +} + +.emptyState { + padding-top: rem(96px); + text-align: center; + color: var(--docs-muted); +} + +/* ---------- Mobile drawers ---------- */ + +.drawerTree { + height: calc(100dvh - rem(60px)); + display: flex; + flex-direction: column; +} diff --git a/apps/client/src/features/public-space/components/public-space-layout.tsx b/apps/client/src/features/public-space/components/public-space-layout.tsx new file mode 100644 index 000000000..2bd01b20a --- /dev/null +++ b/apps/client/src/features/public-space/components/public-space-layout.tsx @@ -0,0 +1,69 @@ +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 { usePublicSpaceTreeQuery } from "@/features/public-space/queries/public-space-query.ts"; +import { buildSharedPageTree } from "@/features/share/utils.ts"; +import { + publicSpaceTreeAtom, + publicSpaceTreeDataAtom, +} from "@/features/public-space/atoms/public-space-atoms.ts"; +import { useDocsAccent } from "@/features/public-space/theme/docs-theme.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 { buildPublicSpaceUrl } from "@/features/page/page.utils.ts"; +import { PublicSpaceSearchSpotlight } from "@/features/search/components/public-space-search-spotlight.tsx"; +import { publicSpaceSearchSpotlight } from "@/features/search/constants"; + +export default function PublicSpaceLayout() { + const { spaceSlug } = useParams(); + const { data } = usePublicSpaceTreeQuery(spaceSlug); + + useDocsAccent(data?.appearance); + + const setPublicSpaceTree = useSetAtom(publicSpaceTreeAtom); + const setPublicSpaceTreeData = useSetAtom(publicSpaceTreeDataAtom); + + const treeData = useMemo(() => { + if (!data?.pageTree) return null; + return buildSharedPageTree(data.pageTree); + }, [data?.pageTree]); + + useEffect(() => { + setPublicSpaceTree(data || null); + setPublicSpaceTreeData(treeData); + }, [data, treeData, setPublicSpaceTree, setPublicSpaceTreeData]); + + const surface = useMemo(() => { + const homeUrl = buildPublicSpaceUrl({ spaceSlug }); + // The first root page is the space home, served at the bare space URL. + const firstRootSlugId = treeData?.[0]?.slugId; + return { + treeData, + hasSidebar: (data?.pageTree?.length ?? 0) > 1, + siteName: data?.space?.name, + homeUrl, + getNodeUrl: (node) => + node.slugId === firstRootSlugId + ? homeUrl + : buildPublicSpaceUrl({ + spaceSlug, + pageSlugId: node.slugId, + pageTitle: node.name, + }), + showBranding: Boolean(data), + showEditPage: true, + }; + }, [data, treeData, spaceSlug]); + + return ( + } + > + + + ); +} diff --git a/apps/client/src/features/public-space/components/publish-space-settings.tsx b/apps/client/src/features/public-space/components/publish-space-settings.tsx new file mode 100644 index 000000000..332ebd373 --- /dev/null +++ b/apps/client/src/features/public-space/components/publish-space-settings.tsx @@ -0,0 +1,283 @@ +import { ActionIcon, Group, Text, Switch, TextInput } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { useAtom } from "jotai"; +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconExternalLink, IconWorld } from "@tabler/icons-react"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { ISpace } from "@/features/space/types/space.types.ts"; +import { IPublicSpace } from "@/features/public-space/types/public-space.types.ts"; +import { + usePublicSpaceForSpaceQuery, + usePublishSpaceMutation, +} from "@/features/public-space/queries/public-space-query.ts"; +import { getAppUrl } from "@/lib/config.ts"; +import CopyTextButton from "@/components/common/copy.tsx"; +import AppearanceSettings from "@/features/public-space/components/appearance-settings.tsx"; +import { isPublicSpacesAllowed } from "@/features/public-space/utils/public-space-access.ts"; + +type PublishSpaceSettingsProps = { + space: ISpace; +}; + +export default function PublishSpaceSettings({ + space, +}: PublishSpaceSettingsProps) { + const { t } = useTranslation(); + const [workspace] = useAtom(workspaceAtom); + + const allowPublicSpaces = isPublicSpacesAllowed(workspace); + + const { data: publicSpace } = usePublicSpaceForSpaceQuery( + allowPublicSpaces ? space?.id : undefined, + ); + const publishMutation = usePublishSpaceMutation(); + + const [published, setPublished] = useState(false); + const [searchIndexing, setSearchIndexing] = useState(false); + const [bylineAuthor, setBylineAuthor] = useState(false); + const [bylineUpdatedAt, setBylineUpdatedAt] = useState(true); + const [directoryListed, setDirectoryListed] = useState(false); + + const workspaceDirectoryEnabled = + workspace?.settings?.publicSpaces?.directory === true; + + const syncFromPublicSpace = (state?: IPublicSpace | null) => { + const byline = state?.settings?.byline; + setPublished(state?.enabled === true); + setSearchIndexing(state?.searchIndexing === true); + setBylineAuthor(byline?.author === true); + setBylineUpdatedAt(byline?.updatedAt !== false); + setDirectoryListed(state?.settings?.directory === true); + }; + + useEffect(() => { + syncFromPublicSpace(publicSpace); + }, [publicSpace]); + + if (!allowPublicSpaces || !space) { + return null; + } + + const publicUrl = `${getAppUrl()}/docs/${space.slug}`; + + const applyPublish = async (enabled: boolean) => { + try { + const result = await publishMutation.mutateAsync({ + spaceId: space.id, + enabled, + }); + syncFromPublicSpace(result); + } catch { + // error handled by mutation + } + }; + + const handlePublishChange = (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + if (!value) { + applyPublish(false); + return; + } + + modals.openConfirmModal({ + title: t("Publish space to the web"), + children: ( + + {t( + "Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?", + )} + + ), + centered: true, + labels: { confirm: t("Publish"), cancel: t("Cancel") }, + onConfirm: () => applyPublish(true), + }); + }; + + const handleIndexingChange = async ( + event: React.ChangeEvent, + ) => { + const value = event.currentTarget.checked; + try { + await publishMutation.mutateAsync({ + spaceId: space.id, + enabled: true, + searchIndexing: value, + }); + setSearchIndexing(value); + } catch { + // error handled by mutation + } + }; + + const handleBylineAuthorChange = async ( + event: React.ChangeEvent, + ) => { + const value = event.currentTarget.checked; + try { + await publishMutation.mutateAsync({ + spaceId: space.id, + enabled: true, + bylineAuthor: value, + }); + setBylineAuthor(value); + } catch { + // error handled by mutation + } + }; + + const handleBylineUpdatedAtChange = async ( + event: React.ChangeEvent, + ) => { + const value = event.currentTarget.checked; + try { + await publishMutation.mutateAsync({ + spaceId: space.id, + enabled: true, + bylineUpdatedAt: value, + }); + setBylineUpdatedAt(value); + } catch { + // error handled by mutation + } + }; + + const handleDirectoryChange = async ( + event: React.ChangeEvent, + ) => { + const value = event.currentTarget.checked; + try { + await publishMutation.mutateAsync({ + spaceId: space.id, + enabled: true, + directory: value, + }); + setDirectoryListed(value); + } catch { + // error handled by mutation + } + }; + + return ( +
    + +
    + {t("Publish space to the web")} + + {t("Make this space publicly readable by anyone on the internet.")} + +
    + +
    + + {published && ( + <> + +
    + {t("Allow search engines to index")} + + {t( + "Let public pages in this space appear in search engine results.", + )} + +
    + +
    + + +
    + {t("Show page author")} + + {t("Display the page creator's name on public pages.")} + +
    + +
    + + +
    + {t("Show last updated")} + + {t("Display when each page was last updated.")} + +
    + +
    + + {workspaceDirectoryEnabled && ( + +
    + {t("Show in public directory")} + + {t("List this space in the public directory at /docs.")} + +
    + +
    + )} + + + } + aria-label={t("Public space link")} + rightSection={ + + } + /> + + + + + + + {t("Renaming the space slug will break public links.")} + + + + + )} +
    + ); +} diff --git a/apps/client/src/features/public-space/components/published-spaces-list.tsx b/apps/client/src/features/public-space/components/published-spaces-list.tsx new file mode 100644 index 000000000..a4aec36a0 --- /dev/null +++ b/apps/client/src/features/public-space/components/published-spaces-list.tsx @@ -0,0 +1,210 @@ +import { Table, Group, Text, Anchor, Menu, ActionIcon } from "@mantine/core"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { modals } from "@mantine/modals"; +import { notifications } from "@mantine/notifications"; +import { + IconCopy, + IconDots, + IconExternalLink, + IconWorld, + IconWorldOff, +} from "@tabler/icons-react"; +import Paginate from "@/components/common/paginate.tsx"; +import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; +import { + usePublishedSpacesQuery, + usePublishSpaceMutation, +} from "@/features/public-space/queries/public-space-query.ts"; +import { IPublishedSpaceItem } from "@/features/public-space/types/public-space.types.ts"; +import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts"; +import { getAppUrl, getSpaceUrl } from "@/lib/config.ts"; +import { useClipboard } from "@/hooks/use-clipboard"; +import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; +import { EmptyState } from "@/components/ui/empty-state.tsx"; +import rowClasses from "@/components/ui/clickable-table-row.module.css"; + +export default function PublishedSpacesList() { + const { t } = useTranslation(); + const { cursor, goNext, goPrev } = useCursorPaginate(); + const { data, isLoading } = usePublishedSpacesQuery({ cursor }); + const locale = useDateFnsLocale(); + + if (!isLoading && data?.items.length === 0) { + return ; + } + + return ( + <> + + + + + {t("Space")} + {t("Published by")} + {t("Published at")} + + + + + {data?.items.map((item: IPublishedSpaceItem) => ( + + + + + + + {item.space.name} + + + + + + + + + {item.creator?.name} + + + + + + {formatLocalized( + item.createdAt, + "MMM dd, yyyy", + "PP", + locale, + )} + + + + + + + ))} + +
    +
    + + {data?.items.length > 0 && ( + goNext(data?.meta?.nextCursor)} + onPrev={goPrev} + /> + )} + + ); +} + +function PublishedSpaceActionMenu({ item }: { item: IPublishedSpaceItem }) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const clipboard = useClipboard(); + const publishMutation = usePublishSpaceMutation(); + + const publicPath = buildPublicSpaceUrl({ spaceSlug: item.space.slug }); + + const copyLink = () => { + clipboard.copy(`${getAppUrl()}${publicPath}`); + notifications.show({ message: t("Link copied") }); + }; + + const onUnpublish = async () => { + try { + await publishMutation.mutateAsync({ + spaceId: item.spaceId, + enabled: false, + }); + } catch { + // error handled by mutation + } + }; + + const openUnpublishModal = () => + modals.openConfirmModal({ + title: t("Unpublish space"), + children: ( + + {t( + "This space will no longer be publicly accessible. Are you sure?", + )} + + ), + centered: true, + labels: { confirm: t("Unpublish"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: onUnpublish, + }); + + return ( + + + + + + + + + }> + {t("Copy link")} + + + navigate(getSpaceUrl(item.space.slug))} + leftSection={} + > + {t("Open space")} + + + } + disabled={item.space?.userRole !== "admin"} + > + {t("Unpublish")} + + + + ); +} diff --git a/apps/client/src/features/public-space/components/space-public-notice.tsx b/apps/client/src/features/public-space/components/space-public-notice.tsx new file mode 100644 index 000000000..5655995f0 --- /dev/null +++ b/apps/client/src/features/public-space/components/space-public-notice.tsx @@ -0,0 +1,47 @@ +import { Alert, Anchor, Group, Text } from "@mantine/core"; +import { IconExternalLink, IconWorld } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { ISpace } from "@/features/space/types/space.types.ts"; +import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts"; +import { isBetaPublicSpaces } from "@/lib/config.ts"; + +type SpacePublicNoticeProps = { + space: ISpace; +}; + +export default function SpacePublicNotice({ space }: SpacePublicNoticeProps) { + const { t } = useTranslation(); + + if (!isBetaPublicSpaces() || !space?.isPublished) { + return null; + } + + return ( + } + title={t("This space is public")} + mb="lg" + > + + + {t( + "Anyone on the internet can read the pages in this space, except restricted pages.", + )} + + + {t("Open public site")} + + + + + ); +} diff --git a/apps/client/src/features/public-space/hooks/use-authenticated-user.ts b/apps/client/src/features/public-space/hooks/use-authenticated-user.ts new file mode 100644 index 000000000..644619b26 --- /dev/null +++ b/apps/client/src/features/public-space/hooks/use-authenticated-user.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import { getMyInfo } from "@/features/user/services/user-service"; +import { ICurrentUser } from "@/features/user/types/user.types"; + +/** Probes login state from public surfaces; the /docs 401 exemption keeps anonymous visitors off the login redirect. */ +export function useAuthenticatedUser(enabled = true) { + return useQuery({ + queryKey: ["currentUser"], + queryFn: getMyInfo, + retry: false, + staleTime: 5 * 60 * 1000, + enabled, + }); +} diff --git a/apps/client/src/features/public-space/hooks/use-docs-current-page.ts b/apps/client/src/features/public-space/hooks/use-docs-current-page.ts new file mode 100644 index 000000000..eb799ee0e --- /dev/null +++ b/apps/client/src/features/public-space/hooks/use-docs-current-page.ts @@ -0,0 +1,23 @@ +import { useMemo } from "react"; +import { useParams } from "react-router-dom"; +import { useDocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx"; +import { flattenTreePreorder } from "@/features/public-space/utils/docs-tree.ts"; +import { extractPageSlugId } from "@/lib"; +import { SharedPageTreeNode } from "@/features/share/utils.ts"; + +export function useDocsCurrentPage(): SharedPageTreeNode | null { + const { pageSlug } = useParams(); + const { treeData } = useDocsSurface(); + + return useMemo(() => { + if (!treeData?.length) return null; + const currentSlugId = pageSlug + ? extractPageSlugId(pageSlug) + : treeData[0]?.slugId; + return ( + flattenTreePreorder(treeData).find( + (node) => node.slugId === currentSlugId, + ) ?? null + ); + }, [treeData, pageSlug]); +} diff --git a/apps/client/src/features/public-space/queries/public-space-query.ts b/apps/client/src/features/public-space/queries/public-space-query.ts new file mode 100644 index 000000000..49cba8cc6 --- /dev/null +++ b/apps/client/src/features/public-space/queries/public-space-query.ts @@ -0,0 +1,109 @@ +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, + UseQueryResult, +} from "@tanstack/react-query"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; +import { + getPublicSpaceDirectory, + getPublicSpaceForSpace, + getPublicSpacePage, + getPublicSpaceTree, + getPublishedSpaces, + publishSpace, +} from "@/features/public-space/services/public-space-service.ts"; +import { + IPublicSpace, + IPublicSpaceDirectory, + IPublicSpacePage, + IPublicSpaceTree, + IPublishedSpaceItem, + IPublishSpace, +} from "@/features/public-space/types/public-space.types.ts"; +import { IPagination, QueryParams } from "@/lib/types.ts"; + +export function usePublicSpaceTreeQuery( + spaceSlug: string, +): UseQueryResult { + return useQuery({ + queryKey: ["public-space-tree", spaceSlug], + queryFn: () => getPublicSpaceTree(spaceSlug), + enabled: !!spaceSlug, + placeholderData: keepPreviousData, + staleTime: 60 * 60 * 1000, + }); +} + +export function usePublicSpacePageQuery(params: { + spaceSlug: string; + pageSlugId?: string; + contentless?: boolean; +}): UseQueryResult { + return useQuery({ + queryKey: ["public-space-page", params], + queryFn: () => getPublicSpacePage(params), + enabled: !!params.spaceSlug, + }); +} + +export function usePublicSpaceDirectoryQuery(): UseQueryResult< + IPublicSpaceDirectory, + Error +> { + return useQuery({ + queryKey: ["public-space-directory"], + queryFn: () => getPublicSpaceDirectory(), + }); +} + +export function usePublicSpaceForSpaceQuery( + spaceId: string, +): UseQueryResult { + return useQuery({ + queryKey: ["public-space-for-space", spaceId], + queryFn: () => getPublicSpaceForSpace(spaceId), + enabled: !!spaceId, + staleTime: 60 * 1000, + retry: false, + }); +} + +export function usePublishedSpacesQuery( + params?: QueryParams, +): UseQueryResult, Error> { + return useQuery({ + queryKey: ["published-spaces", params], + queryFn: () => getPublishedSpaces(params), + placeholderData: keepPreviousData, + }); +} + +export function usePublishSpaceMutation() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data) => publishSpace(data), + onSuccess: () => { + queryClient.invalidateQueries({ + predicate: (item) => + [ + "public-space-for-space", + "published-spaces", + "space", + "spaces", + ].includes(item.queryKey[0] as string), + }); + }, + onError: (error) => { + notifications.show({ + message: + error?.["response"]?.data?.message || t("Failed to update space"), + color: "red", + }); + }, + }); +} diff --git a/apps/client/src/features/public-space/services/public-space-service.ts b/apps/client/src/features/public-space/services/public-space-service.ts new file mode 100644 index 000000000..77899650d --- /dev/null +++ b/apps/client/src/features/public-space/services/public-space-service.ts @@ -0,0 +1,73 @@ +import api from "@/lib/api-client"; +import { IPagination, QueryParams } from "@/lib/types.ts"; +import { + IPublicSpace, + IPublicSpaceDirectory, + IPublicSpaceInfo, + IPublicSpacePage, + IPublicSpaceTree, + IPublishedSpaceItem, + IPublishSpace, +} from "@/features/public-space/types/public-space.types.ts"; + +export async function getPublishedSpaces( + params?: QueryParams, +): Promise> { + const req = await api.post>( + "/public-spaces", + params, + ); + return req.data; +} + +export async function getPublicSpaceInfo( + spaceSlug: string, +): Promise { + const req = await api.post("/public-spaces/info", { + spaceSlug, + }); + return req.data; +} + +export async function getPublicSpaceTree( + spaceSlug: string, +): Promise { + const req = await api.post("/public-spaces/tree", { + spaceSlug, + }); + return req.data; +} + +export async function getPublicSpacePage(params: { + spaceSlug: string; + pageSlugId?: string; + contentless?: boolean; +}): Promise { + const req = await api.post( + "/public-spaces/page-info", + params, + ); + return req.data; +} + +export async function getPublicSpaceDirectory(): Promise { + const req = await api.post( + "/public-spaces/directory", + {}, + ); + return req.data; +} + +export async function getPublicSpaceForSpace( + spaceId: string, +): Promise { + const req = await api.post("/public-spaces/for-space", { + spaceId, + }); + return req.data; +} + +export async function publishSpace(data: IPublishSpace): Promise { + const req = await api.post("/public-spaces/publish", data); + return req.data; +} diff --git a/apps/client/src/features/public-space/theme/docs-theme.ts b/apps/client/src/features/public-space/theme/docs-theme.ts new file mode 100644 index 000000000..4e9368335 --- /dev/null +++ b/apps/client/src/features/public-space/theme/docs-theme.ts @@ -0,0 +1,91 @@ +import { useEffect } from "react"; +import { useComputedColorScheme } from "@mantine/core"; +import { IPublicSpaceAppearance } from "@/features/public-space/types/public-space.types.ts"; + +export type DocsThemePreset = { + id: string; + nameKey: string; + light: string; + dark: string; +}; + +export const DOCS_THEME_PRESETS: DocsThemePreset[] = [ + { id: "default", nameKey: "Default", light: "#2b7af1", dark: "#6ea6f6" }, + { id: "forest", nameKey: "Forest", light: "#0f766e", dark: "#2dd4bf" }, + { id: "violet", nameKey: "Violet", light: "#6d28d9", dark: "#a78bfa" }, + { id: "ember", nameKey: "Ember", light: "#c2410c", dark: "#fb923c" }, + { id: "rose", nameKey: "Rose", light: "#be123c", dark: "#fb7185" }, +]; + +export const DEFAULT_DOCS_PRESET = DOCS_THEME_PRESETS[0]; + +const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; + +export function isValidDocsColor(value: unknown): value is string { + return typeof value === "string" && HEX_COLOR_REGEX.test(value); +} + +export function resolveDocsAccent( + appearance: IPublicSpaceAppearance | undefined, + scheme: "light" | "dark", +): string { + const custom = + scheme === "dark" + ? appearance?.primaryColorDark + : appearance?.primaryColorLight; + if (isValidDocsColor(custom)) return custom; + return scheme === "dark" + ? DEFAULT_DOCS_PRESET.dark + : DEFAULT_DOCS_PRESET.light; +} + +export function matchDocsPreset( + appearance: IPublicSpaceAppearance | undefined, +): DocsThemePreset | null { + const light = appearance?.primaryColorLight; + const dark = appearance?.primaryColorDark; + if (!light && !dark) return DEFAULT_DOCS_PRESET; + return ( + DOCS_THEME_PRESETS.find( + (preset) => + preset.light.toLowerCase() === light?.toLowerCase() && + preset.dark.toLowerCase() === dark?.toLowerCase(), + ) ?? null + ); +} + +// Set on documentElement (not the shell root) so portaled Mantine surfaces on +// /docs routes (spotlight, drawers) follow the space accent too. +const ACCENT_VARIABLES = (accent: string): Record => ({ + "--docs-accent": accent, + "--docs-accent-soft": `color-mix(in srgb, ${accent} 10%, transparent)`, + "--mantine-primary-color-filled": accent, + "--mantine-primary-color-filled-hover": `color-mix(in srgb, ${accent} 85%, black)`, + "--mantine-primary-color-light": `color-mix(in srgb, ${accent} 10%, transparent)`, + "--mantine-primary-color-light-hover": `color-mix(in srgb, ${accent} 15%, transparent)`, + "--mantine-primary-color-light-color": accent, + "--mantine-color-anchor": accent, +}); + +export function useDocsAccent(appearance: IPublicSpaceAppearance | undefined) { + const scheme = useComputedColorScheme("light"); + const light = appearance?.primaryColorLight; + const dark = appearance?.primaryColorDark; + + useEffect(() => { + const accent = resolveDocsAccent( + { primaryColorLight: light, primaryColorDark: dark }, + scheme, + ); + const root = document.documentElement; + const variables = ACCENT_VARIABLES(accent); + for (const [name, value] of Object.entries(variables)) { + root.style.setProperty(name, value); + } + return () => { + for (const name of Object.keys(variables)) { + root.style.removeProperty(name); + } + }; + }, [light, dark, scheme]); +} diff --git a/apps/client/src/features/public-space/types/public-space.types.ts b/apps/client/src/features/public-space/types/public-space.types.ts new file mode 100644 index 000000000..55fabe9af --- /dev/null +++ b/apps/client/src/features/public-space/types/public-space.types.ts @@ -0,0 +1,105 @@ +import { IPage } from "@/features/page/types/page.types.ts"; + +export interface IPublicSpaceSummary { + id: string; + name: string; + slug: string; + description?: string; + logo?: string; +} + +export interface IPublicSpaceAppearance { + primaryColorLight?: string; + primaryColorDark?: string; +} + +export interface IPublicSpaceByline { + author: boolean; + updatedAt: boolean; +} + +export interface IPublicSpaceInfo { + space: IPublicSpaceSummary; + searchIndexing: boolean; + appearance?: IPublicSpaceAppearance; + features?: string[]; +} + +export interface IPublicSpaceTree { + space: IPublicSpaceSummary; + pageTree: Partial; + appearance?: IPublicSpaceAppearance; + features?: string[]; +} + +export interface IPublicSpacePage { + page: IPage | null; + space: IPublicSpaceSummary; + searchIndexing: boolean; + appearance?: IPublicSpaceAppearance; + byline?: IPublicSpaceByline; + features?: string[]; +} + +export interface IPublicSpace { + id: string; + spaceId: string; + workspaceId: string; + enabled: boolean; + searchIndexing: boolean; + settings?: { + appearance?: IPublicSpaceAppearance; + byline?: Partial; + directory?: boolean; + } | null; + creatorId?: string; + createdAt: string; + updatedAt: string; +} + +export interface IPublishedSpaceItem { + id: string; + spaceId: string; + workspaceId: string; + searchIndexing: boolean; + settings?: IPublicSpace["settings"]; + createdAt: string; + updatedAt: string; + space: { + id: string; + name: string; + slug: string; + logo?: string; + userRole: string; + }; + creator: { + id: string; + name: string; + avatarUrl: string | null; + }; +} + +export interface IPublishSpace { + spaceId: string; + enabled: boolean; + searchIndexing?: boolean; + appearance?: { + primaryColorLight?: string | null; + primaryColorDark?: string | null; + }; + bylineAuthor?: boolean; + bylineUpdatedAt?: boolean; + directory?: boolean; +} + +export interface IPublicSpaceDirectoryEntry { + name: string; + slug: string; + description?: string; + logo?: string; +} + +export interface IPublicSpaceDirectory { + spaces: IPublicSpaceDirectoryEntry[]; + features?: string[]; +} diff --git a/apps/client/src/features/public-space/utils/docs-tree.ts b/apps/client/src/features/public-space/utils/docs-tree.ts new file mode 100644 index 000000000..22ad8ffb3 --- /dev/null +++ b/apps/client/src/features/public-space/utils/docs-tree.ts @@ -0,0 +1,39 @@ +import { SharedPageTreeNode } from "@/features/share/utils.ts"; + +// Preorder walk of the whole tree, matching the sidebar's visual order. Drives +// prev/next navigation independently of which nodes are expanded. +export function flattenTreePreorder( + nodes: SharedPageTreeNode[], +): SharedPageTreeNode[] { + const out: SharedPageTreeNode[] = []; + const walk = (list: SharedPageTreeNode[]) => { + for (const node of list) { + out.push(node); + if (node.children?.length) walk(node.children); + } + }; + walk(nodes); + return out; +} + +// Ancestors of the node with the given slugId, root-first, excluding the node +// itself. Null when the slugId is not in the tree. +export function findAncestorTrail( + nodes: SharedPageTreeNode[], + slugId: string, +): SharedPageTreeNode[] | null { + const walk = ( + list: SharedPageTreeNode[], + trail: SharedPageTreeNode[], + ): SharedPageTreeNode[] | null => { + for (const node of list) { + if (node.slugId === slugId) return trail; + if (node.children?.length) { + const found = walk(node.children, [...trail, node]); + if (found) return found; + } + } + return null; + }; + return walk(nodes, []); +} diff --git a/apps/client/src/features/public-space/utils/public-space-access.ts b/apps/client/src/features/public-space/utils/public-space-access.ts new file mode 100644 index 000000000..d10bc1753 --- /dev/null +++ b/apps/client/src/features/public-space/utils/public-space-access.ts @@ -0,0 +1,8 @@ +import { isBetaPublicSpaces } from "@/lib/config.ts"; +import { IWorkspace } from "@/features/workspace/types/workspace.types.ts"; + +export function isPublicSpacesAllowed(workspace?: IWorkspace): boolean { + return ( + isBetaPublicSpaces() && workspace?.settings?.publicSpaces?.enabled === true + ); +} diff --git a/apps/client/src/features/search/components/public-space-search-spotlight.tsx b/apps/client/src/features/search/components/public-space-search-spotlight.tsx new file mode 100644 index 000000000..0dca1e053 --- /dev/null +++ b/apps/client/src/features/search/components/public-space-search-spotlight.tsx @@ -0,0 +1,112 @@ +import { Group, Center, Text, Button } from "@mantine/core"; +import { Spotlight } from "@mantine/spotlight"; +import { IconLetterCase, IconSearch } from "@tabler/icons-react"; +import React, { useState } from "react"; +import { Link } from "react-router-dom"; +import { useDebouncedValue } from "@mantine/hooks"; +import { usePublicSpaceSearchQuery } from "@/features/search/queries/search-query"; +import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts"; +import { getPageIcon } from "@/lib"; +import { useTranslation } from "react-i18next"; +import { publicSpaceSearchSpotlightStore } from "@/features/search/constants.ts"; +import DOMPurify from "dompurify"; + +interface PublicSpaceSearchSpotlightProps { + spaceSlug: string; +} +export function PublicSpaceSearchSpotlight({ + spaceSlug, +}: PublicSpaceSearchSpotlightProps) { + const { t } = useTranslation(); + const [query, setQuery] = useState(""); + const [titleOnly, setTitleOnly] = useState(false); + const [debouncedSearchQuery] = useDebouncedValue(query, 300); + + const { data: searchResults } = usePublicSpaceSearchQuery({ + query: debouncedSearchQuery, + spaceSlug, + ...(titleOnly && { titleOnly: true }), + }); + + const pages = ( + searchResults && searchResults.length > 0 ? searchResults : [] + ).map((page) => ( + + +
    {getPageIcon(page?.icon)}
    + +
    + {page.title} + + {page?.highlight && ( + + )} +
    +
    +
    + )); + + return ( + <> + + } + /> + + + + + {query.length === 0 && pages.length === 0 && ( + {t("Start typing to search...")} + )} + + {query.length > 0 && pages.length === 0 && ( + {t("No results found...")} + )} + + {pages.length > 0 && pages} + + + + ); +} diff --git a/apps/client/src/features/search/constants.ts b/apps/client/src/features/search/constants.ts index a4c6c2f70..365387625 100644 --- a/apps/client/src/features/search/constants.ts +++ b/apps/client/src/features/search/constants.ts @@ -5,3 +5,6 @@ export const [searchSpotlightStore, searchSpotlight] = createSpotlight(); export const [shareSearchSpotlightStore, shareSearchSpotlight] = createSpotlight(); +export const [publicSpaceSearchSpotlightStore, publicSpaceSearchSpotlight] = + createSpotlight(); + diff --git a/apps/client/src/features/search/queries/search-query.ts b/apps/client/src/features/search/queries/search-query.ts index f536b441b..3cbc9a371 100644 --- a/apps/client/src/features/search/queries/search-query.ts +++ b/apps/client/src/features/search/queries/search-query.ts @@ -2,6 +2,7 @@ import { keepPreviousData, useQuery, UseQueryResult } from "@tanstack/react-quer import { searchAttachments, searchPage, + searchPublicSpace, searchShare, searchSuggestions, } from '@/features/search/services/search-service'; @@ -55,3 +56,13 @@ export function useAttachmentSearchQuery( enabled: !!params.query, }); } + +export function usePublicSpaceSearchQuery( + params: IPageSearchParams & { spaceSlug: string }, +): UseQueryResult { + return useQuery({ + queryKey: ["public-space-search", params], + queryFn: () => searchPublicSpace(params), + enabled: !!params.query && !!params.spaceSlug, + }); +} diff --git a/apps/client/src/features/search/services/search-service.ts b/apps/client/src/features/search/services/search-service.ts index 5e52ddf77..b94cccab9 100644 --- a/apps/client/src/features/search/services/search-service.ts +++ b/apps/client/src/features/search/services/search-service.ts @@ -28,6 +28,16 @@ export async function searchShare( return req.data.items; } +export async function searchPublicSpace( + params: IPageSearchParams & { spaceSlug: string }, +): Promise { + const req = await api.post<{ items: IPageSearch[] }>( + "/search/public-space-search", + params, + ); + return req.data.items; +} + export async function searchAttachments( params: IPageSearchParams, ): Promise { diff --git a/apps/client/src/features/share/atoms/open-shared-tree-nodes-atom.ts b/apps/client/src/features/share/atoms/open-shared-tree-nodes-atom.ts deleted file mode 100644 index 47882e5e9..000000000 --- a/apps/client/src/features/share/atoms/open-shared-tree-nodes-atom.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { atom } from "jotai"; - -export const openSharedTreeNodesAtom = atom>({}); diff --git a/apps/client/src/features/share/atoms/shared-page-atom.ts b/apps/client/src/features/share/atoms/shared-page-atom.ts index bf8929942..efb0940db 100644 --- a/apps/client/src/features/share/atoms/shared-page-atom.ts +++ b/apps/client/src/features/share/atoms/shared-page-atom.ts @@ -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(null); -export const sharedTreeDataAtom = atom(null); -export const sharedPageFullWidthAtom = atomWithStorage( - "sharedPageFullWidth", - false, -); \ No newline at end of file +export const sharedPageTreeAtom = atom( + null as ISharedPageTree | null, +); +export const sharedTreeDataAtom = atom( + null as SharedPageTreeNode[] | null, +); diff --git a/apps/client/src/features/share/atoms/sidebar-atom.ts b/apps/client/src/features/share/atoms/sidebar-atom.ts deleted file mode 100644 index 0bc9d6817..000000000 --- a/apps/client/src/features/share/atoms/sidebar-atom.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { atomWithWebStorage } from "@/lib/jotai-helper.ts"; -import { atom } from 'jotai'; - -export const tableOfContentAsideAtom = atomWithWebStorage( - "showTOC", - true, -); - -export const mobileTableOfContentAsideAtom = atom(false); \ No newline at end of file diff --git a/apps/client/src/features/share/components/share-branding.tsx b/apps/client/src/features/share/components/share-branding.tsx deleted file mode 100644 index 4b3dfb3eb..000000000 --- a/apps/client/src/features/share/components/share-branding.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Affix, Button } from "@mantine/core"; - -export default function ShareBranding() { - return ( - - - - ); -} diff --git a/apps/client/src/features/share/components/share-layout.tsx b/apps/client/src/features/share/components/share-layout.tsx index e3b2eb177..21bfca77e 100644 --- a/apps/client/src/features/share/components/share-layout.tsx +++ b/apps/client/src/features/share/components/share-layout.tsx @@ -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( + () => ({ + 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 ( - + : undefined + } + > - + ); } diff --git a/apps/client/src/features/share/components/share-shell.tsx b/apps/client/src/features/share/components/share-shell.tsx deleted file mode 100644 index 54ddbe347..000000000 --- a/apps/client/src/features/share/components/share-shell.tsx +++ /dev/null @@ -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(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 ( - <> - - 1 && { - navbar: { - width: sidebarWidth, - breakpoint: "sm", - collapsed: { - mobile: !mobileOpened, - desktop: !desktopOpened, - }, - }, - })} - aside={{ - width: 300, - breakpoint: "sm", - collapsed: { - mobile: !mobileTocOpened, - desktop: !tocOpened, - }, - }} - padding="md" - > - - - - {data?.pageTree?.length > 1 && ( - <> - - - - - - - - - )} - - - {shareId && ( - - - - )} - - - <> - {shareId && ( - - - - )} - - - - - - - - - - - - - - - setFullWidth((v) => !v)} - visibleFrom="sm" - size="sm" - > - - - - - - - - - - - {data?.pageTree?.length > 1 && ( - -
    - - - )} - - - {children} - - {data && shareId && !(data.features?.length > 0) && } - - - - -
    - {readOnlyEditor && ( - - )} -
    -
    -
    - - - - - ); -} diff --git a/apps/client/src/features/share/components/share.module.css b/apps/client/src/features/share/components/share.module.css index ebf1e74cb..99e84e413 100644 --- a/apps/client/src/features/share/components/share.module.css +++ b/apps/client/src/features/share/components/share.module.css @@ -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; - } -} diff --git a/apps/client/src/features/share/components/shared-tree.tsx b/apps/client/src/features/share/components/shared-tree.tsx deleted file mode 100644 index 370c59e70..000000000 --- a/apps/client/src/features/share/components/shared-tree.tsx +++ /dev/null @@ -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(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 ( -
    - - readOnly - ref={treeRef} - data={treeData} - openIds={openIds} - selectedId={currentNodeId} - renderRow={SharedTreeRow} - onMove={noopMove} - onToggle={handleToggle} - getDragLabel={getDragLabel} - aria-label={t("Pages")} - /> -
    - ); -} - -// Module-scope noop so it's a stable reference across renders. -const noopMove = () => {}; - -function SharedTreeRow({ - node, - isOpen, - hasChildren, - isSelected, - rowRef, - tabIndex, - treeItemProps, - toggleOpen, -}: RenderRowProps) { - const { shareId } = useParams(); - const { t } = useTranslation(); - const [, setMobileSidebarState] = useAtom(mobileSidebarAtom); - - const pageUrl = buildSharedPageUrl({ - shareId: shareId, - pageSlugId: node.slugId, - pageTitle: node.name, - }); - - return ( - } - tabIndex={tabIndex} - {...treeItemProps} - data-selected={isSelected || undefined} - className={clsx(classes.node, styles.treeNode)} - component={Link} - to={pageUrl} - onClick={() => { - setMobileSidebarState(false); - }} - > - -
    - {}} - icon={ - node.icon ? ( - node.icon - ) : ( - - ) - } - readOnly={true} - removeEmojiAction={() => {}} - actionIconProps={{ tabIndex: -1 }} - /> -
    - {node.name || t("untitled")} -
    - ); -} - -interface SharedPageArrowProps { - isOpen: boolean; - hasChildren: boolean; - onToggle: () => void; -} - -function SharedPageArrow({ - isOpen, - hasChildren, - onToggle, -}: SharedPageArrowProps) { - if (!hasChildren) { - return ( - - - - ); - } - - return ( - { - e.preventDefault(); - e.stopPropagation(); - onToggle(); - }} - > - {isOpen ? ( - - ) : ( - - )} - - ); -} 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 index 6112d3fdb..76b17912e 100644 --- a/apps/client/src/features/share/hooks/use-shared-page-subpages.ts +++ b/apps/client/src/features/share/hooks/use-shared-page-subpages.ts @@ -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], + ); } diff --git a/apps/client/src/features/share/hooks/use-toggle-toc.ts b/apps/client/src/features/share/hooks/use-toggle-toc.ts deleted file mode 100644 index ec43086a7..000000000 --- a/apps/client/src/features/share/hooks/use-toggle-toc.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useAtom } from "jotai"; - -export function useToggleToc(tocAtom: any) { - const [tocState, setTocState] = useAtom(tocAtom); - return () => { - setTocState(!tocState); - } -} diff --git a/apps/client/src/features/share/utils.ts b/apps/client/src/features/share/utils.ts index d52016932..334cc786f 100644 --- a/apps/client/src/features/share/utils.ts +++ b/apps/client/src/features/share/utils.ts @@ -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[], diff --git a/apps/client/src/features/space/components/settings-modal.tsx b/apps/client/src/features/space/components/settings-modal.tsx index 3d24d100c..9383feda4 100644 --- a/apps/client/src/features/space/components/settings-modal.tsx +++ b/apps/client/src/features/space/components/settings-modal.tsx @@ -4,6 +4,8 @@ import AddSpaceMembersModal from "@/features/space/components/add-space-members- import React from "react"; import SpaceDetails from "@/features/space/components/space-details.tsx"; import SpaceSecuritySettings from "@/features/space/components/space-security-settings.tsx"; +import PublishSpaceSettings from "@/features/public-space/components/publish-space-settings.tsx"; +import { isPublicSpacesAllowed } from "@/features/public-space/utils/public-space-access.ts"; import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; import { @@ -11,6 +13,8 @@ import { SpaceCaslSubject, } from "@/features/space/permissions/permissions.type.ts"; import { useTranslation } from "react-i18next"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; interface SpaceSettingsModalProps { spaceId: string; @@ -29,6 +33,13 @@ export default function SpaceSettingsModal({ const spaceRules = space?.membership?.permissions; const spaceAbility = useSpaceAbility(spaceRules); + const [workspace] = useAtom(workspaceAtom); + const allowPublicSpaces = isPublicSpacesAllowed(workspace); + const canManageSettings = spaceAbility.can( + SpaceCaslAction.Manage, + SpaceCaslSubject.Settings, + ); + return ( <> {t("Members")} - {spaceAbility.can( - SpaceCaslAction.Manage, - SpaceCaslSubject.Settings, - ) && ( + {canManageSettings && allowPublicSpaces && ( + + {t("Publish")} + + )} + {canManageSettings && ( {t("Security")} @@ -114,6 +127,16 @@ export default function SpaceSettingsModal({
    + + + +
    + {canManageSettings && allowPublicSpaces && ( + + )} +
    +
    +
    diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx index ac67deeb9..dc13bc46b 100644 --- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx +++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx @@ -35,7 +35,7 @@ import clsx from "clsx"; import { useDisclosure } from "@mantine/hooks"; import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx"; import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; -import { getSpaceUrl } from "@/lib/config.ts"; +import { getSpaceUrl, isBetaPublicSpaces } from "@/lib/config.ts"; import SpaceTree from "@/features/page/tree/components/space-tree.tsx"; import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; import { @@ -106,6 +106,7 @@ export function SpaceSidebar() { spaceName={space?.name} spaceSlug={space?.slug} spaceIcon={space?.logo} + isPublished={isBetaPublicSpaces() && space?.isPublished} /> diff --git a/apps/client/src/features/space/components/sidebar/switch-space.tsx b/apps/client/src/features/space/components/sidebar/switch-space.tsx index ff07572fd..447a5bf94 100644 --- a/apps/client/src/features/space/components/sidebar/switch-space.tsx +++ b/apps/client/src/features/space/components/sidebar/switch-space.tsx @@ -2,24 +2,32 @@ import classes from "./switch-space.module.css"; import { useNavigate } from "react-router-dom"; import { SpaceSelect } from "./space-select"; import { getSpaceUrl } from "@/lib/config"; -import { Button, Popover, Text } from "@mantine/core"; -import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; +import { Button, Popover, Text, Tooltip } from "@mantine/core"; +import { + IconChevronDown, + IconChevronUp, + IconWorld, +} from "@tabler/icons-react"; import { useDisclosure } from "@mantine/hooks"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; import React from "react"; +import { useTranslation } from "react-i18next"; interface SwitchSpaceProps { spaceName: string; spaceSlug: string; spaceIcon?: string; + isPublished?: boolean; } export function SwitchSpace({ spaceName, spaceSlug, spaceIcon, + isPublished, }: SwitchSpaceProps) { + const { t } = useTranslation(); const navigate = useNavigate(); const [opened, { close, toggle }] = useDisclosure(false); @@ -61,6 +69,16 @@ export function SwitchSpace({ {spaceName} + + {isPublished && ( + + + + )} diff --git a/apps/client/src/features/space/types/space.types.ts b/apps/client/src/features/space/types/space.types.ts index 6937b233c..733a17d32 100644 --- a/apps/client/src/features/space/types/space.types.ts +++ b/apps/client/src/features/space/types/space.types.ts @@ -30,6 +30,7 @@ export interface ISpace { createdAt: Date; updatedAt: Date; memberCount?: number; + isPublished?: boolean; spaceId?: string; membership?: IMembership; settings?: ISpaceSettings; diff --git a/apps/client/src/features/transclusion/services/transclusion-api.ts b/apps/client/src/features/transclusion/services/transclusion-api.ts index 9aa695221..bf164d988 100644 --- a/apps/client/src/features/transclusion/services/transclusion-api.ts +++ b/apps/client/src/features/transclusion/services/transclusion-api.ts @@ -19,6 +19,14 @@ export async function lookupTransclusionForShare(params: { return r.data; } +export async function lookupTransclusionForPublicSpace(params: { + spaceSlug: string; + references: Array<{ sourcePageId: string; transclusionId: string }>; +}): Promise<{ items: TransclusionLookup[] }> { + const r = await api.post("/public-spaces/transclusion/lookup", params); + return r.data; +} + export async function listReferences(params: { sourcePageId: string; transclusionId: string; diff --git a/apps/client/src/features/workspace/components/settings/components/allow-public-spaces.tsx b/apps/client/src/features/workspace/components/settings/components/allow-public-spaces.tsx new file mode 100644 index 000000000..90078f633 --- /dev/null +++ b/apps/client/src/features/workspace/components/settings/components/allow-public-spaces.tsx @@ -0,0 +1,129 @@ +import { Group, Text, Switch } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; +import { notifications } from "@mantine/notifications"; + +export default function AllowPublicSpaces() { + const { t } = useTranslation(); + const [workspace] = useAtom(workspaceAtom); + + return ( + <> + +
    + {t("Allow public spaces")} + + {t("Space admins can publish their spaces to the web.")} + +
    + + +
    + + {workspace?.settings?.publicSpaces?.enabled === true && ( + +
    + {t("Show public directory")} + + {t("List published spaces at /docs for anyone to browse.")} + +
    + + +
    + )} + + ); +} + +function PublicSpacesDirectoryToggle() { + const { t } = useTranslation(); + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const [checked, setChecked] = useState( + workspace?.settings?.publicSpaces?.directory === true, + ); + + const handleChange = async (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + try { + const updatedWorkspace = await updateWorkspace({ + publicSpacesDirectory: value, + }); + setChecked(value); + setWorkspace(updatedWorkspace); + } catch (err) { + notifications.show({ + message: err?.response?.data?.message, + color: "red", + }); + } + }; + + return ( + + ); +} + +function AllowPublicSpacesToggle() { + const { t } = useTranslation(); + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const [checked, setChecked] = useState( + workspace?.settings?.publicSpaces?.enabled === true, + ); + + const applyChange = async (value: boolean) => { + try { + const updatedWorkspace = await updateWorkspace({ + allowPublicSpaces: value, + }); + setChecked(value); + setWorkspace(updatedWorkspace); + } catch (err) { + notifications.show({ + message: err?.response?.data?.message, + color: "red", + }); + } + }; + + const handleChange = (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + modals.openConfirmModal({ + title: value ? t("Allow public spaces") : t("Disable public spaces"), + children: ( + + {value + ? t( + "Space admins will be able to make their spaces publicly readable by anyone on the internet. Are you sure?", + ) + : t( + "This will immediately unpublish every published space. Re-enabling later will not republish them. Are you sure?", + )} + + ), + centered: true, + labels: { + confirm: value ? t("Allow") : t("Disable"), + cancel: t("Cancel"), + }, + confirmProps: value ? undefined : { color: "red" }, + onConfirm: () => applyChange(value), + }); + }; + + return ( + + ); +} diff --git a/apps/client/src/features/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts index 80bf1f69d..2d3c8679d 100644 --- a/apps/client/src/features/workspace/types/workspace.types.ts +++ b/apps/client/src/features/workspace/types/workspace.types.ts @@ -24,6 +24,8 @@ export interface IWorkspace { aiSearch?: boolean; generativeAi?: boolean; disablePublicSharing?: boolean; + allowPublicSpaces?: boolean; + publicSpacesDirectory?: boolean; mcpEnabled?: boolean; aiChatReadOnly?: boolean; aiChatWorkspaceKnowledgeOnly?: boolean; @@ -42,6 +44,7 @@ export interface IWorkspaceSettings { api?: IWorkspaceApiSettings; templates?: IWorkspaceTemplateSettings; spaces?: IWorkspaceSpaceSettings; + publicSpaces?: IWorkspacePublicSpacesSettings; defaultPageEditMode?: string; } @@ -71,6 +74,11 @@ export interface IWorkspaceSpaceSettings { allowPersonal?: boolean; } +export interface IWorkspacePublicSpacesSettings { + enabled?: boolean; + directory?: boolean; +} + export interface ICreateInvite { role: string; emails: string[]; diff --git a/apps/client/src/lib/api-client.ts b/apps/client/src/lib/api-client.ts index c39b70a1e..cde9317ac 100644 --- a/apps/client/src/lib/api-client.ts +++ b/apps/client/src/lib/api-client.ts @@ -32,6 +32,13 @@ api.interceptors.response.use( const url = new URL(error.request.responseURL)?.pathname; if (url === "/api/auth/collab-token") return; if (window.location.pathname.startsWith("/share/")) return; + // public docs probe authed endpoints; reject without the login redirect + if ( + window.location.pathname === "/docs" || + window.location.pathname.startsWith("/docs/") + ) { + break; + } // Handle unauthorized error redirectToLogin(); diff --git a/apps/client/src/lib/config.ts b/apps/client/src/lib/config.ts index 388447060..246017114 100644 --- a/apps/client/src/lib/config.ts +++ b/apps/client/src/lib/config.ts @@ -43,6 +43,10 @@ export function isCloud(): boolean { return castToBoolean(getConfigValue("CLOUD")); } +export function isBetaPublicSpaces(): boolean { + return castToBoolean(getConfigValue("BETA_PUBLIC_SPACES")); +} + export function getAiVectorDriver(): string { return getConfigValue("AI_VECTOR_DRIVER"); } diff --git a/apps/client/src/pages/public-space/public-space-directory-page.tsx b/apps/client/src/pages/public-space/public-space-directory-page.tsx new file mode 100644 index 000000000..1b50aea06 --- /dev/null +++ b/apps/client/src/pages/public-space/public-space-directory-page.tsx @@ -0,0 +1,235 @@ +import "@fontsource-variable/inter"; +import "@/styles/public-typography.css"; +import { useMemo, useState } from "react"; +import { Skeleton } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; +import clsx from "clsx"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { IconSearch } from "@tabler/icons-react"; +import { usePublicSpaceDirectoryQuery } from "@/features/public-space/queries/public-space-query.ts"; +import { useAuthenticatedUser } from "@/features/public-space/hooks/use-authenticated-user.ts"; +import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts"; +import { getAvatarUrl } from "@/lib/config.ts"; +import { Error404 } from "@/components/ui/error-404.tsx"; +import { DocumentTitle } from "@/components/ui/document-title.tsx"; +import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx"; +import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; +import styles from "@/features/public-space/components/docs/docs-hub.module.css"; + +const TILE_COLORS = [ + "#1f9d55", + "#2b4bd6", + "#7c3aed", + "#0e7490", + "#d9480f", + "#b42318", + "#a16207", + "#475569", + "#be185d", + "#0f766e", + "#4338ca", + "#65a30d", +]; + +function getInitials(name: string) { + return name + .split(/[\s&]+/) + .filter(Boolean) + .slice(0, 2) + .map((word) => word[0]) + .join("") + .toUpperCase(); +} + +export default function PublicSpaceDirectoryPage() { + const { t } = useTranslation(); + const { data, isLoading, isError, error } = usePublicSpaceDirectoryQuery(); + const { data: currentUser } = useAuthenticatedUser(); + const isMobile = useMediaQuery("(max-width: 48em)"); + const [query, setQuery] = useState(""); + + const spaces = useMemo(() => { + const all = (data?.spaces ?? []).map((space, index) => ({ + ...space, + color: TILE_COLORS[index % TILE_COLORS.length], + initials: getInitials(space.name), + })); + const needle = query.trim().toLowerCase(); + if (!needle) return all; + return all.filter( + (space) => + space.name.toLowerCase().includes(needle) || + space.description?.toLowerCase().includes(needle), + ); + }, [data?.spaces, query]); + + if (isError) { + if ([401, 403, 404].includes(error?.["response"]?.status)) { + return ; + } + return
    {t("Error fetching page data.")}
    ; + } + + const total = data?.spaces.length ?? 0; + const title = t("Documentation"); + const searchLabel = isMobile ? t("Search...") : t("Search documentation..."); + + return ( +
    + + + + +
    +
    + + + {title.charAt(0).toUpperCase()} + + {title} + + +
    + {currentUser?.user ? ( + + {t("Open app")} + + ) : ( + + {t("Sign in")} + + )} +
    +
    +
    + +
    +
    +

    + {t("Welcome to our documentation")} +

    +

    + {isMobile + ? t("Guides, references and answers across all our spaces.") + : t( + "Guides, references and answers across all our published spaces.", + )} +

    + +
    event.preventDefault()} + > + setQuery(event.target.value)} + placeholder={searchLabel} + aria-label={searchLabel} + /> + +
    +
    +
    + +
    +
    +
    +

    {t("Spaces")}

    + {!isLoading && ( + + {total === 1 + ? t("1 published space") + : t("{{count}} published spaces", { count: total })} + + )} +
    + + {isLoading && ( +
    + + + + +
    + )} + + {!isLoading && total === 0 && ( +

    {t("No public spaces yet.")}

    + )} + + {!isLoading && total > 0 && spaces.length === 0 && ( +

    + {t("No spaces match your search.")} +

    + )} + + {spaces.length > 0 && ( +
    + {spaces.map((space) => { + const logoUrl = getAvatarUrl( + space.logo, + AvatarIconType.SPACE_ICON, + ); + return ( + + + + {logoUrl ? ( + + ) : ( + space.initials + )} + + {space.name} + + {space.description && ( + + {space.description} + + )} + + ); + })} +
    + )} +
    +
    + + {data && ( + + )} +
    + ); +} diff --git a/apps/client/src/pages/public-space/public-space-page.tsx b/apps/client/src/pages/public-space/public-space-page.tsx new file mode 100644 index 000000000..d3fc09117 --- /dev/null +++ b/apps/client/src/pages/public-space/public-space-page.tsx @@ -0,0 +1,125 @@ +import { useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Skeleton, Stack, Text } from "@mantine/core"; +import React from "react"; +import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx"; +import { extractPageSlugId } from "@/lib"; +import { Error404 } from "@/components/ui/error-404.tsx"; +import { usePublicSpacePageQuery } from "@/features/public-space/queries/public-space-query.ts"; +import { DocumentTitle } from "@/components/ui/document-title.tsx"; +import DocsBreadcrumbs from "@/features/public-space/components/docs/docs-breadcrumbs.tsx"; +import DocsPageNav from "@/features/public-space/components/docs/docs-page-nav.tsx"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { timeAgo } from "@/lib/time.ts"; +import styles from "@/features/public-space/components/docs/docs.module.css"; + +export default function PublicSpacePage() { + const { t } = useTranslation(); + const { spaceSlug, pageSlug } = useParams(); + + const { data, isLoading, isError, error } = usePublicSpacePageQuery({ + spaceSlug, + pageSlugId: pageSlug ? extractPageSlugId(pageSlug) : undefined, + }); + + if (isLoading) { + return ( + + + + + + + + + ); + } + + if (isError || !data) { + if ([401, 403, 404].includes(error?.["status"])) { + return ; + } + return
    {t("Error fetching page data.")}
    ; + } + + if (data.page === null) { + return ( +
    + {t("This space has no public pages yet.")} +
    + ); + } + + const showAuthor = + data.byline?.author === true && Boolean(data.page.creator?.name); + const showUpdatedAt = + data.byline?.updatedAt === true && Boolean(data.page.updatedAt); + + return ( +
    + + {!data.searchIndexing && } + + + + + + } + trailingSpace={false} + /> + + +
    + ); +} + +type DocsBylineProps = { + creator?: { name: string; avatarUrl: string }; + updatedAt?: Date | string; +}; + +function DocsByline({ creator, updatedAt }: DocsBylineProps) { + const { t } = useTranslation(); + + if (!creator && !updatedAt) return null; + + return ( +
    + {creator && ( + + + {t("By {{name}}", { name: creator.name })} + + )} + + {creator && updatedAt && ( + + • + + )} + + {updatedAt && ( + + {t("Updated {{date}}", { date: timeAgo(new Date(updatedAt)) })} + + )} +
    + ); +} diff --git a/apps/client/src/pages/settings/shares/shares.tsx b/apps/client/src/pages/settings/shares/shares.tsx index d327681af..23a74827e 100644 --- a/apps/client/src/pages/settings/shares/shares.tsx +++ b/apps/client/src/pages/settings/shares/shares.tsx @@ -1,19 +1,23 @@ import SettingsTitle from "@/components/settings/settings-title.tsx"; import { useTranslation } from "react-i18next"; +import { useAtom } from "jotai"; import ShareList from "@/features/share/components/share-list.tsx"; -import { Alert, Text } from "@mantine/core"; +import PublishedSpacesList from "@/features/public-space/components/published-spaces-list.tsx"; +import { isPublicSpacesAllowed } from "@/features/public-space/utils/public-space-access.ts"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { Alert, Tabs } from "@mantine/core"; import { IconInfoCircle } from "@tabler/icons-react"; import React from "react"; import { DocumentTitle } from "@/components/ui/document-title.tsx"; export default function Shares() { const { t } = useTranslation(); + const [workspace] = useAtom(workspaceAtom); - return ( + const allowPublicSpaces = isPublicSpacesAllowed(workspace); + + const sharedPages = ( <> - - - }> {t( "Publicly shared pages from spaces you are a member of will appear here", @@ -23,4 +27,38 @@ export default function Shares() { ); + + return ( + <> + + + + {allowPublicSpaces ? ( + + + + {t("Shared pages")} + + + {t("Published spaces")} + + + + + {sharedPages} + + + + }> + {t("Spaces published to the web will appear here")} + + + + + + ) : ( + sharedPages + )} + + ); } diff --git a/apps/client/src/pages/settings/workspace/workspace-settings.tsx b/apps/client/src/pages/settings/workspace/workspace-settings.tsx index dc9e7c5fc..e43586ecc 100644 --- a/apps/client/src/pages/settings/workspace/workspace-settings.tsx +++ b/apps/client/src/pages/settings/workspace/workspace-settings.tsx @@ -2,12 +2,13 @@ import SettingsTitle from "@/components/settings/settings-title.tsx"; import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form"; import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx"; import { useTranslation } from "react-i18next"; -import { isCloud } from "@/lib/config.ts"; +import { isBetaPublicSpaces, isCloud } from "@/lib/config.ts"; import ManageHostname from "@/ee/components/manage-hostname.tsx"; import { Divider } from "@mantine/core"; import AllowMemberTemplates from "@/ee/security/components/allow-member-templates.tsx"; import WorkspaceDefaultPageEditMode from "@/features/workspace/components/settings/components/workspace-default-page-edit-mode.tsx"; import PersonalSpacesSetting from "@/ee/personal-space/components/personal-spaces-setting.tsx"; +import AllowPublicSpaces from "@/features/workspace/components/settings/components/allow-public-spaces.tsx"; import { DocumentTitle } from "@/components/ui/document-title.tsx"; export default function WorkspaceSettings() { @@ -25,6 +26,13 @@ export default function WorkspaceSettings() { + {isBetaPublicSpaces() && ( + <> + + + + )} + {isCloud() && ( <> diff --git a/apps/client/src/pages/share/shared-page.tsx b/apps/client/src/pages/share/shared-page.tsx index b7028b2fb..dbce86233 100644 --- a/apps/client/src/pages/share/shared-page.tsx +++ b/apps/client/src/pages/share/shared-page.tsx @@ -1,19 +1,18 @@ import { useNavigate, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useSharePageQuery } from "@/features/share/queries/share-query.ts"; -import { Container } from "@mantine/core"; +import { Skeleton, Stack } from "@mantine/core"; import React, { useEffect } from "react"; import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx"; import { extractPageSlugId } from "@/lib"; import { Error404 } from "@/components/ui/error-404.tsx"; -import ShareBranding from "@/features/share/components/share-branding.tsx"; import { useAtomValue } from "jotai"; -import { - sharedPageFullWidthAtom, - sharedTreeDataAtom, -} from "@/features/share/atoms/shared-page-atom.ts"; +import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom.ts"; import { isPageInTree } from "@/features/share/utils.ts"; import { DocumentTitle } from "@/components/ui/document-title.tsx"; +import DocsBreadcrumbs from "@/features/public-space/components/docs/docs-breadcrumbs.tsx"; +import DocsPageNav from "@/features/public-space/components/docs/docs-page-nav.tsx"; +import DocsFooterBranding from "@/features/public-space/components/docs/docs-footer-branding.tsx"; export default function SharedPage() { const { t } = useTranslation(); @@ -26,7 +25,6 @@ export default function SharedPage() { }); const sharedTreeData = useAtomValue(sharedTreeDataAtom); - const fullWidth = useAtomValue(sharedPageFullWidthAtom); useEffect(() => { if (shareId && data) { @@ -44,7 +42,16 @@ export default function SharedPage() { }, [shareId, data, sharedTreeData]); if (isLoading) { - return <>; + return ( + + + + + + + + + ); } if (isError || !data) { @@ -65,17 +72,21 @@ export default function SharedPage() { )} - - - + - {data && !shareId && !(data.features?.length > 0) && } + + + + + {/* No tree query without a shareId, so the shell can't own branding here. */} + {!shareId && } ); } diff --git a/apps/client/src/pages/space/space-home.tsx b/apps/client/src/pages/space/space-home.tsx index df8e297d2..8b349edde 100644 --- a/apps/client/src/pages/space/space-home.tsx +++ b/apps/client/src/pages/space/space-home.tsx @@ -1,5 +1,6 @@ import {Container} from "@mantine/core"; import SpaceHomeTabs from "@/features/space/components/space-home-tabs.tsx"; +import SpacePublicNotice from "@/features/public-space/components/space-public-notice.tsx"; import {useParams} from "react-router-dom"; import {useGetSpaceBySlugQuery} from "@/features/space/queries/space-query.ts"; import { DocumentTitle } from "@/components/ui/document-title.tsx"; @@ -12,6 +13,7 @@ export default function SpaceHome() { <> + {space && } {space && } diff --git a/apps/client/src/styles/a11y-overrides.css b/apps/client/src/styles/a11y-overrides.css index 1fb4264d9..5114b5865 100644 --- a/apps/client/src/styles/a11y-overrides.css +++ b/apps/client/src/styles/a11y-overrides.css @@ -61,3 +61,13 @@ var(--mantine-color-dark-5) ); } + +/* The skip-link target main is tabindex="-1", so clicks in readonly content + * focus it and overlays (e.g. the lightbox) return focus to it on close, + * ringing the whole content region. The skip link's purpose is reading + * position, not a visible ring around everything, so suppress the outline. + */ +#main-content:focus, +#main-content:focus-visible { + outline: none; +} diff --git a/apps/client/src/styles/public-typography.css b/apps/client/src/styles/public-typography.css new file mode 100644 index 000000000..505e315b3 --- /dev/null +++ b/apps/client/src/styles/public-typography.css @@ -0,0 +1,39 @@ +/* Reading typography for the public surfaces (public spaces and share links). + * Scoped to the .public-typography root so the authenticated app keeps the + * system font stack. Loaded only from the lazy public layouts. */ + +.public-typography { + font-family: "Inter Variable", var(--mantine-font-family); + letter-spacing: -0.011em; + -webkit-font-smoothing: antialiased; +} + +/* Code keeps its own mono family; only undo the inherited tracking. */ +.public-typography code, +.public-typography pre { + letter-spacing: normal; +} + +/* Pins the reading metrics that core.css otherwise takes from the Mantine + * type scale. rem, not px, so browser font-size preferences still apply. */ +.public-typography .ProseMirror { + font-size: 1rem; + line-height: 1.65; +} + +.public-typography .ProseMirror h1 { + letter-spacing: -0.021em; +} + +.public-typography .ProseMirror h2 { + letter-spacing: -0.017em; +} + +.public-typography .ProseMirror h3, +.public-typography .ProseMirror h4 { + letter-spacing: -0.014em; +} + +.public-typography .page-title .ProseMirror h1 { + letter-spacing: -0.022em; +} diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts index d1127bca9..e4361fb4b 100644 --- a/apps/client/vite.config.ts +++ b/apps/client/vite.config.ts @@ -17,6 +17,7 @@ export default defineConfig(({ mode }) => { POSTHOG_HOST, POSTHOG_KEY, AI_VECTOR_DRIVER, + BETA_PUBLIC_SPACES, } = loadEnv(mode, envPath, ""); return { @@ -33,6 +34,7 @@ export default defineConfig(({ mode }) => { POSTHOG_HOST, POSTHOG_KEY, AI_VECTOR_DRIVER, + BETA_PUBLIC_SPACES, }, APP_VERSION: JSON.stringify(process.env.npm_package_version), }, diff --git a/apps/server/src/common/features.ts b/apps/server/src/common/features.ts index fcb21d35a..c8c327226 100644 --- a/apps/server/src/common/features.ts +++ b/apps/server/src/common/features.ts @@ -26,6 +26,7 @@ export const Feature = { OAUTH: 'oauth', AI_CONTROLS: 'ai:controls', MCP_CONTROLS: 'mcp:controls', + PUBLIC_SPACE_APPEARANCE: 'public-space:appearance', } as const; export type FeatureKey = (typeof Feature)[keyof typeof Feature]; diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index e898a4a1c..5bd9be1c1 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -18,6 +18,7 @@ import { PageAccessModule } from './page/page-access/page-access.module'; import { DomainMiddleware } from '../common/middlewares/domain.middleware'; import { AuditContextMiddleware } from '../common/middlewares/audit-context.middleware'; import { ShareModule } from './share/share.module'; +import { PublicSpaceModule } from './public-space/public-space.module'; import { LabelModule } from './label/label.module'; import { NotificationModule } from './notification/notification.module'; import { WatcherModule } from './watcher/watcher.module'; @@ -40,6 +41,7 @@ import { ClsMiddleware } from 'nestjs-cls'; CaslModule, PageAccessModule, ShareModule, + PublicSpaceModule, LabelModule, NotificationModule, WatcherModule, diff --git a/apps/server/src/core/public-space/dto/public-space.dto.ts b/apps/server/src/core/public-space/dto/public-space.dto.ts new file mode 100644 index 000000000..8a27fc4b5 --- /dev/null +++ b/apps/server/src/core/public-space/dto/public-space.dto.ts @@ -0,0 +1,79 @@ +import { + IsBoolean, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + Matches, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { LookupDto } from '../../page/transclusion/dto/lookup.dto'; + +export const APPEARANCE_HEX_REGEX = /^#[0-9a-fA-F]{6}$/; + +export class PublicSpaceSlugDto { + @IsString() + @IsNotEmpty() + spaceSlug: string; +} + +export class PublicSpacePageDto extends PublicSpaceSlugDto { + @IsString() + @IsOptional() + pageSlugId?: string; + + @IsOptional() + @IsBoolean() + contentless?: boolean; +} + +export class PublicSpaceTransclusionLookupDto extends LookupDto { + @IsString() + @IsNotEmpty() + spaceSlug!: string; +} + +export class PublicSpaceAppearanceDto { + @IsOptional() + @Matches(APPEARANCE_HEX_REGEX) + primaryColorLight?: string | null; + + @IsOptional() + @Matches(APPEARANCE_HEX_REGEX) + primaryColorDark?: string | null; +} + +export class PublishSpaceDto { + @IsUUID() + spaceId: string; + + @IsBoolean() + enabled: boolean; + + @IsOptional() + @IsBoolean() + searchIndexing?: boolean; + + @IsOptional() + @ValidateNested() + @Type(() => PublicSpaceAppearanceDto) + appearance?: PublicSpaceAppearanceDto; + + @IsOptional() + @IsBoolean() + bylineAuthor?: boolean; + + @IsOptional() + @IsBoolean() + bylineUpdatedAt?: boolean; + + @IsOptional() + @IsBoolean() + directory?: boolean; +} + +export class PublicSpaceForSpaceDto { + @IsUUID() + spaceId: string; +} diff --git a/apps/server/src/core/public-space/public-space-seo.controller.ts b/apps/server/src/core/public-space/public-space-seo.controller.ts new file mode 100644 index 000000000..f35ecc5e5 --- /dev/null +++ b/apps/server/src/core/public-space/public-space-seo.controller.ts @@ -0,0 +1,173 @@ +import { Controller, Get, Logger, Param, Req, Res } from '@nestjs/common'; +import { FastifyReply, FastifyRequest } from 'fastify'; +import { join } from 'path'; +import * as fs from 'node:fs'; +import { validate as isValidUUID } from 'uuid'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; +import { EnvironmentService } from '../../integrations/environment/environment.service'; +import { Workspace } from '@docmost/db/types/entity.types'; +import { htmlEscape } from '../../common/helpers/html-escaper'; +import { PublicSpaceService } from './public-space.service'; + +@Controller('docs') +export class PublicSpaceSeoController { + private readonly logger = new Logger(PublicSpaceSeoController.name); + + constructor( + private readonly publicSpaceService: PublicSpaceService, + private workspaceRepo: WorkspaceRepo, + private environmentService: EnvironmentService, + ) {} + + /* + * The /docs hub: inject meta only when the directory is enabled; + * otherwise the untouched SPA shell (uniform with 404s). + */ + @Get() + async getDirectoryHub( + @Res({ passthrough: false }) res: FastifyReply, + @Req() req: FastifyRequest, + ) { + const workspace = await this.resolveWorkspace(req); + + const clientDistPath = join( + __dirname, + '..', + '..', + '..', + '..', + 'client/dist', + ); + if (!fs.existsSync(clientDistPath)) { + return; + } + const indexFilePath = join(clientDistPath, 'index.html'); + + if (!workspace) { + return this.sendIndex(indexFilePath, res); + } + + try { + await this.publicSpaceService.getPublicSpaceDirectory(workspace); + } catch (err) { + return this.sendIndex(indexFilePath, res); + } + + const metaTitle = 'Documentation'; + const metaTagVar = ''; + const metaTags = ``; + + const html = fs.readFileSync(indexFilePath, 'utf8'); + const transformedHtml = html + .replace(/[\s\S]*?<\/title>/i, () => `<title>${metaTitle}`) + .replace(metaTagVar, () => metaTags); + + res.type('text/html').send(transformedHtml); + } + + /* + * add meta tags to public space pages + */ + @Get([':spaceSlug', ':spaceSlug/:pageSlug']) + async getPublicSpacePage( + @Res({ passthrough: false }) res: FastifyReply, + @Req() req: FastifyRequest, + @Param('spaceSlug') spaceSlug: string, + @Param('pageSlug') pageSlug: string, + ) { + const workspace = await this.resolveWorkspace(req); + + const clientDistPath = join( + __dirname, + '..', + '..', + '..', + '..', + 'client/dist', + ); + + if (!fs.existsSync(clientDistPath)) { + return; + } + const indexFilePath = join(clientDistPath, 'index.html'); + + if (!workspace) { + return this.sendIndex(indexFilePath, res); + } + + let title: string = null; + let searchIndexing = false; + + try { + if (pageSlug) { + const pageSlugId = this.extractPageSlugId(pageSlug); + const pageData = await this.publicSpaceService.getPublicPage( + spaceSlug, + pageSlugId, + workspace, + { includeContent: false }, + ); + title = pageData.page?.title ?? pageData.space.name; + searchIndexing = pageData.searchIndexing; + } else { + const info = await this.publicSpaceService.getPublicSpaceInfo( + spaceSlug, + workspace, + ); + title = info.space.name; + searchIndexing = info.searchIndexing; + } + } catch (err) { + // Not public: serve the untouched SPA shell with zero injected meta. + this.logger.debug(`no public meta for ${spaceSlug}`); + return this.sendIndex(indexFilePath, res); + } + + const rawTitle = htmlEscape(title ?? 'untitled'); + const metaTitle = + rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}…` : rawTitle; + + const metaTagVar = ''; + const metaTags = [ + ``, + ``, + !searchIndexing ? `` : '', + ] + .filter(Boolean) + .join('\n '); + + const html = fs.readFileSync(indexFilePath, 'utf8'); + const transformedHtml = html + .replace(/[\s\S]*?<\/title>/i, () => `<title>${metaTitle}`) + .replace(metaTagVar, () => metaTags); + + res.type('text/html').send(transformedHtml); + } + + // Prefix-excluded routes skip middleware, so resolve the workspace inline + // exactly like ShareSeoController does. + private async resolveWorkspace(req: FastifyRequest): Promise { + if (this.environmentService.isSelfHosted()) { + return this.workspaceRepo.findFirst(); + } + const header = req.raw.headers.host; + const subdomain = header.split('.')[0]; + return this.workspaceRepo.findByHostname(subdomain); + } + + sendIndex(indexFilePath: string, res: FastifyReply) { + const stream = fs.createReadStream(indexFilePath); + res.type('text/html').send(stream); + } + + extractPageSlugId(slug: string): string { + if (!slug) { + return undefined; + } + if (isValidUUID(slug)) { + return slug; + } + const parts = slug.split('-'); + return parts.length > 1 ? parts[parts.length - 1] : slug; + } +} diff --git a/apps/server/src/core/public-space/public-space.controller.ts b/apps/server/src/core/public-space/public-space.controller.ts new file mode 100644 index 000000000..d60ebc8d9 --- /dev/null +++ b/apps/server/src/core/public-space/public-space.controller.ts @@ -0,0 +1,249 @@ +import { + Body, + Controller, + ForbiddenException, + HttpCode, + HttpStatus, + Inject, + NotFoundException, + Post, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../../common/decorators/auth-user.decorator'; +import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; +import { User, Workspace } from '@docmost/db/types/entity.types'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { Public } from '../../common/decorators/public.decorator'; +import { PublicSpaceService } from './public-space.service'; +import { + PublicSpaceForSpaceDto, + PublicSpacePageDto, + PublicSpaceSlugDto, + PublicSpaceTransclusionLookupDto, + PublishSpaceDto, +} from './dto/public-space.dto'; +import { PublicSpaceRepo } from '@docmost/db/repos/public-space/public-space.repo'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; +import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; +import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; +import { + SpaceCaslAction, + SpaceCaslSubject, +} from '../casl/interfaces/space-ability.type'; +import { LicenseCheckService } from '../../integrations/environment/license-check.service'; +import { EnvironmentService } from '../../integrations/environment/environment.service'; +import { AuditEvent, AuditResource } from '../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../integrations/audit/audit.service'; +import { Feature, FeatureKey } from '../../common/features'; + +const PUBLIC_SPACE_FEATURES: FeatureKey[] = [Feature.PUBLIC_SPACE_APPEARANCE]; + +@UseGuards(JwtAuthGuard) +@Controller('public-spaces') +export class PublicSpaceController { + constructor( + private readonly publicSpaceService: PublicSpaceService, + private readonly publicSpaceRepo: PublicSpaceRepo, + private readonly spaceRepo: SpaceRepo, + private readonly spaceAbility: SpaceAbilityFactory, + private readonly licenseCheckService: LicenseCheckService, + private readonly environmentService: EnvironmentService, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, + ) {} + + private assertBetaPublicSpaces() { + if (!this.environmentService.isBetaPublicSpaces()) { + throw new ForbiddenException( + 'Public spaces are not enabled on this instance', + ); + } + } + + @HttpCode(HttpStatus.OK) + @Post('/') + async getPublishedSpaces( + @Body() pagination: PaginationOptions, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + this.assertBetaPublicSpaces(); + return this.publicSpaceRepo.getPublishedSpaces( + user.id, + workspace.id, + pagination, + ); + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('/info') + async getInfo( + @Body() dto: PublicSpaceSlugDto, + @AuthWorkspace() workspace: Workspace, + ) { + const info = await this.publicSpaceService.getPublicSpaceInfo( + dto.spaceSlug, + workspace, + ); + return { + ...info, + features: this.publicFeatures(workspace), + }; + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('/directory') + async getDirectory(@AuthWorkspace() workspace: Workspace) { + const directory = + await this.publicSpaceService.getPublicSpaceDirectory(workspace); + return { + ...directory, + features: this.publicFeatures(workspace), + }; + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('/tree') + async getTree( + @Body() dto: PublicSpaceSlugDto, + @AuthWorkspace() workspace: Workspace, + ) { + const treeData = await this.publicSpaceService.getPublicSpaceTree( + dto.spaceSlug, + workspace, + ); + return { + ...treeData, + features: this.publicFeatures(workspace), + }; + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('/page-info') + async getPageInfo( + @Body() dto: PublicSpacePageDto, + @AuthWorkspace() workspace: Workspace, + ) { + const pageData = await this.publicSpaceService.getPublicPage( + dto.spaceSlug, + dto.pageSlugId, + workspace, + { includeContent: dto.contentless !== true }, + ); + return { + ...pageData, + features: this.publicFeatures(workspace), + }; + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('/transclusion/lookup') + async transclusionLookup( + @Body() dto: PublicSpaceTransclusionLookupDto, + @AuthWorkspace() workspace: Workspace, + ) { + return this.publicSpaceService.lookupTransclusionForPublicSpace( + dto.spaceSlug, + dto.references, + workspace, + ); + } + + private publicFeatures(workspace: Workspace): string[] { + const features = this.licenseCheckService.resolveFeatures( + workspace.licenseKey, + workspace.plan, + ); + return PUBLIC_SPACE_FEATURES.filter((feature) => + features.includes(feature), + ); + } + + @HttpCode(HttpStatus.OK) + @Post('/for-space') + async getForSpace( + @Body() dto: PublicSpaceForSpaceDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + this.assertBetaPublicSpaces(); + const space = await this.spaceRepo.findById(dto.spaceId, workspace.id); + if (!space || space.deletedAt) { + throw new NotFoundException('Space not found'); + } + + const ability = await this.spaceAbility.createForUser(user, space.id); + if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) { + throw new ForbiddenException(); + } + + return (await this.publicSpaceRepo.findBySpaceId(space.id)) ?? null; + } + + @HttpCode(HttpStatus.OK) + @Post('/publish') + async publish( + @Body() dto: PublishSpaceDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const space = await this.spaceRepo.findById(dto.spaceId, workspace.id); + if (!space || space.deletedAt) { + throw new NotFoundException('Space not found'); + } + + const ability = await this.spaceAbility.createForUser(user, space.id); + if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) { + throw new ForbiddenException(); + } + + const prev = await this.publicSpaceRepo.findBySpaceId(space.id); + + const publicSpace = await this.publicSpaceService.publish({ + space, + workspace, + authUserId: user.id, + enabled: dto.enabled, + searchIndexing: dto.searchIndexing, + appearance: dto.appearance, + bylineAuthor: dto.bylineAuthor, + bylineUpdatedAt: dto.bylineUpdatedAt, + directory: dto.directory, + }); + + const prevByline = (prev?.settings as any)?.byline; + const nextSettings = publicSpace?.settings as any; + + this.auditService.log({ + event: AuditEvent.SPACE_UPDATED, + resourceType: AuditResource.SPACE, + resourceId: space.id, + spaceId: space.id, + changes: { + before: { + isPublished: prev?.enabled ?? false, + searchIndexing: prev?.searchIndexing === true, + bylineAuthor: prevByline?.author === true, + bylineUpdatedAt: prevByline?.updatedAt !== false, + directory: (prev?.settings as any)?.directory === true, + }, + after: { + isPublished: publicSpace?.enabled ?? dto.enabled, + searchIndexing: publicSpace?.searchIndexing === true, + bylineAuthor: nextSettings?.byline?.author === true, + bylineUpdatedAt: nextSettings?.byline?.updatedAt !== false, + directory: nextSettings?.directory === true, + }, + }, + }); + + return publicSpace; + } +} diff --git a/apps/server/src/core/public-space/public-space.module.ts b/apps/server/src/core/public-space/public-space.module.ts new file mode 100644 index 000000000..466cc2055 --- /dev/null +++ b/apps/server/src/core/public-space/public-space.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { PublicSpaceController } from './public-space.controller'; +import { PublicSpaceSeoController } from './public-space-seo.controller'; +import { PublicSpaceService } from './public-space.service'; +import { ShareModule } from '../share/share.module'; +import { TransclusionModule } from '../page/transclusion/transclusion.module'; + +@Module({ + imports: [ShareModule, TransclusionModule], + controllers: [PublicSpaceController, PublicSpaceSeoController], + providers: [PublicSpaceService], + exports: [PublicSpaceService], +}) +export class PublicSpaceModule {} diff --git a/apps/server/src/core/public-space/public-space.service.spec.ts b/apps/server/src/core/public-space/public-space.service.spec.ts new file mode 100644 index 000000000..794f93f8c --- /dev/null +++ b/apps/server/src/core/public-space/public-space.service.spec.ts @@ -0,0 +1,1103 @@ +import { NotFoundException, ForbiddenException } from '@nestjs/common'; +import { PublicSpaceService } from './public-space.service'; +import { Feature } from '../../common/features'; + +const WORKSPACE_ID = '018f0000-0000-7000-8000-000000000001'; +const SPACE_ID = '018f0000-0000-7000-8000-000000000002'; + +function makeWorkspace(settings: any = {}) { + return { id: WORKSPACE_ID, settings } as any; +} + +const optInSettings = { publicSpaces: { enabled: true } }; + +function makeService(overrides: any = {}) { + const publicSpaceRepo = { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: true, + searchIndexing: false, + }), + upsert: jest.fn().mockImplementation(async (opts) => opts), + ...overrides.publicSpaceRepo, + }; + const spaceRepo = { + findBySlug: jest.fn().mockResolvedValue({ + id: SPACE_ID, + slug: 'handbook', + name: 'Handbook', + workspaceId: WORKSPACE_ID, + deletedAt: null, + }), + ...overrides.spaceRepo, + }; + const pageRepo = { + findById: jest.fn().mockResolvedValue({ + id: 'page1', + slugId: 'abc123XYZ0', + spaceId: SPACE_ID, + workspaceId: WORKSPACE_ID, + deletedAt: null, + content: null, + }), + getSpacePagesExcludingRestricted: jest.fn().mockResolvedValue([]), + getFirstUnrestrictedRootPage: jest + .fn() + .mockResolvedValue({ id: 'page1', slugId: 'abc123XYZ0' }), + ...overrides.pageRepo, + }; + const pagePermissionRepo = { + hasRestrictedAncestor: jest.fn().mockResolvedValue(false), + ...overrides.pagePermissionRepo, + }; + const shareService = { + updatePublicAttachments: jest.fn().mockImplementation(async (p) => p.content), + sanitizeTransclusionItemsForPublic: jest + .fn() + .mockImplementation(async (items) => items), + ...overrides.shareService, + }; + const transclusionService = { + lookupWithAccessSet: jest.fn().mockResolvedValue({ items: [] }), + ...overrides.transclusionService, + }; + + const licenseCheckService = { + resolveFeatures: jest.fn().mockReturnValue([Feature.PUBLIC_SPACE_APPEARANCE]), + ...overrides.licenseCheckService, + }; + + const environmentService = { + isBetaPublicSpaces: jest.fn().mockReturnValue(true), + ...overrides.environmentService, + }; + + const service = new PublicSpaceService( + publicSpaceRepo as any, + spaceRepo as any, + pageRepo as any, + pagePermissionRepo as any, + shareService as any, + transclusionService as any, + licenseCheckService as any, + environmentService as any, + ); + return { + service, + publicSpaceRepo, + spaceRepo, + pageRepo, + pagePermissionRepo, + shareService, + transclusionService, + licenseCheckService, + environmentService, + }; +} + +describe('PublicSpaceService', () => { + describe('getPublicSpace', () => { + it('404s when the workspace opt-in is off (default)', async () => { + const { service } = makeService(); + await expect( + service.getPublicSpace('handbook', makeWorkspace({})), + ).rejects.toThrow(NotFoundException); + }); + + it('404s when BETA_PUBLIC_SPACES is off even with the workspace opt-in on', async () => { + const { service, spaceRepo } = makeService({ + environmentService: { + isBetaPublicSpaces: jest.fn().mockReturnValue(false), + }, + }); + const ws = makeWorkspace({ publicSpaces: { enabled: true } }); + await expect(service.getPublicSpace('handbook', ws)).rejects.toThrow( + NotFoundException, + ); + expect(spaceRepo.findBySlug).not.toHaveBeenCalled(); + }); + + it('resolves when public page sharing is disabled', async () => { + const { service } = makeService(); + const ws = makeWorkspace({ + publicSpaces: { enabled: true }, + sharing: { disabled: true }, + }); + const result = await service.getPublicSpace('handbook', ws); + + expect(result.space.id).toBe(SPACE_ID); + expect(result.publicSpace.enabled).toBe(true); + }); + + it('404s when the space row is disabled', async () => { + const { service } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue({ enabled: false }), + }, + }); + await expect( + service.getPublicSpace('handbook', makeWorkspace(optInSettings)), + ).rejects.toThrow(NotFoundException); + }); + + it('404s when there is no public_spaces row', async () => { + const { service } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue(undefined), + }, + }); + await expect( + service.getPublicSpace('handbook', makeWorkspace(optInSettings)), + ).rejects.toThrow(NotFoundException); + }); + + it('404s when the space is deleted', async () => { + const { service } = makeService({ + spaceRepo: { + findBySlug: jest + .fn() + .mockResolvedValue({ id: SPACE_ID, deletedAt: new Date() }), + }, + }); + await expect( + service.getPublicSpace('handbook', makeWorkspace(optInSettings)), + ).rejects.toThrow(NotFoundException); + }); + + it('resolves when opted in, enabled, and space exists', async () => { + const { service } = makeService(); + const result = await service.getPublicSpace( + 'handbook', + makeWorkspace(optInSettings), + ); + expect(result.space.id).toBe(SPACE_ID); + expect(result.publicSpace.enabled).toBe(true); + }); + }); + + describe('getPublicPage', () => { + it('404s for a page belonging to another space', async () => { + const { service } = makeService({ + pageRepo: { + findById: jest.fn().mockResolvedValue({ + id: 'page2', + spaceId: 'other-space', + deletedAt: null, + }), + }, + }); + await expect( + service.getPublicPage('handbook', 'abc123XYZ0', makeWorkspace(optInSettings)), + ).rejects.toThrow(NotFoundException); + }); + + it('404s for a restricted page', async () => { + const { service } = makeService({ + pagePermissionRepo: { + hasRestrictedAncestor: jest.fn().mockResolvedValue(true), + }, + }); + await expect( + service.getPublicPage('handbook', 'abc123XYZ0', makeWorkspace(optInSettings)), + ).rejects.toThrow(NotFoundException); + }); + + it('resolves the first unrestricted root page as the home', async () => { + const { service, pageRepo } = makeService(); + const result = await service.getPublicPage( + 'handbook', + undefined, + makeWorkspace(optInSettings), + ); + expect(pageRepo.getFirstUnrestrictedRootPage).toHaveBeenCalledWith(SPACE_ID); + expect(result.page.id).toBe('page1'); + }); + + it('sanitizes the served page content for public delivery', async () => { + const sanitizedDoc = { type: 'doc', sanitized: true }; + const { service, shareService } = makeService({ + pageRepo: { + findById: jest.fn().mockResolvedValue({ + id: 'page1', + slugId: 'abc123XYZ0', + spaceId: SPACE_ID, + workspaceId: WORKSPACE_ID, + deletedAt: null, + content: { type: 'doc', content: [] }, + }), + }, + shareService: { + updatePublicAttachments: jest.fn().mockResolvedValue(sanitizedDoc), + }, + }); + + const result = await service.getPublicPage( + 'handbook', + 'abc123XYZ0', + makeWorkspace(optInSettings), + ); + + expect(shareService.updatePublicAttachments.mock.calls[0][0]).toBe( + result.page, + ); + expect(result.page.content).toBe(sanitizedDoc); + }); + + it('returns page null for an empty published space', async () => { + const { service } = makeService({ + pageRepo: { + getFirstUnrestrictedRootPage: jest.fn().mockResolvedValue(undefined), + }, + }); + const result = await service.getPublicPage( + 'handbook', + undefined, + makeWorkspace(optInSettings), + ); + expect(result.page).toBeNull(); + }); + + it('skips content preparation when includeContent is false', async () => { + const { service, pageRepo, shareService } = makeService(); + + const result = await service.getPublicPage( + 'handbook', + 'abc123XYZ0', + makeWorkspace(optInSettings), + { includeContent: false }, + ); + + expect(pageRepo.findById).toHaveBeenCalledWith('abc123XYZ0'); + expect(shareService.updatePublicAttachments).not.toHaveBeenCalled(); + expect(result.page.id).toBe('page1'); + }); + }); + + describe('lookupTransclusionForPublicSpace', () => { + const refs = [{ sourcePageId: 'ref1', transclusionId: 't1' }]; + + it('excludes a candidate from another space', async () => { + const { service, transclusionService } = makeService({ + pageRepo: { + findById: jest.fn().mockResolvedValue({ + id: 'page2', + spaceId: 'other-space', + deletedAt: null, + }), + }, + }); + + await service.lookupTransclusionForPublicSpace( + 'handbook', + refs, + makeWorkspace(optInSettings), + ); + + const accessSet = transclusionService.lookupWithAccessSet.mock.calls[0][1]; + expect(Array.from(accessSet)).toEqual([]); + }); + + it('excludes a restricted candidate', async () => { + const { service, transclusionService } = makeService({ + pagePermissionRepo: { + hasRestrictedAncestor: jest.fn().mockResolvedValue(true), + }, + }); + + await service.lookupTransclusionForPublicSpace( + 'handbook', + refs, + makeWorkspace(optInSettings), + ); + + const accessSet = transclusionService.lookupWithAccessSet.mock.calls[0][1]; + expect(Array.from(accessSet)).toEqual([]); + }); + + it('includes a valid same-space candidate by its page id', async () => { + const { service, transclusionService } = makeService(); + + await service.lookupTransclusionForPublicSpace( + 'handbook', + refs, + makeWorkspace(optInSettings), + ); + + const accessSet = transclusionService.lookupWithAccessSet.mock.calls[0][1]; + expect(Array.from(accessSet)).toEqual(['page1']); + }); + + it('delegates item sanitization to the share service', async () => { + const rawItems = [ + { sourcePageId: 'page1', transclusionId: 't1', status: 'not_found' }, + ]; + const sanitizedItems = [ + { sourcePageId: 'page1', transclusionId: 't1', status: 'no_access' }, + ]; + const { service, shareService } = makeService({ + transclusionService: { + lookupWithAccessSet: jest.fn().mockResolvedValue({ items: rawItems }), + }, + shareService: { + sanitizeTransclusionItemsForPublic: jest + .fn() + .mockResolvedValue(sanitizedItems), + }, + }); + + const result = await service.lookupTransclusionForPublicSpace( + 'handbook', + refs, + makeWorkspace(optInSettings), + ); + + expect( + shareService.sanitizeTransclusionItemsForPublic, + ).toHaveBeenCalledWith(rawItems, WORKSPACE_ID); + expect(result.items).toBe(sanitizedItems); + }); + }); + + describe('BETA_PUBLIC_SPACES off', () => { + const flagOff = { + environmentService: { + isBetaPublicSpaces: jest.fn().mockReturnValue(false), + }, + }; + const refs = [{ sourcePageId: 'ref1', transclusionId: 't1' }]; + + it.each([ + ['getPublicSpaceInfo', (s, ws) => s.getPublicSpaceInfo('handbook', ws)], + ['getPublicSpaceTree', (s, ws) => s.getPublicSpaceTree('handbook', ws)], + [ + 'getPublicPage', + (s, ws) => s.getPublicPage('handbook', 'abc123XYZ0', ws), + ], + [ + 'lookupTransclusionForPublicSpace', + (s, ws) => s.lookupTransclusionForPublicSpace('handbook', refs, ws), + ], + ])( + '%s 404s before any lookup even with the workspace opted in', + async (_name, call) => { + const { service, spaceRepo, publicSpaceRepo, pageRepo } = + makeService(flagOff); + await expect( + call(service, makeWorkspace(optInSettings)), + ).rejects.toBeInstanceOf(NotFoundException); + expect(spaceRepo.findBySlug).not.toHaveBeenCalled(); + expect(publicSpaceRepo.findBySpaceId).not.toHaveBeenCalled(); + expect(pageRepo.findById).not.toHaveBeenCalled(); + expect(pageRepo.getSpacePagesExcludingRestricted).not.toHaveBeenCalled(); + }, + ); + + it('directory 404s before any lookup even with the double opt-in on', async () => { + const findEnabledWithSpaceByWorkspaceId = jest.fn().mockResolvedValue([]); + const { service } = makeService({ + ...flagOff, + publicSpaceRepo: { findEnabledWithSpaceByWorkspaceId }, + }); + await expect( + service.getPublicSpaceDirectory( + makeWorkspace({ publicSpaces: { enabled: true, directory: true } }), + ), + ).rejects.toBeInstanceOf(NotFoundException); + expect(findEnabledWithSpaceByWorkspaceId).not.toHaveBeenCalled(); + }); + + it.each([true, false])( + 'publish rejects every write (enabled=%s) without touching the row', + async (enabled) => { + const { service, publicSpaceRepo } = makeService(flagOff); + await expect( + service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(publicSpaceRepo.findBySpaceId).not.toHaveBeenCalled(); + expect(publicSpaceRepo.upsert).not.toHaveBeenCalled(); + }, + ); + }); + + describe('publish appearance', () => { + it('merges appearance into existing settings preserving unknown keys', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: true, + searchIndexing: false, + settings: { + future: 1, + appearance: { primaryColorLight: '#111111' }, + }, + }), + }, + }); + + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + appearance: { primaryColorDark: '#222222' }, + }); + + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + settings: { + future: 1, + appearance: { + primaryColorLight: '#111111', + primaryColorDark: '#222222', + }, + }, + }), + ); + }); + + it('deletes an appearance key when its value is null', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: true, + searchIndexing: false, + settings: { + appearance: { + primaryColorLight: '#111111', + primaryColorDark: '#222222', + }, + }, + }), + }, + }); + + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + appearance: { primaryColorLight: null }, + }); + + expect(publicSpaceRepo.upsert.mock.calls[0][0].settings).toEqual({ + appearance: { primaryColorDark: '#222222' }, + }); + }); + + it('passes settings undefined when appearance is omitted', async () => { + const { service, publicSpaceRepo } = makeService(); + + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + }); + + expect(publicSpaceRepo.upsert.mock.calls[0][0].settings).toBeUndefined(); + }); + + it('rejects appearance when the workspace lacks the license feature', async () => { + const { service, publicSpaceRepo } = makeService({ + licenseCheckService: { resolveFeatures: jest.fn().mockReturnValue([]) }, + }); + + await expect( + service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + appearance: { primaryColorLight: '#111111' }, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + + expect(publicSpaceRepo.upsert).not.toHaveBeenCalled(); + }); + }); + + describe('public appearance exposure', () => { + const storedSettings = { + future: 1, + appearance: { + primaryColorLight: '#111111', + primaryColorDark: '#222222', + internal: 'secret', + }, + }; + const publicAppearance = { + primaryColorLight: '#111111', + primaryColorDark: '#222222', + }; + const publicSpaceRepoWithSettings = { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: true, + searchIndexing: false, + settings: storedSettings, + }), + }; + + it('whitelists appearance on getPublicSpaceInfo', async () => { + const { service } = makeService({ + publicSpaceRepo: publicSpaceRepoWithSettings, + }); + const result = await service.getPublicSpaceInfo( + 'handbook', + makeWorkspace(optInSettings), + ); + expect(result.appearance).toEqual(publicAppearance); + expect(result).not.toHaveProperty('settings'); + }); + + it('whitelists appearance on getPublicSpaceTree', async () => { + const { service } = makeService({ + publicSpaceRepo: publicSpaceRepoWithSettings, + }); + const result = await service.getPublicSpaceTree( + 'handbook', + makeWorkspace(optInSettings), + ); + expect(result.appearance).toEqual(publicAppearance); + expect(result).not.toHaveProperty('settings'); + }); + + it('whitelists appearance on the served page response', async () => { + const { service } = makeService({ + publicSpaceRepo: publicSpaceRepoWithSettings, + }); + const result = await service.getPublicPage( + 'handbook', + 'abc123XYZ0', + makeWorkspace(optInSettings), + ); + expect(result.appearance).toEqual(publicAppearance); + expect(result).not.toHaveProperty('settings'); + }); + + it('whitelists appearance on the empty-space page response', async () => { + const { service } = makeService({ + publicSpaceRepo: publicSpaceRepoWithSettings, + pageRepo: { + getFirstUnrestrictedRootPage: jest.fn().mockResolvedValue(undefined), + }, + }); + const result = await service.getPublicPage( + 'handbook', + undefined, + makeWorkspace(optInSettings), + ); + expect(result.page).toBeNull(); + expect(result.appearance).toEqual(publicAppearance); + expect(result).not.toHaveProperty('settings'); + }); + + it('strips appearance when the workspace lacks the license feature', async () => { + const { service } = makeService({ + publicSpaceRepo: publicSpaceRepoWithSettings, + licenseCheckService: { resolveFeatures: jest.fn().mockReturnValue([]) }, + }); + const result = await service.getPublicSpaceInfo( + 'handbook', + makeWorkspace(optInSettings), + ); + expect(result.appearance).toBeUndefined(); + }); + + it('omits appearance when none is stored', async () => { + const { service } = makeService(); + const result = await service.getPublicSpaceInfo( + 'handbook', + makeWorkspace(optInSettings), + ); + expect(result.appearance ?? {}).toEqual({}); + }); + }); + + describe('byline', () => { + function makeRepoWithByline(byline: any) { + return { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: true, + searchIndexing: false, + settings: byline === null ? null : { byline }, + }), + }; + } + + it('defaults to a hidden author and a visible updated date', async () => { + const { service, pageRepo } = makeService({ + publicSpaceRepo: makeRepoWithByline(null), + }); + + const result = await service.getPublicPage( + 'handbook', + 'abc123XYZ0', + makeWorkspace(optInSettings), + ); + + expect(result.byline).toEqual({ author: false, updatedAt: true }); + expect(pageRepo.findById).toHaveBeenCalledWith('abc123XYZ0', { + includeContent: true, + includeCreator: false, + }); + }); + + it('requests the creator only when the author byline is enabled', async () => { + const { service, pageRepo } = makeService({ + publicSpaceRepo: makeRepoWithByline({ author: true }), + }); + + const result = await service.getPublicPage( + 'handbook', + 'abc123XYZ0', + makeWorkspace(optInSettings), + ); + + expect(result.byline.author).toBe(true); + expect(pageRepo.findById).toHaveBeenCalledWith('abc123XYZ0', { + includeContent: true, + includeCreator: true, + }); + }); + + it('strips a creator from the page when the author byline is off', async () => { + const { service } = makeService({ + publicSpaceRepo: makeRepoWithByline({ author: false }), + pageRepo: { + findById: jest.fn().mockResolvedValue({ + id: 'page1', + slugId: 'abc123XYZ0', + spaceId: SPACE_ID, + workspaceId: WORKSPACE_ID, + deletedAt: null, + content: null, + creatorId: 'u1', + creator: { id: 'u1', name: 'Jane', avatarUrl: null }, + }), + }, + }); + + const result = await service.getPublicPage( + 'handbook', + 'abc123XYZ0', + makeWorkspace(optInSettings), + ); + + expect(result.page.creatorId).toBe('u1'); + expect((result.page as any).creator).toBeUndefined(); + }); + + it('returns the byline for an empty published space', async () => { + const { service } = makeService({ + publicSpaceRepo: makeRepoWithByline({ author: true, updatedAt: false }), + pageRepo: { + getFirstUnrestrictedRootPage: jest.fn().mockResolvedValue(undefined), + }, + }); + + const result = await service.getPublicPage( + 'handbook', + undefined, + makeWorkspace(optInSettings), + ); + + expect(result.page).toBeNull(); + expect(result.byline).toEqual({ author: true, updatedAt: false }); + }); + + it('merges a single byline field into the stored byline', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: makeRepoWithByline({ author: true, updatedAt: true }), + }); + + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + bylineUpdatedAt: false, + }); + + expect(publicSpaceRepo.upsert.mock.calls[0][0].settings).toEqual({ + byline: { author: true, updatedAt: false }, + }); + }); + + it('leaves stored settings untouched when no byline field is sent', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: makeRepoWithByline({ author: true }), + }); + + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + }); + + expect(publicSpaceRepo.upsert.mock.calls[0][0].settings).toBeUndefined(); + }); + }); + + describe('publish', () => { + it('rejects enabling when the workspace opt-in is off', async () => { + const { service } = makeService(); + await expect( + service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace({}), + authUserId: 'u1', + enabled: true, + }), + ).rejects.toThrow(ForbiddenException); + }); + + it('allows enabling when public page sharing is disabled', async () => { + const { service, publicSpaceRepo } = makeService(); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace({ + publicSpaces: { enabled: true }, + sharing: { disabled: true }, + }), + authUserId: 'u1', + enabled: true, + }); + + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true }), + ); + }); + + it('allows disabling even when the opt-in is off', async () => { + const { service, publicSpaceRepo } = makeService(); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace({}), + authUserId: 'u1', + enabled: false, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); + + it('defaults every option on except the author byline on first publish', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue(undefined), + }, + }); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace({ + publicSpaces: { enabled: true, directory: true }, + }), + authUserId: 'u1', + enabled: true, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + searchIndexing: true, + settings: { directory: true }, + }), + ); + }); + + it('skips the directory default when the workspace directory is off', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue(undefined), + }, + }); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + searchIndexing: true, + settings: undefined, + }), + ); + }); + + it('keeps explicitly provided flags on first publish', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue(undefined), + }, + }); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace({ + publicSpaces: { enabled: true, directory: true }, + }), + authUserId: 'u1', + enabled: true, + searchIndexing: false, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + searchIndexing: false, + settings: { directory: true }, + }), + ); + }); + + it('republish preserves prior customization instead of re-applying defaults', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: false, + searchIndexing: false, + settings: { directory: false }, + }), + }, + }); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + searchIndexing: undefined, + settings: undefined, + }), + ); + }); + }); + + describe('directory', () => { + const directorySettings = { + publicSpaces: { enabled: true, directory: true }, + }; + + function makeDirectoryRows() { + return [ + { + settings: { directory: true }, + searchIndexing: false, + name: 'Handbook', + slug: 'handbook', + description: 'All about us', + logo: null, + }, + { + settings: {}, + searchIndexing: true, + name: 'Hidden', + slug: 'hidden', + description: null, + logo: null, + }, + ]; + } + + it('404s when the workspace opt-in is off', async () => { + const { service } = makeService(); + await expect( + service.getPublicSpaceDirectory( + makeWorkspace({ publicSpaces: { enabled: false, directory: true } }), + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('404s when the directory flag is off', async () => { + const { service } = makeService(); + await expect( + service.getPublicSpaceDirectory(makeWorkspace(optInSettings)), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('lists only spaces opted into the directory with whitelisted fields', async () => { + const { service } = makeService({ + publicSpaceRepo: { + findEnabledWithSpaceByWorkspaceId: jest + .fn() + .mockResolvedValue(makeDirectoryRows()), + }, + }); + const result = await service.getPublicSpaceDirectory( + makeWorkspace(directorySettings), + ); + expect(result.spaces).toEqual([ + { + name: 'Handbook', + slug: 'handbook', + description: 'All about us', + logo: null, + }, + ]); + }); + + it('publish stores the directory flag while preserving other settings', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: true, + settings: { appearance: { primaryColorLight: '#111111' } }, + }), + }, + }); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + directory: true, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + settings: { + appearance: { primaryColorLight: '#111111' }, + directory: true, + }, + }), + ); + }); + + it('publish leaves settings untouched when directory is omitted', async () => { + const { service, publicSpaceRepo } = makeService(); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ settings: undefined }), + ); + }); + }); + + describe('cross-space link resolution', () => { + const OTHER_SPACE_ID = '018f0000-0000-7000-8000-000000000004'; + + const crossSpacePage = { + id: 'page2', + slugId: 'crossSlug001', + spaceId: OTHER_SPACE_ID, + workspaceId: WORKSPACE_ID, + deletedAt: null, + content: null, + }; + + const otherSpace = { + id: OTHER_SPACE_ID, + slug: 'engineering', + name: 'Engineering', + workspaceId: WORKSPACE_ID, + deletedAt: null, + }; + + it('resolves a contentless probe into another published space', async () => { + const { service } = makeService({ + pageRepo: { findById: jest.fn().mockResolvedValue(crossSpacePage) }, + spaceRepo: { findById: jest.fn().mockResolvedValue(otherSpace) }, + }); + const result = await service.getPublicPage( + 'handbook', + 'crossSlug001', + makeWorkspace(optInSettings), + { includeContent: false }, + ); + expect(result.space.slug).toBe('engineering'); + expect(result.page.id).toBe('page2'); + }); + + it('404s a cross-space target when content is requested', async () => { + const { service } = makeService({ + pageRepo: { findById: jest.fn().mockResolvedValue(crossSpacePage) }, + spaceRepo: { findById: jest.fn().mockResolvedValue(otherSpace) }, + }); + await expect( + service.getPublicPage( + 'handbook', + 'crossSlug001', + makeWorkspace(optInSettings), + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('404s when the target space is not published', async () => { + const { service } = makeService({ + pageRepo: { findById: jest.fn().mockResolvedValue(crossSpacePage) }, + spaceRepo: { findById: jest.fn().mockResolvedValue(otherSpace) }, + publicSpaceRepo: { + findBySpaceId: jest + .fn() + .mockImplementation(async (spaceId: string) => + spaceId === SPACE_ID + ? { id: 'ps1', spaceId: SPACE_ID, enabled: true } + : { id: 'ps2', spaceId, enabled: false }, + ), + }, + }); + await expect( + service.getPublicPage( + 'handbook', + 'crossSlug001', + makeWorkspace(optInSettings), + { includeContent: false }, + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('404s when the target space is outside the workspace', async () => { + const { service } = makeService({ + pageRepo: { findById: jest.fn().mockResolvedValue(crossSpacePage) }, + spaceRepo: { findById: jest.fn().mockResolvedValue(undefined) }, + }); + await expect( + service.getPublicPage( + 'handbook', + 'crossSlug001', + makeWorkspace(optInSettings), + { includeContent: false }, + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('404s when the cross-space target has a restricted ancestor', async () => { + const { service } = makeService({ + pageRepo: { findById: jest.fn().mockResolvedValue(crossSpacePage) }, + spaceRepo: { findById: jest.fn().mockResolvedValue(otherSpace) }, + pagePermissionRepo: { + hasRestrictedAncestor: jest.fn().mockResolvedValue(true), + }, + }); + await expect( + service.getPublicPage( + 'handbook', + 'crossSlug001', + makeWorkspace(optInSettings), + { includeContent: false }, + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); +}); diff --git a/apps/server/src/core/public-space/public-space.service.ts b/apps/server/src/core/public-space/public-space.service.ts new file mode 100644 index 000000000..d524ddf2a --- /dev/null +++ b/apps/server/src/core/public-space/public-space.service.ts @@ -0,0 +1,397 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PublicSpaceRepo } from '@docmost/db/repos/public-space/public-space.repo'; +import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; +import { ShareService } from '../share/share.service'; +import { TransclusionService } from '../page/transclusion/transclusion.service'; +import { TransclusionLookup } from '../page/transclusion/transclusion.types'; +import { + Page, + PublicSpace, + Space, + Workspace, +} from '@docmost/db/types/entity.types'; +import { PublicSpaceAppearanceDto } from './dto/public-space.dto'; +import { LicenseCheckService } from '../../integrations/environment/license-check.service'; +import { EnvironmentService } from '../../integrations/environment/environment.service'; +import { Feature, FeatureKey } from '../../common/features'; + +@Injectable() +export class PublicSpaceService { + constructor( + private readonly publicSpaceRepo: PublicSpaceRepo, + private readonly spaceRepo: SpaceRepo, + private readonly pageRepo: PageRepo, + private readonly pagePermissionRepo: PagePermissionRepo, + private readonly shareService: ShareService, + private readonly transclusionService: TransclusionService, + private readonly licenseCheckService: LicenseCheckService, + private readonly environmentService: EnvironmentService, + ) {} + + hasFeature(workspace: Workspace, feature: FeatureKey): boolean { + return this.licenseCheckService + .resolveFeatures(workspace.licenseKey, workspace.plan) + .includes(feature); + } + + isPublicSpacesAllowed(workspace: Workspace): boolean { + const settings = workspace.settings as any; + return ( + this.environmentService.isBetaPublicSpaces() && + settings?.publicSpaces?.enabled === true + ); + } + + private isDirectoryEnabled(workspace: Workspace): boolean { + return (workspace.settings as any)?.publicSpaces?.directory === true; + } + + async getPublicSpace(spaceSlug: string, workspace: Workspace) { + if (!this.isPublicSpacesAllowed(workspace)) { + throw new NotFoundException('Space not found'); + } + + const space = await this.spaceRepo.findBySlug(spaceSlug, workspace.id); + if (!space || space.deletedAt) { + throw new NotFoundException('Space not found'); + } + + const publicSpace = await this.publicSpaceRepo.findBySpaceId(space.id); + if (!publicSpace?.enabled) { + throw new NotFoundException('Space not found'); + } + + return { space, publicSpace }; + } + + async getPublicSpaceInfo(spaceSlug: string, workspace: Workspace) { + const { space, publicSpace } = await this.getPublicSpace( + spaceSlug, + workspace, + ); + return { + space: this.toPublicSpaceFields(space), + searchIndexing: publicSpace.searchIndexing, + appearance: this.toPublicAppearance(publicSpace, workspace), + }; + } + + async getPublicSpaceTree(spaceSlug: string, workspace: Workspace) { + const { space, publicSpace } = await this.getPublicSpace( + spaceSlug, + workspace, + ); + const pageTree = await this.pageRepo.getSpacePagesExcludingRestricted( + space.id, + ); + return { + space: this.toPublicSpaceFields(space), + pageTree, + appearance: this.toPublicAppearance(publicSpace, workspace), + }; + } + + async getPublicPage( + spaceSlug: string, + pageSlugId: string | undefined, + workspace: Workspace, + opts?: { includeContent?: boolean }, + ) { + const includeContent = opts?.includeContent !== false; + + const { space, publicSpace } = await this.getPublicSpace( + spaceSlug, + workspace, + ); + const byline = this.getBylineSettings(publicSpace); + + let pageId = pageSlugId; + if (!pageId) { + const firstRoot = await this.pageRepo.getFirstUnrestrictedRootPage( + space.id, + ); + if (!firstRoot) { + return { + page: null, + space: this.toPublicSpaceFields(space), + searchIndexing: publicSpace.searchIndexing, + appearance: this.toPublicAppearance(publicSpace, workspace), + byline, + }; + } + pageId = firstRoot.id; + } + + const page = includeContent + ? await this.pageRepo.findById(pageId, { + includeContent: true, + includeCreator: byline.author, + }) + : await this.pageRepo.findById(pageId); + if (!page || page.deletedAt) { + throw new NotFoundException('Page not found'); + } + + // cross-space targets resolve only as contentless link probes, and only + // into published spaces; content stays canonical under its own space URL + if (page.spaceId !== space.id) { + if (includeContent) { + throw new NotFoundException('Page not found'); + } + return this.resolveCrossSpacePublicPage(page, workspace); + } + + const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor( + page.id, + ); + if (isRestricted) { + throw new NotFoundException('Page not found'); + } + + // never ship creator details the space admin chose to hide + if (!byline.author && 'creator' in page) { + delete (page as any).creator; + } + + if (includeContent) { + page.content = await this.shareService.updatePublicAttachments(page); + } + + return { + page, + space: this.toPublicSpaceFields(space), + searchIndexing: publicSpace.searchIndexing, + appearance: this.toPublicAppearance(publicSpace, workspace), + byline, + }; + } + + /** Uniform 404 unless the target page's own space is published, not deleted, in this workspace, and unrestricted. */ + private async resolveCrossSpacePublicPage(page: Page, workspace: Workspace) { + const space = await this.spaceRepo.findById(page.spaceId, workspace.id); + if (!space || space.deletedAt) { + throw new NotFoundException('Page not found'); + } + + const publicSpace = await this.publicSpaceRepo.findBySpaceId(space.id); + if (!publicSpace?.enabled) { + throw new NotFoundException('Page not found'); + } + + const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor( + page.id, + ); + if (isRestricted) { + throw new NotFoundException('Page not found'); + } + + return { + page, + space: this.toPublicSpaceFields(space), + searchIndexing: publicSpace.searchIndexing, + appearance: this.toPublicAppearance(publicSpace, workspace), + byline: this.getBylineSettings(publicSpace), + }; + } + + /** Share-style transclusion resolution scoped to one public space; viewer permissions are never consulted. */ + async lookupTransclusionForPublicSpace( + spaceSlug: string, + references: Array<{ sourcePageId: string; transclusionId: string }>, + workspace: Workspace, + ): Promise<{ items: TransclusionLookup[] }> { + const { space } = await this.getPublicSpace(spaceSlug, workspace); + + const candidatePageIds = Array.from( + new Set(references.map((r) => r.sourcePageId)), + ); + + const accessibleResults = await Promise.all( + candidatePageIds.map(async (pageId) => { + const page = await this.pageRepo.findById(pageId); + if (!page || page.deletedAt || page.spaceId !== space.id) return null; + const restricted = + await this.pagePermissionRepo.hasRestrictedAncestor(page.id); + if (restricted) return null; + return page.id; + }), + ); + const accessibleSet = new Set( + accessibleResults.filter((id): id is string => id !== null), + ); + + const { items } = await this.transclusionService.lookupWithAccessSet( + references, + accessibleSet, + workspace.id, + ); + + return { + items: await this.shareService.sanitizeTransclusionItemsForPublic( + items, + workspace.id, + ), + }; + } + + async publish(opts: { + space: Space; + workspace: Workspace; + authUserId: string; + enabled: boolean; + searchIndexing?: boolean; + appearance?: PublicSpaceAppearanceDto; + bylineAuthor?: boolean; + bylineUpdatedAt?: boolean; + directory?: boolean; + }) { + const { + space, + workspace, + authUserId, + enabled, + appearance, + bylineAuthor, + bylineUpdatedAt, + } = opts; + let { searchIndexing, directory } = opts; + + if (!this.environmentService.isBetaPublicSpaces()) { + throw new ForbiddenException( + 'Public spaces are not enabled on this instance', + ); + } + + if (enabled && !this.isPublicSpacesAllowed(workspace)) { + throw new ForbiddenException( + 'Public spaces are not enabled for this workspace', + ); + } + + if (appearance && !this.hasFeature(workspace, Feature.PUBLIC_SPACE_APPEARANCE)) { + throw new ForbiddenException( + 'Public docs appearance requires a paid license', + ); + } + + const prev = await this.publicSpaceRepo.findBySpaceId(space.id); + + // first publish defaults every option on except the author byline; republish keeps prior customization + if (enabled && !prev) { + searchIndexing ??= true; + if (this.isDirectoryEnabled(workspace)) { + directory ??= true; + } + } + + const hasByline = + typeof bylineAuthor !== 'undefined' || + typeof bylineUpdatedAt !== 'undefined'; + + let settings: Record | undefined; + if (appearance || hasByline || typeof directory !== 'undefined') { + const prevSettings = (prev?.settings as Record) ?? {}; + settings = { ...prevSettings }; + + if (typeof directory !== 'undefined') { + settings.directory = directory; + } + + if (appearance) { + const nextAppearance: Record = { + ...(prevSettings.appearance ?? {}), + }; + for (const key of ['primaryColorLight', 'primaryColorDark'] as const) { + const value = appearance[key]; + if (value === null) delete nextAppearance[key]; + else if (typeof value !== 'undefined') nextAppearance[key] = value; + } + settings.appearance = nextAppearance; + } + + if (hasByline) { + const prevByline = this.getBylineSettings(prev); + settings.byline = { + author: bylineAuthor ?? prevByline.author, + updatedAt: bylineUpdatedAt ?? prevByline.updatedAt, + }; + } + } + + return this.publicSpaceRepo.upsert({ + spaceId: space.id, + workspaceId: space.workspaceId, + enabled, + searchIndexing, + creatorId: authUserId, + settings, + }); + } + + /** The /docs hub: listed public spaces only, behind the workspace double opt-in. */ + async getPublicSpaceDirectory(workspace: Workspace) { + if ( + !this.isPublicSpacesAllowed(workspace) || + !this.isDirectoryEnabled(workspace) + ) { + throw new NotFoundException('Not found'); + } + + const rows = await this.publicSpaceRepo.findEnabledWithSpaceByWorkspaceId( + workspace.id, + ); + const listed = rows.filter( + (row) => (row.settings as any)?.directory === true, + ); + + return { + spaces: listed.map((row) => ({ + name: row.name, + slug: row.slug, + description: row.description, + logo: row.logo, + })), + }; + } + + private toPublicSpaceFields(space: Space) { + return { + id: space.id, + name: space.name, + slug: space.slug, + description: space.description, + logo: space.logo, + }; + } + + private getBylineSettings(publicSpace: PublicSpace) { + const byline = (publicSpace?.settings as any)?.byline; + return { + author: byline?.author === true, + updatedAt: byline?.updatedAt !== false, + }; + } + + private toPublicAppearance(publicSpace: PublicSpace, workspace: Workspace) { + if (!this.hasFeature(workspace, Feature.PUBLIC_SPACE_APPEARANCE)) { + return undefined; + } + const appearance = (publicSpace?.settings as any)?.appearance; + if (!appearance) return undefined; + const result: { primaryColorLight?: string; primaryColorDark?: string } = + {}; + if (typeof appearance.primaryColorLight === 'string') { + result.primaryColorLight = appearance.primaryColorLight; + } + if (typeof appearance.primaryColorDark === 'string') { + result.primaryColorDark = appearance.primaryColorDark; + } + return Object.keys(result).length > 0 ? result : undefined; + } +} diff --git a/apps/server/src/core/search/dto/search.dto.ts b/apps/server/src/core/search/dto/search.dto.ts index 89fb1b289..b9d09d17d 100644 --- a/apps/server/src/core/search/dto/search.dto.ts +++ b/apps/server/src/core/search/dto/search.dto.ts @@ -53,6 +53,12 @@ export class SearchShareDTO extends SearchDTO { spaceId: string; } +export class SearchPublicSpaceDTO extends SearchDTO { + @IsNotEmpty() + @IsString() + spaceSlug: string; +} + export class SearchSuggestionDTO { @IsString() query: string; diff --git a/apps/server/src/core/search/search.controller.spec.ts b/apps/server/src/core/search/search.controller.spec.ts index 6d6bad58e..b04994764 100644 --- a/apps/server/src/core/search/search.controller.spec.ts +++ b/apps/server/src/core/search/search.controller.spec.ts @@ -1,4 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; import { SearchController } from './search.controller'; describe('SearchController', () => { @@ -16,3 +17,77 @@ describe('SearchController', () => { expect(controller).toBeDefined(); }); }); + +describe('SearchController public-space-search gate', () => { + function makeController(overrides: any = {}) { + const searchService = { searchPage: jest.fn().mockResolvedValue([]) }; + const environmentService = { + getSearchDriver: jest.fn().mockReturnValue('postgres'), + }; + const publicSpaceService = { + getPublicSpace: jest + .fn() + .mockRejectedValue(new NotFoundException('Space not found')), + ...overrides.publicSpaceService, + }; + const pageRepo = { + getSpacePagesExcludingRestricted: jest + .fn() + .mockResolvedValue([{ id: 'p1' }, { id: 'p2' }]), + }; + const controller = new SearchController( + searchService as any, + {} as any, + environmentService as any, + publicSpaceService as any, + pageRepo as any, + {} as any, + ); + return { controller, searchService, publicSpaceService, pageRepo }; + } + + const workspace = { id: 'ws1' } as any; + + it('does not read pages or search when the public space gate rejects', async () => { + const { controller, searchService, pageRepo } = makeController(); + await expect( + controller.searchPublicSpace( + { query: 'roadmap', spaceSlug: 'handbook' } as any, + workspace, + ), + ).rejects.toBeInstanceOf(NotFoundException); + expect(pageRepo.getSpacePagesExcludingRestricted).not.toHaveBeenCalled(); + expect(searchService.searchPage).not.toHaveBeenCalled(); + }); + + it('searches only the unrestricted pages of the gated space, ignoring client filters', async () => { + const { controller, searchService, publicSpaceService, pageRepo } = + makeController({ + publicSpaceService: { + getPublicSpace: jest + .fn() + .mockResolvedValue({ space: { id: 's1' }, publicSpace: {} }), + }, + }); + await controller.searchPublicSpace( + { + query: 'roadmap', + spaceSlug: 'handbook', + spaceId: 'attacker-space', + shareId: 'share1', + creatorId: 'u1', + labelIds: ['l1'], + } as any, + workspace, + ); + expect(publicSpaceService.getPublicSpace).toHaveBeenCalledWith( + 'handbook', + workspace, + ); + expect(pageRepo.getSpacePagesExcludingRestricted).toHaveBeenCalledWith('s1'); + expect(searchService.searchPage).toHaveBeenCalledWith( + { query: 'roadmap', spaceSlug: 'handbook' }, + { workspaceId: 'ws1', publicPageIds: ['p1', 'p2'] }, + ); + }); +}); diff --git a/apps/server/src/core/search/search.controller.ts b/apps/server/src/core/search/search.controller.ts index e060d8a8c..469fa9c7e 100644 --- a/apps/server/src/core/search/search.controller.ts +++ b/apps/server/src/core/search/search.controller.ts @@ -12,6 +12,7 @@ import { import { SearchService } from './search.service'; import { SearchDTO, + SearchPublicSpaceDTO, SearchShareDTO, SearchSuggestionDTO, } from './dto/search.dto'; @@ -28,6 +29,8 @@ import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { Public } from 'src/common/decorators/public.decorator'; import { EnvironmentService } from '../../integrations/environment/environment.service'; import { ModuleRef } from '@nestjs/core'; +import { PublicSpaceService } from '../public-space/public-space.service'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; @UseGuards(JwtAuthGuard) @Controller('search') @@ -38,6 +41,8 @@ export class SearchController { private readonly searchService: SearchService, private readonly spaceAbility: SpaceAbilityFactory, private readonly environmentService: EnvironmentService, + private readonly publicSpaceService: PublicSpaceService, + private readonly pageRepo: PageRepo, private moduleRef: ModuleRef, ) {} @@ -109,14 +114,51 @@ export class SearchController { }); } + @Public() + @HttpCode(HttpStatus.OK) + @Post('public-space-search') + async searchPublicSpace( + @Body() searchDto: SearchPublicSpaceDTO, + @AuthWorkspace() workspace: Workspace, + ) { + delete searchDto.spaceId; + delete searchDto.shareId; + // Member-facing filters stay internal: creator identities and label + // taxonomy must not become a grouping oracle on the anonymous surface. + delete searchDto.creatorId; + delete searchDto.labelIds; + + const { space } = await this.publicSpaceService.getPublicSpace( + searchDto.spaceSlug, + workspace, + ); + const pages = await this.pageRepo.getSpacePagesExcludingRestricted( + space.id, + ); + const publicPageIds = pages.map((page) => page.id); + + if (this.environmentService.getSearchDriver() === 'typesense') { + return this.searchTypesense(searchDto, { + workspaceId: workspace.id, + publicPageIds, + }); + } + + return this.searchService.searchPage(searchDto, { + workspaceId: workspace.id, + publicPageIds, + }); + } + async searchTypesense( searchParams: SearchDTO, opts: { userId?: string; workspaceId: string; + publicPageIds?: string[]; }, ) { - const { userId, workspaceId } = opts; + const { userId, workspaceId, publicPageIds } = opts; let TypesenseModule: any; try { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -132,6 +174,7 @@ export class SearchController { return PageSearchService.searchPage(searchParams, { userId: userId, workspaceId, + publicPageIds, }); } catch (err) { this.logger.debug( diff --git a/apps/server/src/core/search/search.module.ts b/apps/server/src/core/search/search.module.ts index fa74573df..4bd944e27 100644 --- a/apps/server/src/core/search/search.module.ts +++ b/apps/server/src/core/search/search.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { SearchController } from './search.controller'; import { SearchService } from './search.service'; +import { PublicSpaceModule } from '../public-space/public-space.module'; @Module({ + imports: [PublicSpaceModule], controllers: [SearchController], providers: [SearchService], exports: [SearchService], diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index 32cedd7ee..bfb3ca918 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -27,6 +27,7 @@ export class SearchService { opts: { userId?: string; workspaceId: string; + publicPageIds?: string[]; }, ): Promise<{ items: SearchResponseDto[] }> { const query = searchParams.query?.trim() ?? ''; @@ -109,7 +110,7 @@ export class SearchService { .limit(searchParams.limit || 25) .offset(searchParams.offset || 0); - if (!searchParams.shareId) { + if (!searchParams.shareId && !opts.publicPageIds) { queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb)); } @@ -124,6 +125,15 @@ export class SearchService { this.spaceMemberRepo.getUserSpaceIdsQuery(opts.userId), ) .where('workspaceId', '=', opts.workspaceId); + } else if (opts.publicPageIds && !opts.userId) { + // Public space search: the allowed id set is computed from live DB + // state by the controller on every request. + if (opts.publicPageIds.length === 0) { + return { items: [] }; + } + queryResults = queryResults + .where('id', 'in', opts.publicPageIds) + .where('workspaceId', '=', opts.workspaceId); } else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) { // search in shares const shareId = searchParams.shareId; diff --git a/apps/server/src/core/share/share-seo.controller.ts b/apps/server/src/core/share/share-seo.controller.ts index 51967ada5..d2cfdd975 100644 --- a/apps/server/src/core/share/share-seo.controller.ts +++ b/apps/server/src/core/share/share-seo.controller.ts @@ -85,8 +85,8 @@ export class ShareSeoController { const html = fs.readFileSync(indexFilePath, 'utf8'); const transformedHtml = html - .replace(/[\s\S]*?<\/title>/i, `<title>${metaTitle}`) - .replace(metaTagVar, metaTags); + .replace(/[\s\S]*?<\/title>/i, () => `<title>${metaTitle}`) + .replace(metaTagVar, () => metaTags); res.type('text/html').send(transformedHtml); } diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts index e567e6caa..68acf48e8 100644 --- a/apps/server/src/core/share/share.service.ts +++ b/apps/server/src/core/share/share.service.ts @@ -365,35 +365,9 @@ export class ShareService { workspaceId, ); - // Sanitize each item's content for public delivery - // generate per-attachment tokens scoped to the source page - // and strip comment marks. - const tokenized = await Promise.all( - items.map(async (item) => { - if ('status' in item) return item; - const doc = await this.prepareContentForShare( - item.content, - item.sourcePageId, - workspaceId, - ); - return { ...item, content: doc?.toJSON() ?? item.content }; - }), - ); - - // Collapse `not_found` to `no_access` for share viewers so the response - // can't be used to tell "page is shared but transclusion id doesn't - // match" from "page isn't shared at all". - const sanitized = tokenized.map((item) => - 'status' in item && item.status === 'not_found' - ? { - sourcePageId: item.sourcePageId, - transclusionId: item.transclusionId, - status: 'no_access' as const, - } - : item, - ); - - return { items: sanitized }; + return { + items: await this.sanitizeTransclusionItemsForPublic(items, workspaceId), + }; } async isSharingAllowed( @@ -430,6 +404,38 @@ export class ShareService { return doc?.toJSON() ?? page.content; } + /** + * Sanitization tail shared by every public transclusion surface: tokenize + * each content item against its source page, then hide lookup misses. + */ + async sanitizeTransclusionItemsForPublic( + items: TransclusionLookup[], + workspaceId: string, + ): Promise { + const tokenized = await Promise.all( + items.map(async (item) => { + if ('status' in item) return item; + const doc = await this.prepareContentForShare( + item.content, + item.sourcePageId, + workspaceId, + ); + return { ...item, content: doc?.toJSON() ?? item.content }; + }), + ); + + // Collapse not_found to no_access so hidden sources are indistinguishable from missing ids. + return tokenized.map((item) => + 'status' in item && item.status === 'not_found' + ? { + sourcePageId: item.sourcePageId, + transclusionId: item.transclusionId, + status: 'no_access' as const, + } + : item, + ); + } + /** * Prepare a ProseMirror JSON doc for delivery to a public share viewer. * Performs the two transforms required by the share threat model: diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts index 51b749516..eb5e44d29 100644 --- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts +++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts @@ -39,6 +39,14 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) { @IsBoolean() disablePublicSharing: boolean; + @IsOptional() + @IsBoolean() + allowPublicSpaces: boolean; + + @IsOptional() + @IsBoolean() + publicSpacesDirectory: boolean; + @IsOptional() @IsBoolean() mcpEnabled: boolean; diff --git a/apps/server/src/core/workspace/services/workspace.service.spec.ts b/apps/server/src/core/workspace/services/workspace.service.spec.ts deleted file mode 100644 index 0f5443495..000000000 --- a/apps/server/src/core/workspace/services/workspace.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { WorkspaceService } from './workspace.service'; - -describe('WorkspaceService', () => { - let service: WorkspaceService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [WorkspaceService], - }).compile(); - - service = module.get(WorkspaceService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index 1f5db0fec..c110bb8d1 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -42,6 +42,7 @@ import { import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { ShareRepo } from '@docmost/db/repos/share/share.repo'; +import { PublicSpaceRepo } from '@docmost/db/repos/public-space/public-space.repo'; import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo'; import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo'; import { AuditEvent, AuditResource } from '../../../common/events/audit-events'; @@ -65,6 +66,7 @@ export class WorkspaceService { private domainService: DomainService, private licenseCheckService: LicenseCheckService, private shareRepo: ShareRepo, + private readonly publicSpaceRepo: PublicSpaceRepo, private watcherRepo: WatcherRepo, private favoriteRepo: FavoriteRepo, @InjectKysely() private readonly db: KyselyDB, @@ -504,6 +506,47 @@ export class WorkspaceService { } } + if ( + !this.environmentService.isBetaPublicSpaces() && + (typeof updateWorkspaceDto.allowPublicSpaces !== 'undefined' || + typeof updateWorkspaceDto.publicSpacesDirectory !== 'undefined') + ) { + throw new ForbiddenException( + 'Public spaces are not enabled on this instance', + ); + } + + if (typeof updateWorkspaceDto.allowPublicSpaces !== 'undefined') { + const prev = settingsBefore?.publicSpaces?.enabled ?? false; + if (prev !== updateWorkspaceDto.allowPublicSpaces) { + before.allowPublicSpaces = prev; + after.allowPublicSpaces = updateWorkspaceDto.allowPublicSpaces; + } + await this.workspaceRepo.updatePublicSpacesSettings( + workspaceId, + 'enabled', + updateWorkspaceDto.allowPublicSpaces, + trx, + ); + if (!updateWorkspaceDto.allowPublicSpaces) { + await this.publicSpaceRepo.disableByWorkspaceId(workspaceId, trx); + } + } + + if (typeof updateWorkspaceDto.publicSpacesDirectory !== 'undefined') { + const prev = settingsBefore?.publicSpaces?.directory ?? false; + if (prev !== updateWorkspaceDto.publicSpacesDirectory) { + before.publicSpacesDirectory = prev; + after.publicSpacesDirectory = updateWorkspaceDto.publicSpacesDirectory; + } + await this.workspaceRepo.updatePublicSpacesSettings( + workspaceId, + 'directory', + updateWorkspaceDto.publicSpacesDirectory, + trx, + ); + } + if (typeof updateWorkspaceDto.mcpEnabled !== 'undefined') { const prev = settingsBefore?.ai?.mcp ?? false; if (prev !== updateWorkspaceDto.mcpEnabled) { @@ -620,6 +663,8 @@ export class WorkspaceService { delete updateWorkspaceDto.aiSearch; delete updateWorkspaceDto.generativeAi; delete updateWorkspaceDto.disablePublicSharing; + delete updateWorkspaceDto.allowPublicSpaces; + delete updateWorkspaceDto.publicSpacesDirectory; delete updateWorkspaceDto.mcpEnabled; delete updateWorkspaceDto.allowMemberTemplates; delete updateWorkspaceDto.aiChat; diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index d5ecb4ac9..89164acbe 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -22,6 +22,7 @@ import { UserTokenRepo } from './repos/user-token/user-token.repo'; import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo'; import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo'; +import { PublicSpaceRepo } from '@docmost/db/repos/public-space/public-space.repo'; import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo'; import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo'; import { LabelRepo } from '@docmost/db/repos/label/label.repo'; @@ -88,6 +89,7 @@ import { normalizePostgresUrl } from '../common/helpers'; UserSessionRepo, BacklinkRepo, ShareRepo, + PublicSpaceRepo, NotificationRepo, WatcherRepo, LabelRepo, @@ -113,6 +115,7 @@ import { normalizePostgresUrl } from '../common/helpers'; UserSessionRepo, BacklinkRepo, ShareRepo, + PublicSpaceRepo, NotificationRepo, WatcherRepo, LabelRepo, diff --git a/apps/server/src/database/migrations/20260904T171920-public-spaces.ts b/apps/server/src/database/migrations/20260904T171920-public-spaces.ts new file mode 100644 index 000000000..2408faadf --- /dev/null +++ b/apps/server/src/database/migrations/20260904T171920-public-spaces.ts @@ -0,0 +1,38 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('public_spaces') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('space_id', 'uuid', (col) => + col.references('spaces.id').onDelete('cascade').notNull().unique(), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('enabled', 'boolean', (col) => col.notNull().defaultTo(false)) + .addColumn('search_indexing', 'boolean', (col) => + col.notNull().defaultTo(false), + ) + .addColumn('settings', 'jsonb', (col) => col) + .addColumn('creator_id', 'uuid', (col) => col.references('users.id')) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .execute(); + + await db.schema + .createIndex('public_spaces_workspace_id_idx') + .on('public_spaces') + .column('workspace_id') + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('public_spaces').execute(); +} diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index 9eb9f3a50..389314236 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -605,4 +605,83 @@ export class PageRepo { .execute() ); } + + /** + * All pages of a space excluding restricted subtrees. + * Used by public spaces; a restricted page hides its whole subtree. + */ + async getSpacePagesExcludingRestricted(spaceId: string) { + return this.db + .withRecursive('page_hierarchy', (db) => + db + .selectFrom('pages') + .leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id') + .select([ + 'pages.id', + 'pages.slugId', + 'pages.title', + 'pages.icon', + 'pages.position', + 'pages.parentPageId', + 'pages.spaceId', + 'pages.workspaceId', + sql`page_access.id IS NOT NULL`.as('isRestricted'), + ]) + .where('pages.spaceId', '=', spaceId) + .where('pages.parentPageId', 'is', null) + .where('pages.deletedAt', 'is', null) + .unionAll((exp) => + exp + .selectFrom('pages as p') + .innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id') + .leftJoin('pageAccess', 'pageAccess.pageId', 'p.id') + .select([ + 'p.id', + 'p.slugId', + 'p.title', + 'p.icon', + 'p.position', + 'p.parentPageId', + 'p.spaceId', + 'p.workspaceId', + sql`page_access.id IS NOT NULL`.as('isRestricted'), + ]) + .where('p.deletedAt', 'is', null) + .where('ph.isRestricted', '=', false), + ), + ) + .selectFrom('page_hierarchy') + .select([ + 'id', + 'slugId', + 'title', + 'icon', + 'position', + 'parentPageId', + 'spaceId', + 'workspaceId', + ]) + .where('isRestricted', '=', false) + .execute(); + } + + async getFirstUnrestrictedRootPage(spaceId: string) { + return this.db + .selectFrom('pages') + .select(['id', 'slugId']) + .where('spaceId', '=', spaceId) + .where('parentPageId', 'is', null) + .where('deletedAt', 'is', null) + .where(({ not, exists, selectFrom }) => + not( + exists( + selectFrom('pageAccess') + .select('pageAccess.id') + .whereRef('pageAccess.pageId', '=', 'pages.id'), + ), + ), + ) + .orderBy('position', (ob) => ob.collate('C').asc()) + .executeTakeFirst(); + } } diff --git a/apps/server/src/database/repos/public-space/public-space.repo.ts b/apps/server/src/database/repos/public-space/public-space.repo.ts new file mode 100644 index 000000000..e6b122fd2 --- /dev/null +++ b/apps/server/src/database/repos/public-space/public-space.repo.ts @@ -0,0 +1,196 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { ExpressionBuilder, sql } from 'kysely'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; +import { KyselyDB, KyselyTransaction } from '../../types/kysely.types'; +import { dbOrTx } from '../../utils'; +import { PublicSpace } from '@docmost/db/types/entity.types'; +import { DB, Json } from '@docmost/db/types/db'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; +import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; + +@Injectable() +export class PublicSpaceRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async findBySpaceId( + spaceId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('publicSpaces') + .selectAll() + .where('spaceId', '=', spaceId) + .executeTakeFirst(); + } + + async upsert(opts: { + spaceId: string; + workspaceId: string; + enabled: boolean; + searchIndexing?: boolean; + creatorId: string; + settings?: Record; + }): Promise { + const settingsColumn = + typeof opts.settings !== 'undefined' + ? { settings: sql`${JSON.stringify(opts.settings)}::text::jsonb` } + : {}; + + return this.db + .insertInto('publicSpaces') + .values({ + spaceId: opts.spaceId, + workspaceId: opts.workspaceId, + enabled: opts.enabled, + searchIndexing: opts.searchIndexing ?? false, + creatorId: opts.creatorId, + ...settingsColumn, + }) + .onConflict((oc) => + oc.column('spaceId').doUpdateSet({ + enabled: opts.enabled, + updatedAt: new Date(), + ...(typeof opts.searchIndexing !== 'undefined' + ? { searchIndexing: opts.searchIndexing } + : {}), + ...settingsColumn, + }), + ) + .returningAll() + .executeTakeFirst(); + } + + // Published spaces are public by definition, so the list spans the whole + // workspace; userId only resolves the viewer's per-space role. + async getPublishedSpaces( + userId: string, + workspaceId: string, + pagination: PaginationOptions, + ) { + const query = this.db + .selectFrom('publicSpaces') + .select([ + 'id', + 'spaceId', + 'workspaceId', + 'searchIndexing', + 'settings', + 'createdAt', + 'updatedAt', + ]) + .select((eb) => this.withSpace(eb, userId)) + .select((eb) => this.withCreator(eb)) + .where('workspaceId', '=', workspaceId) + .where('enabled', '=', true) + .where(({ exists, selectFrom }) => + exists( + selectFrom('spaces') + .select('spaces.id') + .whereRef('spaces.id', '=', 'publicSpaces.spaceId') + .where('spaces.deletedAt', 'is', null), + ), + ); + + return executeWithCursorPagination(query, { + perPage: pagination.limit, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [ + { expression: 'updatedAt', direction: 'desc' }, + { expression: 'id', direction: 'desc' }, + ], + parseCursor: (cursor) => ({ + updatedAt: new Date(cursor.updatedAt), + id: cursor.id, + }), + }); + } + + withSpace(eb: ExpressionBuilder, userId: string) { + return jsonObjectFrom( + eb + .selectFrom('spaces') + .select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo']) + .select((eb) => this.withUserSpaceRole(eb, userId)) + .whereRef('spaces.id', '=', 'publicSpaces.spaceId'), + ).as('space'); + } + + withUserSpaceRole(eb: ExpressionBuilder, userId: string) { + return eb + .selectFrom( + eb + .selectFrom('spaceMembers') + .select(['spaceMembers.role']) + .whereRef('spaceMembers.spaceId', '=', 'spaces.id') + .where('spaceMembers.userId', '=', userId) + .unionAll( + eb + .selectFrom('spaceMembers') + .innerJoin( + 'groupUsers', + 'groupUsers.groupId', + 'spaceMembers.groupId', + ) + .select(['spaceMembers.role']) + .whereRef('spaceMembers.spaceId', '=', 'spaces.id') + .where('groupUsers.userId', '=', userId), + ) + .as('roles_union'), + ) + .select('roles_union.role') + .orderBy( + sql`CASE roles_union.role + WHEN 'admin' THEN 3 + WHEN 'writer' THEN 2 + WHEN 'reader' THEN 1 + ELSE 0 + END`, + 'desc', + ) + .limit(1) + .as('userRole'); + } + + withCreator(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('users') + .select(['users.id', 'users.name', 'users.avatarUrl']) + .whereRef('users.id', '=', 'publicSpaces.creatorId'), + ).as('creator'); + } + + async findEnabledWithSpaceByWorkspaceId(workspaceId: string) { + return this.db + .selectFrom('publicSpaces') + .innerJoin('spaces', 'spaces.id', 'publicSpaces.spaceId') + .select([ + 'publicSpaces.settings', + 'publicSpaces.searchIndexing', + 'spaces.name', + 'spaces.slug', + 'spaces.description', + 'spaces.logo', + ]) + .where('publicSpaces.workspaceId', '=', workspaceId) + .where('publicSpaces.enabled', '=', true) + .where('spaces.deletedAt', 'is', null) + .orderBy('spaces.name', 'asc') + .execute(); + } + + async disableByWorkspaceId( + workspaceId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db + .updateTable('publicSpaces') + .set({ enabled: false, updatedAt: new Date() }) + .where('workspaceId', '=', workspaceId) + .execute(); + } +} diff --git a/apps/server/src/database/repos/space/space.repo.ts b/apps/server/src/database/repos/space/space.repo.ts index 82fc170bd..f91aae55e 100644 --- a/apps/server/src/database/repos/space/space.repo.ts +++ b/apps/server/src/database/repos/space/space.repo.ts @@ -33,6 +33,7 @@ export class SpaceRepo { .selectFrom('spaces') .selectAll('spaces') .$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount)) + .select((eb) => this.withIsPublished(eb)) .where('workspaceId', '=', workspaceId); if (isValidUUID(spaceId)) { @@ -52,6 +53,7 @@ export class SpaceRepo { .selectFrom('spaces') .selectAll('spaces') .$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount)) + .select((eb) => this.withIsPublished(eb)) .where(sql`LOWER(slug)`, '=', sql`LOWER(${slug})`) .where('workspaceId', '=', workspaceId) .executeTakeFirst(); @@ -170,6 +172,7 @@ export class SpaceRepo { .selectFrom('spaces') .selectAll('spaces') .select((eb) => [this.withMemberCount(eb)]) + .select((eb) => this.withIsPublished(eb)) .where('workspaceId', '=', workspaceId); if (pagination.query) { @@ -221,6 +224,18 @@ export class SpaceRepo { .as('memberCount'); } + withIsPublished(eb: ExpressionBuilder) { + return eb + .exists( + eb + .selectFrom('publicSpaces') + .select('publicSpaces.id') + .whereRef('publicSpaces.spaceId', '=', 'spaces.id') + .where('publicSpaces.enabled', '=', true), + ) + .as('isPublished'); + } + async deleteSpace(spaceId: string, workspaceId: string): Promise { await this.db .deleteFrom('spaces') diff --git a/apps/server/src/database/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts index a8323d7ce..98608be5c 100644 --- a/apps/server/src/database/repos/workspace/workspace.repo.ts +++ b/apps/server/src/database/repos/workspace/workspace.repo.ts @@ -249,6 +249,26 @@ export class WorkspaceRepo { .executeTakeFirst(); } + async updatePublicSpacesSettings( + workspaceId: string, + prefKey: string, + prefValue: string | boolean, + trx?: KyselyTransaction, + ) { + const db = dbOrTx(this.db, trx); + return db + .updateTable('workspaces') + .set({ + settings: sql`COALESCE(settings, '{}'::jsonb) + || jsonb_build_object('publicSpaces', COALESCE(settings->'publicSpaces', '{}'::jsonb) + || jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`, + updatedAt: new Date(), + }) + .where('id', '=', workspaceId) + .returning(this.baseFields) + .executeTakeFirst(); + } + async updateTemplateSettings( workspaceId: string, prefKey: string, diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 25060c71a..1fac41b84 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -335,6 +335,18 @@ export interface Pages { ydoc: Buffer | null; } +export interface PublicSpaces { + createdAt: Generated; + creatorId: string | null; + enabled: Generated; + id: Generated; + searchIndexing: Generated; + settings: Json | null; + spaceId: string; + updatedAt: Generated; + workspaceId: string; +} + export interface Shares { createdAt: Generated; creatorId: string | null; @@ -722,6 +734,7 @@ export interface DB { pageVerifications: PageVerifications; pageVerifiers: PageVerifiers; pages: Pages; + publicSpaces: PublicSpaces; scimTokens: ScimTokens; shares: Shares; spaceMembers: SpaceMembers; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index c7d2af0a0..18adf9123 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -31,6 +31,7 @@ import { AuthProviders, AuthAccounts, Shares, + PublicSpaces, Favorites, FileTasks, UserMfa as _UserMFA, @@ -151,6 +152,11 @@ export type Share = Selectable; export type InsertableShare = Insertable; export type UpdatableShare = Updateable>; +// PublicSpace +export type PublicSpace = Selectable; +export type InsertablePublicSpace = Insertable; +export type UpdatablePublicSpace = Updateable>; + // Favorite export type Favorite = Selectable; export type InsertableFavorite = Insertable; diff --git a/apps/server/src/ee b/apps/server/src/ee index cbf35d363..de453dee4 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit cbf35d363a1745bc09e7e8c72ca1fd63cf1b7b5c +Subproject commit de453dee4ab0fa11c8474260820fc5b6cea464c3 diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index c483df626..749abded3 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -248,6 +248,13 @@ export class EnvironmentService { return disable === 'true'; } + isBetaPublicSpaces(): boolean { + const enabled = this.configService + .get('BETA_PUBLIC_SPACES', 'false') + .toLowerCase(); + return enabled === 'true'; + } + getPostHogHost(): string { return this.configService.get('POSTHOG_HOST'); } diff --git a/apps/server/src/integrations/static/static.module.ts b/apps/server/src/integrations/static/static.module.ts index 5914a3e07..16e3f4cf2 100644 --- a/apps/server/src/integrations/static/static.module.ts +++ b/apps/server/src/integrations/static/static.module.ts @@ -47,6 +47,7 @@ export class StaticModule implements OnModuleInit { BILLING_TRIAL_DAYS: this.environmentService.isCloud() ? this.environmentService.getBillingTrialDays() : undefined, + BETA_PUBLIC_SPACES: this.environmentService.isBetaPublicSpaces(), POSTHOG_HOST: this.environmentService.getPostHogHost(), POSTHOG_KEY: this.environmentService.getPostHogKey(), AI_VECTOR_DRIVER: diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index b6db64627..b51d02941 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -49,6 +49,9 @@ async function bootstrap() { '.well-known/oauth-authorization-server', '.well-known/oauth-protected-resource', '.well-known/oauth-protected-resource/mcp', + 'docs', + 'docs/:spaceSlug', + 'docs/:spaceSlug/:pageSlug', ], }); @@ -70,7 +73,8 @@ async function bootstrap() { // Skipped routes: // /api/files/ - attachment controller sets its own CSP we'd overwrite // /share/ - public share pages are safe to embed - const frameHeaderSkippedPrefixes = ['/api/files/', '/share/']; + // /docs/ - public space pages are safe to embed + const frameHeaderSkippedPrefixes = ['/api/files/', '/share/', '/docs/']; app .getHttpAdapter() .getInstance() diff --git a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts index 0377ab7f7..d51a54be3 100644 --- a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts @@ -61,6 +61,11 @@ export function markdownToHtml( .trimStart(); resetFootnotes(); - const html = marked.parse(markdown).toString(); + // marked always closes fenced code with a newline that the editor keeps as + // an empty trailing line inside the code block. + const html = marked + .parse(markdown) + .toString() + .replace(/\n<\/code><\/pre>/g, ""); return html + renderFootnotesList(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 109214fe8..ca7e2d3d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -268,6 +268,9 @@ importers: '@excalidraw/excalidraw': specifier: 0.18.0-3a5ef40 version: 0.18.0-3a5ef40(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@fontsource-variable/inter': + specifier: 5.3.0 + version: 5.3.0 '@mantine/core': specifier: 9.3.2 version: 9.3.2(@mantine/hooks@9.3.2(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -2225,6 +2228,9 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@fontsource-variable/inter@5.3.0': + resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==} + '@hocuspocus/common@4.5.0': resolution: {integrity: sha512-hz6IBLLNKOWrWf+8r236CFFhpkcjEVXtZH1XRrkY0b5dYoQgR5gmuV5/5dJm1Vvyc8I70D+SHCvOch2AsRXonA==} @@ -12026,6 +12032,8 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@fontsource-variable/inter@5.3.0': {} + '@hocuspocus/common@4.5.0': dependencies: lib0: 0.2.117