diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx
index f2aef597..06dc4230 100644
--- a/apps/client/src/App.tsx
+++ b/apps/client/src/App.tsx
@@ -52,7 +52,7 @@ export default function App() {
>
)}
- } />
+ } />
} />
diff --git a/apps/client/src/features/editor/components/mention/mention-view.tsx b/apps/client/src/features/editor/components/mention/mention-view.tsx
index fa23237f..706742ae 100644
--- a/apps/client/src/features/editor/components/mention/mention-view.tsx
+++ b/apps/client/src/features/editor/components/mention/mention-view.tsx
@@ -3,19 +3,29 @@ import { ActionIcon, Anchor, Text } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { Link, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
-import { buildPageUrl } from "@/features/page/page.utils.ts";
+import {
+ buildPageUrl,
+ buildSharedPageUrl,
+} from "@/features/page/page.utils.ts";
import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) {
const { node } = props;
const { label, entityType, entityId, slugId } = node.attrs;
const { spaceSlug } = useParams();
+ const { shareId } = useParams();
const {
data: page,
isLoading,
isError,
} = usePageQuery({ pageId: entityType === "page" ? slugId : null });
+ const shareSlugUrl = buildSharedPageUrl({
+ shareId,
+ pageSlugId: slugId,
+ pageTitle: label,
+ });
+
return (
{entityType === "user" && (
@@ -28,7 +38,7 @@ export default function MentionView(props: NodeViewProps) {
diff --git a/apps/client/src/features/page/page.utils.ts b/apps/client/src/features/page/page.utils.ts
index 7ff823d5..e47ec100 100644
--- a/apps/client/src/features/page/page.utils.ts
+++ b/apps/client/src/features/page/page.utils.ts
@@ -1,6 +1,9 @@
import slugify from "@sindresorhus/slugify";
-export const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
+export const buildPageSlug = (
+ pageSlugId: string,
+ pageTitle?: string,
+): string => {
const titleSlug = slugify(pageTitle?.substring(0, 70) || "untitled", {
customReplacements: [
["♥", ""],
@@ -21,3 +24,12 @@ export const buildPageUrl = (
}
return `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
};
+
+export const buildSharedPageUrl = (opts: {
+ shareId: string;
+ pageSlugId: string;
+ pageTitle?: string;
+}): string => {
+ const { shareId, pageSlugId, pageTitle } = opts;
+ return `/share/${shareId}/${buildPageSlug(pageSlugId, pageTitle)}`;
+};
diff --git a/apps/client/src/features/share/queries/share-query.ts b/apps/client/src/features/share/queries/share-query.ts
index e49025c2..f22100ff 100644
--- a/apps/client/src/features/share/queries/share-query.ts
+++ b/apps/client/src/features/share/queries/share-query.ts
@@ -8,22 +8,22 @@ import { validate as isValidUuid } from "uuid";
import { useTranslation } from "react-i18next";
import {
ICreateShare,
- IShareInput,
+ IShareInfoInput,
} from "@/features/share/types/share.types.ts";
import {
createShare,
deleteShare,
- getShare,
+ getShareInfo,
updateShare,
} from "@/features/share/services/share-service.ts";
import { IPage } from "@/features/page/types/page.types.ts";
export function useShareQuery(
- shareInput: Partial,
+ shareInput: Partial,
): UseQueryResult {
const query = useQuery({
queryKey: ["shares", shareInput],
- queryFn: () => getShare(shareInput),
+ queryFn: () => getShareInfo(shareInput),
enabled: !!shareInput.shareId,
staleTime: 5 * 60 * 1000,
});
@@ -43,7 +43,7 @@ export function useCreateShareMutation() {
}
export function useUpdateShareMutation() {
- return useMutation>({
+ return useMutation>({
mutationFn: (data) => updateShare(data),
});
}
diff --git a/apps/client/src/features/share/services/share-service.ts b/apps/client/src/features/share/services/share-service.ts
index d44f3564..e0c03819 100644
--- a/apps/client/src/features/share/services/share-service.ts
+++ b/apps/client/src/features/share/services/share-service.ts
@@ -1,17 +1,9 @@
import api from "@/lib/api-client";
-import {
- IExportPageParams,
- IMovePage,
- IMovePageToSpace,
- IPage,
- IPageInput,
- SidebarPagesParams,
-} from "@/features/page/types/page.types";
-import { IAttachment, IPagination } from "@/lib/types.ts";
-import { saveAs } from "file-saver";
+import { IPage } from "@/features/page/types/page.types";
+
import {
ICreateShare,
- IShareInput,
+ IShareInfoInput,
} from "@/features/share/types/share.types.ts";
export async function createShare(data: ICreateShare): Promise {
@@ -19,14 +11,16 @@ export async function createShare(data: ICreateShare): Promise {
return req.data;
}
-export async function getShare(
- shareInput: Partial,
+export async function getShareInfo(
+ shareInput: Partial,
): Promise {
const req = await api.post("/shares/info", shareInput);
return req.data;
}
-export async function updateShare(data: Partial): Promise {
+export async function updateShare(
+ data: Partial,
+): Promise {
const req = await api.post("/shares/update", data);
return req.data;
}
diff --git a/apps/client/src/features/share/types/share.types.ts b/apps/client/src/features/share/types/share.types.ts
index 100a5756..e3b45d1c 100644
--- a/apps/client/src/features/share/types/share.types.ts
+++ b/apps/client/src/features/share/types/share.types.ts
@@ -1,12 +1,10 @@
-
export interface ICreateShare {
- slugId: string;
+ slugId: string; // share slugId
+ pageId: string;
+ includeSubPages?: boolean;
+}
+
+export interface IShareInfoInput {
+ shareId: string;
pageId: string;
}
-
-
-export interface IShareInput {
- shareId: string;
- pageId?: string;
-}
-
diff --git a/apps/client/src/pages/share/shared-page.tsx b/apps/client/src/pages/share/shared-page.tsx
index 47e59cb8..3c7f7d5f 100644
--- a/apps/client/src/pages/share/shared-page.tsx
+++ b/apps/client/src/pages/share/shared-page.tsx
@@ -1,29 +1,23 @@
-import { useNavigate, useParams } from "react-router-dom";
+import { useParams } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useShareQuery } from "@/features/share/queries/share-query.ts";
import { Container } from "@mantine/core";
-import React, { useEffect } from "react";
+import React from "react";
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
-import { buildPageSlug } from "@/features/page/page.utils.ts";
+import { extractPageSlugId } from "@/lib";
export default function SharedPage() {
const { t } = useTranslation();
const { shareId } = useParams();
+ const { pageSlug } = useParams();
+
const {
data: page,
isLoading,
isError,
error,
- } = useShareQuery({ shareId: shareId });
- const navigate = useNavigate();
-
- useEffect(() => {
- if (!page) return;
- const pageSlug = buildPageSlug(page.slugId, page.title);
- const shareSlug = `/share/${shareId}/${pageSlug}`;
- navigate(shareSlug, { replace: true });
- }, [page]);
+ } = useShareQuery({ shareId: shareId, pageId: extractPageSlugId(pageSlug) });
if (isLoading) {
return <>>;
diff --git a/apps/server/src/core/share/dto/create-share.dto.ts b/apps/server/src/core/share/dto/create-share.dto.ts
index 9d200c45..fcc5a848 100644
--- a/apps/server/src/core/share/dto/create-share.dto.ts
+++ b/apps/server/src/core/share/dto/create-share.dto.ts
@@ -1,6 +1,9 @@
-import { IsString } from 'class-validator';
+import { IsBoolean, IsString } from 'class-validator';
export class CreateShareDto {
@IsString()
pageId: string;
+
+ @IsBoolean()
+ includeSubPages: boolean;
}
diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts
index fb35cad3..0f6d3ef2 100644
--- a/apps/server/src/core/share/share.service.ts
+++ b/apps/server/src/core/share/share.service.ts
@@ -12,12 +12,14 @@ import { TokenService } from '../auth/services/token.service';
import { jsonToNode } from '../../collaboration/collaboration.util';
import {
getAttachmentIds,
+ getProsemirrorContent,
isAttachmentNode,
} from '../../common/helpers/prosemirror/utils';
import { Node } from '@tiptap/pm/model';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { updateAttachmentAttr } from './share.util';
import { Page } from '@docmost/db/types/entity.types';
+import { validate as isValidUUID } from 'uuid';
@Injectable()
export class ShareService {
@@ -59,7 +61,21 @@ export class ShareService {
throw new NotFoundException('Share not found');
}
- const page = await this.pageRepo.findById(share.pageId, {
+ let targetPageId = share.pageId;
+ if (dto.pageId && dto.pageId !== share.pageId) {
+ // Check if dto.pageId is a descendant of the shared page.
+ const isDescendant = await this.getShareAncestorPage(
+ share.pageId,
+ dto.pageId,
+ );
+ if (isDescendant) {
+ targetPageId = dto.pageId;
+ } else {
+ throw new NotFoundException(`Shared page not found`);
+ }
+ }
+
+ const page = await this.pageRepo.findById(targetPageId, {
includeContent: true,
includeCreator: true,
});
@@ -73,8 +89,74 @@ export class ShareService {
return page;
}
+ async getShareAncestorPage(
+ ancestorPageId: string,
+ childPageId: string,
+ ): Promise {
+ let ancestor = null;
+ try {
+ ancestor = await this.db
+ .withRecursive('page_ancestors', (db) =>
+ db
+ .selectFrom('pages')
+ .select([
+ 'id',
+ 'slugId',
+ 'title',
+ 'parentPageId',
+ 'spaceId',
+ (eb) =>
+ eb
+ .case()
+ .when(eb.ref('id'), '=', ancestorPageId)
+ .then(true)
+ .else(false)
+ .end()
+ .as('found'),
+ ])
+ .where(
+ !isValidUUID(childPageId) ? 'slugId' : 'id',
+ '=',
+ childPageId,
+ )
+ .unionAll((exp) =>
+ exp
+ .selectFrom('pages as p')
+ .select([
+ 'p.id',
+ 'p.slugId',
+ 'p.title',
+ 'p.parentPageId',
+ 'p.spaceId',
+ (eb) =>
+ eb
+ .case()
+ .when(eb.ref('p.id'), '=', ancestorPageId)
+ .then(true)
+ .else(false)
+ .end()
+ .as('found'),
+ ])
+ .innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
+ // Continue recursing only when the target ancestor hasn't been found on that branch.
+ .where('pa.found', '=', false),
+ ),
+ )
+ .selectFrom('page_ancestors')
+ .selectAll()
+ .where('found', '=', true)
+ .limit(1)
+ .executeTakeFirst();
+ } catch (err) {
+ // empty
+ }
+
+ return ancestor;
+ }
+
async updatePublicAttachments(page: Page): Promise {
- const attachmentIds = getAttachmentIds(page.content);
+ const prosemirrorJson = getProsemirrorContent(page.content);
+ const attachmentIds = getAttachmentIds(prosemirrorJson);
const attachmentMap = new Map();
await Promise.all(
@@ -88,7 +170,7 @@ export class ShareService {
}),
);
- const doc = jsonToNode(page.content as any);
+ const doc = jsonToNode(prosemirrorJson);
doc?.descendants((node: Node) => {
if (!isAttachmentNode(node.type.name)) return;