From 16a253ec40f3f831c92e02ec611e6b0406c7dbd2 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Thu, 10 Apr 2025 23:47:26 +0100 Subject: [PATCH] WIP --- apps/client/src/App.tsx | 2 +- .../components/mention/mention-view.tsx | 14 ++- apps/client/src/features/page/page.utils.ts | 14 ++- .../src/features/share/queries/share-query.ts | 10 +-- .../features/share/services/share-service.ts | 22 ++--- .../src/features/share/types/share.types.ts | 16 ++-- apps/client/src/pages/share/shared-page.tsx | 18 ++-- .../src/core/share/dto/create-share.dto.ts | 5 +- apps/server/src/core/share/share.service.ts | 88 ++++++++++++++++++- 9 files changed, 141 insertions(+), 48 deletions(-) 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;