This commit is contained in:
Philipinho
2025-04-10 23:47:26 +01:00
parent 4f52d32bef
commit 16a253ec40
9 changed files with 141 additions and 48 deletions

View File

@ -52,7 +52,7 @@ export default function App() {
</> </>
)} )}
<Route path={"/share/:shareId/:pageId"} element={<SharedPage />} /> <Route path={"/share/:shareId/:pageSlug"} element={<SharedPage />} />
<Route path={"/p/:pageSlug"} element={<PageRedirect />} /> <Route path={"/p/:pageSlug"} element={<PageRedirect />} />

View File

@ -3,19 +3,29 @@ import { ActionIcon, Anchor, Text } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react"; import { IconFileDescription } from "@tabler/icons-react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; 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"; import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) { export default function MentionView(props: NodeViewProps) {
const { node } = props; const { node } = props;
const { label, entityType, entityId, slugId } = node.attrs; const { label, entityType, entityId, slugId } = node.attrs;
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { shareId } = useParams();
const { const {
data: page, data: page,
isLoading, isLoading,
isError, isError,
} = usePageQuery({ pageId: entityType === "page" ? slugId : null }); } = usePageQuery({ pageId: entityType === "page" ? slugId : null });
const shareSlugUrl = buildSharedPageUrl({
shareId,
pageSlugId: slugId,
pageTitle: label,
});
return ( return (
<NodeViewWrapper style={{ display: "inline" }}> <NodeViewWrapper style={{ display: "inline" }}>
{entityType === "user" && ( {entityType === "user" && (
@ -28,7 +38,7 @@ export default function MentionView(props: NodeViewProps) {
<Anchor <Anchor
component={Link} component={Link}
fw={500} fw={500}
to={buildPageUrl(spaceSlug, slugId, label)} to={shareId ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label)}
underline="never" underline="never"
className={classes.pageMentionLink} className={classes.pageMentionLink}
> >

View File

@ -1,6 +1,9 @@
import slugify from "@sindresorhus/slugify"; 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", { const titleSlug = slugify(pageTitle?.substring(0, 70) || "untitled", {
customReplacements: [ customReplacements: [
["♥", ""], ["♥", ""],
@ -21,3 +24,12 @@ export const buildPageUrl = (
} }
return `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`; 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)}`;
};

View File

@ -8,22 +8,22 @@ import { validate as isValidUuid } from "uuid";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ICreateShare, ICreateShare,
IShareInput, IShareInfoInput,
} from "@/features/share/types/share.types.ts"; } from "@/features/share/types/share.types.ts";
import { import {
createShare, createShare,
deleteShare, deleteShare,
getShare, getShareInfo,
updateShare, updateShare,
} from "@/features/share/services/share-service.ts"; } from "@/features/share/services/share-service.ts";
import { IPage } from "@/features/page/types/page.types.ts"; import { IPage } from "@/features/page/types/page.types.ts";
export function useShareQuery( export function useShareQuery(
shareInput: Partial<IShareInput>, shareInput: Partial<IShareInfoInput>,
): UseQueryResult<IPage, Error> { ): UseQueryResult<IPage, Error> {
const query = useQuery({ const query = useQuery({
queryKey: ["shares", shareInput], queryKey: ["shares", shareInput],
queryFn: () => getShare(shareInput), queryFn: () => getShareInfo(shareInput),
enabled: !!shareInput.shareId, enabled: !!shareInput.shareId,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
@ -43,7 +43,7 @@ export function useCreateShareMutation() {
} }
export function useUpdateShareMutation() { export function useUpdateShareMutation() {
return useMutation<any, Error, Partial<IShareInput>>({ return useMutation<any, Error, Partial<IShareInfoInput>>({
mutationFn: (data) => updateShare(data), mutationFn: (data) => updateShare(data),
}); });
} }

View File

@ -1,17 +1,9 @@
import api from "@/lib/api-client"; import api from "@/lib/api-client";
import { import { IPage } from "@/features/page/types/page.types";
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 { import {
ICreateShare, ICreateShare,
IShareInput, IShareInfoInput,
} from "@/features/share/types/share.types.ts"; } from "@/features/share/types/share.types.ts";
export async function createShare(data: ICreateShare): Promise<any> { export async function createShare(data: ICreateShare): Promise<any> {
@ -19,14 +11,16 @@ export async function createShare(data: ICreateShare): Promise<any> {
return req.data; return req.data;
} }
export async function getShare( export async function getShareInfo(
shareInput: Partial<IShareInput>, shareInput: Partial<IShareInfoInput>,
): Promise<IPage> { ): Promise<IPage> {
const req = await api.post<IPage>("/shares/info", shareInput); const req = await api.post<IPage>("/shares/info", shareInput);
return req.data; return req.data;
} }
export async function updateShare(data: Partial<IShareInput>): Promise<any> { export async function updateShare(
data: Partial<IShareInfoInput>,
): Promise<any> {
const req = await api.post<IPage>("/shares/update", data); const req = await api.post<IPage>("/shares/update", data);
return req.data; return req.data;
} }

View File

@ -1,12 +1,10 @@
export interface ICreateShare { export interface ICreateShare {
slugId: string; slugId: string; // share slugId
pageId: string;
includeSubPages?: boolean;
}
export interface IShareInfoInput {
shareId: string;
pageId: string; pageId: string;
} }
export interface IShareInput {
shareId: string;
pageId?: string;
}

View File

@ -1,29 +1,23 @@
import { useNavigate, useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useShareQuery } from "@/features/share/queries/share-query.ts"; import { useShareQuery } from "@/features/share/queries/share-query.ts";
import { Container } from "@mantine/core"; import { Container } from "@mantine/core";
import React, { useEffect } from "react"; import React from "react";
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx"; 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() { export default function SharedPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { shareId } = useParams(); const { shareId } = useParams();
const { pageSlug } = useParams();
const { const {
data: page, data: page,
isLoading, isLoading,
isError, isError,
error, error,
} = useShareQuery({ shareId: shareId }); } = useShareQuery({ shareId: shareId, pageId: extractPageSlugId(pageSlug) });
const navigate = useNavigate();
useEffect(() => {
if (!page) return;
const pageSlug = buildPageSlug(page.slugId, page.title);
const shareSlug = `/share/${shareId}/${pageSlug}`;
navigate(shareSlug, { replace: true });
}, [page]);
if (isLoading) { if (isLoading) {
return <></>; return <></>;

View File

@ -1,6 +1,9 @@
import { IsString } from 'class-validator'; import { IsBoolean, IsString } from 'class-validator';
export class CreateShareDto { export class CreateShareDto {
@IsString() @IsString()
pageId: string; pageId: string;
@IsBoolean()
includeSubPages: boolean;
} }

View File

@ -12,12 +12,14 @@ import { TokenService } from '../auth/services/token.service';
import { jsonToNode } from '../../collaboration/collaboration.util'; import { jsonToNode } from '../../collaboration/collaboration.util';
import { import {
getAttachmentIds, getAttachmentIds,
getProsemirrorContent,
isAttachmentNode, isAttachmentNode,
} from '../../common/helpers/prosemirror/utils'; } from '../../common/helpers/prosemirror/utils';
import { Node } from '@tiptap/pm/model'; import { Node } from '@tiptap/pm/model';
import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { updateAttachmentAttr } from './share.util'; import { updateAttachmentAttr } from './share.util';
import { Page } from '@docmost/db/types/entity.types'; import { Page } from '@docmost/db/types/entity.types';
import { validate as isValidUUID } from 'uuid';
@Injectable() @Injectable()
export class ShareService { export class ShareService {
@ -59,7 +61,21 @@ export class ShareService {
throw new NotFoundException('Share not found'); 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, includeContent: true,
includeCreator: true, includeCreator: true,
}); });
@ -73,8 +89,74 @@ export class ShareService {
return page; return page;
} }
async getShareAncestorPage(
ancestorPageId: string,
childPageId: string,
): Promise<any> {
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<any> { async updatePublicAttachments(page: Page): Promise<any> {
const attachmentIds = getAttachmentIds(page.content); const prosemirrorJson = getProsemirrorContent(page.content);
const attachmentIds = getAttachmentIds(prosemirrorJson);
const attachmentMap = new Map<string, string>(); const attachmentMap = new Map<string, string>();
await Promise.all( 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) => { doc?.descendants((node: Node) => {
if (!isAttachmentNode(node.type.name)) return; if (!isAttachmentNode(node.type.name)) return;