mirror of
https://github.com/docmost/docmost.git
synced 2025-11-17 11:21:10 +10:00
WIP
This commit is contained in:
@ -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 />} />
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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)}`;
|
||||||
|
};
|
||||||
|
|||||||
@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@ -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 <></>;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user