Share - WIP

This commit is contained in:
Philipinho
2025-04-09 13:26:50 +01:00
parent a9f370660b
commit 18e8c4cbaf
19 changed files with 514 additions and 6 deletions

View File

@ -26,6 +26,7 @@ import { useTranslation } from "react-i18next";
import Security from "@/ee/security/pages/security.tsx";
import License from "@/ee/licence/pages/license.tsx";
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
import SharedPage from '@/pages/share/shared-page.tsx';
export default function App() {
const { t } = useTranslation();
@ -51,6 +52,8 @@ export default function App() {
</>
)}
<Route path={"/share/:shareId/:pageId"} element={<SharedPage />} />
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
<Route element={<Layout />}>

View File

@ -139,7 +139,7 @@ export default function DrawioView(props: NodeViewProps) {
)}
/>
{selected && (
{selected && editor.isEditable && (
<ActionIcon
onClick={handleOpen}
variant="default"

View File

@ -170,7 +170,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
)}
/>
{selected && (
{selected && editor.isEditable && (
<ActionIcon
onClick={handleOpen}
variant="default"

View File

@ -0,0 +1,52 @@
import "@/features/editor/styles/index.css";
import React, { useMemo } from "react";
import { EditorProvider } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Document } from "@tiptap/extension-document";
import { Heading } from "@tiptap/extension-heading";
import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder";
interface PageEditorProps {
title: string;
content: any;
}
export default function ReadonlyPageEditor({
title,
content,
}: PageEditorProps) {
const extensions = useMemo(() => {
return [...mainExtensions];
}, []);
const titleExtensions = [
Document.extend({
content: "heading",
}),
Heading,
Text,
Placeholder.configure({
placeholder: "Untitled",
showOnlyWhenEditable: false,
}),
];
return (
<>
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={titleExtensions}
content={title}
></EditorProvider>
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={extensions}
content={content}
></EditorProvider>
</>
);
}

View File

@ -1,6 +1,6 @@
import slugify from "@sindresorhus/slugify";
const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
export const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
const titleSlug = slugify(pageTitle?.substring(0, 70) || "untitled", {
customReplacements: [
["♥", ""],
@ -8,7 +8,7 @@ const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
],
});
return `p/${titleSlug}-${pageSlugId}`;
return `${titleSlug}-${pageSlugId}`;
};
export const buildPageUrl = (
@ -17,7 +17,7 @@ export const buildPageUrl = (
pageTitle?: string,
): string => {
if (spaceName === undefined) {
return `/${buildPageSlug(pageSlugId, pageTitle)}`;
return `/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}
return `/s/${spaceName}/${buildPageSlug(pageSlugId, pageTitle)}`;
return `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
};

View File

@ -0,0 +1,65 @@
import {
useMutation,
useQuery,
UseQueryResult,
} from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { validate as isValidUuid } from "uuid";
import { useTranslation } from "react-i18next";
import {
ICreateShare,
IShareInput,
} from "@/features/share/types/share.types.ts";
import {
createShare,
deleteShare,
getShare,
updateShare,
} from "@/features/share/services/share-service.ts";
import { IPage } from "@/features/page/types/page.types.ts";
export function useShareQuery(
shareInput: Partial<IShareInput>,
): UseQueryResult<IPage, Error> {
const query = useQuery({
queryKey: ["shares", shareInput],
queryFn: () => getShare(shareInput),
enabled: !!shareInput.shareId,
staleTime: 5 * 60 * 1000,
});
return query;
}
export function useCreateShareMutation() {
const { t } = useTranslation();
return useMutation<any, Error, ICreateShare>({
mutationFn: (data) => createShare(data),
onSuccess: (data) => {},
onError: (error) => {
notifications.show({ message: t("Failed to share page"), color: "red" });
},
});
}
export function useUpdateShareMutation() {
return useMutation<any, Error, Partial<IShareInput>>({
mutationFn: (data) => updateShare(data),
});
}
export function useDeleteShareMutation() {
const { t } = useTranslation();
return useMutation({
mutationFn: (shareId: string) => deleteShare(shareId),
onSuccess: () => {
notifications.show({ message: t("Share deleted successfully") });
},
onError: (error) => {
notifications.show({
message: t("Failed to delete share"),
color: "red",
});
},
});
}

View File

@ -0,0 +1,36 @@
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 {
ICreateShare,
IShareInput,
} from "@/features/share/types/share.types.ts";
export async function createShare(data: ICreateShare): Promise<any> {
const req = await api.post<any>("/shares/create", data);
return req.data;
}
export async function getShare(
shareInput: Partial<IShareInput>,
): Promise<IPage> {
const req = await api.post<IPage>("/shares/info", shareInput);
return req.data;
}
export async function updateShare(data: Partial<IShareInput>): Promise<any> {
const req = await api.post<IPage>("/shares/update", data);
return req.data;
}
export async function deleteShare(shareId: string): Promise<void> {
await api.post("/shares/delete", { shareId });
}

View File

@ -0,0 +1,12 @@
export interface ICreateShare {
slugId: string;
pageId: string;
}
export interface IShareInput {
shareId: string;
pageId?: string;
}

View File

@ -0,0 +1,56 @@
import { useNavigate, 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 ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
import { buildPageSlug } from "@/features/page/page.utils.ts";
export default function SharedPage() {
const { t } = useTranslation();
const { shareId } = 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]);
if (isLoading) {
return <></>;
}
if (isError || !page) {
if ([401, 403, 404].includes(error?.["status"])) {
return <div>{t("Page not found")}</div>;
}
return <div>{t("Error fetching page data.")}</div>;
}
return (
page && (
<div>
<Helmet>
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
</Helmet>
<Container size={900} pt={50}>
<ReadonlyPageEditor
key={page.id}
title={page.title}
content={page.content}
/>
</Container>
</div>
)
);
}

View File

@ -15,6 +15,7 @@ import { SpaceModule } from './space/space.module';
import { GroupModule } from './group/group.module';
import { CaslModule } from './casl/casl.module';
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
import { ShareModule } from './share/share.module';
@Module({
imports: [
@ -28,6 +29,7 @@ import { DomainMiddleware } from '../common/middlewares/domain.middleware';
SpaceModule,
GroupModule,
CaslModule,
ShareModule,
],
})
export class CoreModule implements NestModule {

View File

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

View File

@ -0,0 +1,28 @@
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
export class ShareIdDto {
@IsString()
@IsNotEmpty()
shareId: string;
}
export class SpaceIdDto {
@IsUUID()
spaceId: string;
}
export class ShareInfoDto extends ShareIdDto {
@IsString()
@IsOptional()
pageId: string;
// @IsOptional()
// @IsBoolean()
// includeContent: boolean;
}

View File

@ -0,0 +1,8 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateShareDto } from './create-share.dto';
import { IsString } from 'class-validator';
export class UpdateShareDto extends PartialType(CreateShareDto) {
//@IsString()
//pageId: string;
}

View File

@ -0,0 +1,105 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import { ShareService } from './share.service';
import { UpdateShareDto } from './dto/update-page.dto';
import { CreateShareDto } from './dto/create-share.dto';
import { ShareIdDto, ShareInfoDto } from './dto/share.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator';
@UseGuards(JwtAuthGuard)
@Controller('shares')
export class ShareController {
constructor(
private readonly shareService: ShareService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageRepo: PageRepo,
) {}
@Public()
@HttpCode(HttpStatus.OK)
@Post('/info')
async getPage(@Body() dto: ShareInfoDto) {
return this.shareService.getShare(dto);
}
@HttpCode(HttpStatus.OK)
@Post('create')
async create(
@Body() createShareDto: CreateShareDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(createShareDto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.shareService.createShare({
pageId: page.id,
authUserId: user.id,
workspaceId: workspace.id,
});
}
@HttpCode(HttpStatus.OK)
@Post('update')
async update(@Body() updatePageDto: UpdateShareDto, @AuthUser() user: User) {
/* const page = await this.pageRepo.findById(updatePageDto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
//return this.shareService.update(page, updatePageDto, user.id);
*/
}
@HttpCode(HttpStatus.OK)
@Post('delete')
async delete(@Body() shareIdDto: ShareIdDto, @AuthUser() user: User) {
/* const page = await this.pageRepo.findById(pageIdDto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
*/
// await this.shareService.forceDelete(pageIdDto.pageId);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ShareController } from './share.controller';
import { ShareService } from './share.service';
@Module({
controllers: [ShareController],
providers: [ShareService],
exports: [ShareService],
})
export class ShareModule {}

View File

@ -0,0 +1,65 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ShareInfoDto } from './dto/share.dto';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { generateSlugId } from '../../common/helpers';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
@Injectable()
export class ShareService {
constructor(
private readonly pageRepo: PageRepo,
@InjectKysely() private readonly db: KyselyDB,
) {}
async createShare(opts: {
authUserId: string;
workspaceId: string;
pageId: string;
}) {
const { authUserId, workspaceId, pageId } = opts;
const slugId = generateSlugId(); // or custom slug
const share = this.db
.insertInto('shares')
.values({ slugId: slugId, pageId, creatorId: authUserId, workspaceId })
.returningAll()
.executeTakeFirst();
return share;
}
async getShare(dto: ShareInfoDto) {
// for now only single page share
// if only share Id is provided, return
// if share id is pass with page id, what to do?
// if uuid is used, use Id
const share = await this.db
.selectFrom('shares')
.selectAll()
.where('slugId', '=', dto.shareId)
.executeTakeFirst();
if (!share) {
throw new NotFoundException('Share not found');
}
const page = await this.pageRepo.findById(share.pageId, {
includeContent: true,
includeCreator: true,
});
// cleanup json content
// remove comments mark
// make sure attachments work (videos, images, excalidraw, drawio)
// figure out internal links?
if (!page) {
throw new NotFoundException('Page not found');
}
return page;
}
}

View File

@ -0,0 +1,41 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('shares')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('slug_id', 'varchar', (col) => col.notNull())
.addColumn('page_id', 'varchar', (col) => col.notNull())
.addColumn('include_sub_pages', 'varchar', (col) => col)
.addColumn('creator_id', 'uuid', (col) => col.references('users.id'))
// pageSlug
//.addColumn('space_id', 'uuid', (col) =>
// col.references('spaces.id').onDelete('cascade').notNull(),
// )
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('deleted_at', 'timestamptz', (col) => col)
.addUniqueConstraint('shares_slug_id_unique', ['slug_id'])
.execute();
await db.schema
.createIndex('shares_slug_id_idx')
.on('shares')
.column('slug_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('shares').execute();
}

View File

@ -183,6 +183,18 @@ export interface Pages {
ydoc: Buffer | null;
}
export interface Shares {
createdAt: Generated<Timestamp>;
creatorId: string | null;
deletedAt: Timestamp | null;
id: Generated<string>;
includeSubPages: string | null;
pageId: string;
slugId: string;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
export interface SpaceMembers {
addedById: string | null;
createdAt: Generated<Timestamp>;
@ -288,6 +300,7 @@ export interface DB {
groupUsers: GroupUsers;
pageHistory: PageHistory;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
spaces: Spaces;
users: Users;

View File

@ -16,6 +16,7 @@ import {
Billing as BillingSubscription,
AuthProviders,
AuthAccounts,
Shares,
} from './db';
// Workspace
@ -101,3 +102,8 @@ export type UpdatableAuthProvider = Updateable<Omit<AuthProviders, 'id'>>;
export type AuthAccount = Selectable<AuthAccounts>;
export type InsertableAuthAccount = Insertable<AuthAccounts>;
export type UpdatableAuthAccount = Updateable<Omit<AuthAccounts, 'id'>>;
// Share
export type Share = Selectable<Shares>;
export type InsertableShare = Insertable<Shares>;
export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;