diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 00000000..57c5257d --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_BACKEND_API_URL=http://localhost:3001 +NEXT_PUBLIC_COLLABORATION_URL= diff --git a/frontend/package.json b/frontend/package.json index dbe897e7..fb28adf9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "lint": "next lint" }, "dependencies": { + "@hocuspocus/provider": "^2.5.0", "@hookform/resolvers": "^3.3.1", "@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-icons": "^1.3.0", @@ -24,6 +25,14 @@ "@tabler/icons-react": "^2.32.0", "@tanstack/react-query": "^4.33.0", "@tanstack/react-table": "^8.9.3", + "@tiptap/extension-collaboration": "^2.1.8", + "@tiptap/extension-collaboration-cursor": "^2.1.8", + "@tiptap/extension-document": "^2.1.8", + "@tiptap/extension-heading": "^2.1.8", + "@tiptap/extension-placeholder": "^2.1.8", + "@tiptap/pm": "^2.1.8", + "@tiptap/react": "^2.1.8", + "@tiptap/starter-kit": "^2.1.8", "@types/node": "20.4.8", "@types/react": "18.2.18", "@types/react-dom": "18.2.7", @@ -48,6 +57,7 @@ "tailwindcss": "3.3.3", "tailwindcss-animate": "^1.0.6", "typescript": "5.1.6", + "yjs": "^13.6.7", "zod": "^3.22.2" }, "devDependencies": { diff --git a/frontend/src/app/(dashboard)/(page)/p/[pageId]/page.tsx b/frontend/src/app/(dashboard)/(page)/p/[pageId]/page.tsx new file mode 100644 index 00000000..d2cf8c10 --- /dev/null +++ b/frontend/src/app/(dashboard)/(page)/p/[pageId]/page.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import dynamic from 'next/dynamic'; + +const Editor = dynamic(() => import("@/features/editor/Editor"), { + ssr: false, +}); + +export default function Page() { + const { pageId } = useParams(); + + return ( +
+ +
+ ); +} diff --git a/frontend/src/app/(dashboard)/home/page.tsx b/frontend/src/app/(dashboard)/home/page.tsx index 7d45f299..ef79bfd5 100644 --- a/frontend/src/app/(dashboard)/home/page.tsx +++ b/frontend/src/app/(dashboard)/home/page.tsx @@ -7,10 +7,8 @@ export default function Home() { const [currentUser] = useAtom(currentUserAtom); return ( -
-
- Hello {currentUser && currentUser.user.name}! -
-
+ <> + Hello {currentUser && currentUser.user.name}! + ); } diff --git a/frontend/src/app/(dashboard)/layout.tsx b/frontend/src/app/(dashboard)/layout.tsx index dafe0ff5..d59615d2 100644 --- a/frontend/src/app/(dashboard)/layout.tsx +++ b/frontend/src/app/(dashboard)/layout.tsx @@ -14,7 +14,11 @@ export default function DashboardLayout({ children }: { return ( - {children} +
+
+ {children} +
+
); diff --git a/frontend/src/app/(dashboard)/settings/layout.tsx b/frontend/src/app/(dashboard)/settings/layout.tsx index f083d3f1..99e1c5cb 100644 --- a/frontend/src/app/(dashboard)/settings/layout.tsx +++ b/frontend/src/app/(dashboard)/settings/layout.tsx @@ -2,8 +2,8 @@ import { ReactNode } from 'react'; export default function SettingsLayout({ children }: { children: ReactNode }) { return ( -
-
{children}
-
+ <> + {children} + ); } diff --git a/frontend/src/components/sidebar/navigation/navigation-items.tsx b/frontend/src/components/sidebar/navigation/navigation-items.tsx index 0bc028f8..8e4d18af 100644 --- a/frontend/src/components/sidebar/navigation/navigation-items.tsx +++ b/frontend/src/components/sidebar/navigation/navigation-items.tsx @@ -45,6 +45,7 @@ export const renderMenuItem = (menu, index) => { if (menu.target) { return ( list[Math.floor(Math.random() * list.length)] +const getRandomColor = () => getRandomElement(colors) + +export default function Editor({ pageId }: EditorProps ) { + const [token] = useAtom(authTokensAtom); + const collaborationURL = useCollaborationUrl(); + + const [provider, setProvider] = useState(); + const [doc, setDoc] = useState() + + useLayoutEffect(() => { + if (token) { + const ydoc = new Y.Doc(); + + const provider = new HocuspocusProvider({ + url: collaborationURL, + name: pageId, + document: ydoc, + token: token.accessToken, + }); + + setDoc(ydoc); + setProvider(provider); + + return () => { + ydoc.destroy(); + provider.destroy(); + }; + } + }, [collaborationURL, pageId, token]); + + if(!doc || !provider){ + return null; + } + + return ( + + ); +} + +interface TiptapEditorProps { + ydoc: Y.Doc, + provider: HocuspocusProvider +} + +function TiptapEditor({ ydoc, provider }: TiptapEditorProps) { + const [currentUser] = useAtom(currentUserAtom); + + const extensions = [ + StarterKit.configure({ + history: false, + }), + Placeholder.configure({ + placeholder: 'Write here', + }), + Collaboration.configure({ + document: ydoc, + }), + CollaborationCursor.configure({ + provider + }), + ]; + + const editor = useEditor({ + extensions: extensions, + editorProps: { + attributes: { + class: + "min-h-[500px] flex-1 p-4", + }, + }, + }); + + useEffect(() => { + if (editor && currentUser.user){ + editor.chain().focus().updateUser({...currentUser.user, color: getRandomColor()}).run() + } + }, [editor, currentUser.user]) + + useEffect(() => { + provider.on('status', event => { + console.log(event) + }) + + }, [provider]) + + + return ( + <> + + + ); +} diff --git a/frontend/src/features/editor/css/editor.css b/frontend/src/features/editor/css/editor.css new file mode 100644 index 00000000..759cff9a --- /dev/null +++ b/frontend/src/features/editor/css/editor.css @@ -0,0 +1,26 @@ +/* Give a remote user a caret */ +.collaboration-cursor__caret { + border-left: 1px solid #0d0d0d; + border-right: 1px solid #0d0d0d; + margin-left: -1px; + margin-right: -1px; + pointer-events: none; + position: relative; + word-break: normal; +} + +/* Render the username above the caret */ +.collaboration-cursor__label { + border-radius: 3px 3px 3px 0; + color: #0d0d0d; + font-size: 12px; + font-style: normal; + font-weight: 600; + left: -1px; + line-height: normal; + padding: 0.1rem 0.3rem; + position: absolute; + top: -1.4em; + user-select: none; + white-space: nowrap; +} diff --git a/frontend/src/features/editor/hooks/use-collaboration-url.ts b/frontend/src/features/editor/hooks/use-collaboration-url.ts new file mode 100644 index 00000000..16f65549 --- /dev/null +++ b/frontend/src/features/editor/hooks/use-collaboration-url.ts @@ -0,0 +1,17 @@ +const useCollaborationURL = (): string => { + const PATH = "/collaboration"; + + if (process.env.NEXT_PUBLIC_COLLABORATION_URL) { + return process.env.NEXT_PUBLIC_COLLABORATION_URL + PATH; + } + + const API_URL = process.env.NEXT_PUBLIC_BACKEND_API_URL; + if (!API_URL) { + throw new Error("Backend API URL is not defined"); + } + + const wsProtocol = API_URL.startsWith('https') ? 'wss' : 'ws'; + return `${wsProtocol}://${API_URL.split('://')[1]}${PATH}`; +}; + +export default useCollaborationURL; diff --git a/server/package.json b/server/package.json index 8b2e089a..ec1f3e4a 100644 --- a/server/package.json +++ b/server/package.json @@ -4,7 +4,7 @@ "description": "", "author": "", "private": true, - "license": "UNLICENSED", + "license": "", "scripts": { "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", @@ -27,24 +27,27 @@ "migration:show": "npm run typeorm migration:show" }, "dependencies": { + "@hocuspocus/server": "^2.5.0", + "@hocuspocus/transformer": "^2.5.0", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.0.0", "@nestjs/jwt": "^10.1.0", "@nestjs/mapped-types": "^2.0.2", - "@nestjs/platform-express": "^10.0.0", - "@nestjs/platform-fastify": "^10.1.3", + "@nestjs/platform-fastify": "^10.2.4", + "@nestjs/platform-ws": "^10.2.4", "@nestjs/typeorm": "^10.0.0", - "@types/uuid": "^9.0.2", - "bcrypt": "^5.1.0", + "@nestjs/websockets": "^10.2.4", + "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", - "fastify": "^4.21.0", - "pg": "^8.11.2", + "fastify": "^4.22.2", + "pg": "^8.11.3", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "typeorm": "^0.3.17", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "ws": "^8.14.1" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -55,6 +58,8 @@ "@types/jest": "^29.5.2", "@types/node": "^20.3.1", "@types/supertest": "^2.0.12", + "@types/uuid": "^9.0.3", + "@types/ws": "^8.5.5", "@typescript-eslint/eslint-plugin": "^5.59.11", "@typescript-eslint/parser": "^5.59.11", "eslint": "^8.42.0", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index b3f0dfd2..6918ce2e 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -5,6 +5,7 @@ import { CoreModule } from './core/core.module'; import { EnvironmentModule } from './environment/environment.module'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AppDataSource } from './database/typeorm.config'; +import { CollaborationModule } from './collaboration/collaboration.module'; @Module({ imports: [ @@ -16,6 +17,7 @@ import { AppDataSource } from './database/typeorm.config'; migrations: ['dist/src/**/migrations/*.{ts,js}'], autoLoadEntities: true, }), + CollaborationModule, ], controllers: [AppController], providers: [AppService], diff --git a/server/src/collaboration/collaboration.gateway.ts b/server/src/collaboration/collaboration.gateway.ts new file mode 100644 index 00000000..c4719d3d --- /dev/null +++ b/server/src/collaboration/collaboration.gateway.ts @@ -0,0 +1,31 @@ +import { + OnGatewayConnection, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { Server as HocuspocusServer } from '@hocuspocus/server'; +import { IncomingMessage } from 'http'; +import WebSocket, { Server } from 'ws'; +import { AuthenticationExtension } from './extensions/authentication.extension'; +import { PersistenceExtension } from './extensions/persistence.extension'; + +@WebSocketGateway({ path: '/collaboration' }) +export class CollaborationGateway implements OnGatewayConnection { + @WebSocketServer() + server: Server; + + constructor( + private authenticationExtension: AuthenticationExtension, + private persistenceExtension: PersistenceExtension, + ) {} + + private hocuspocus = HocuspocusServer.configure({ + debounce: 5000, + maxDebounce: 10000, + extensions: [this.authenticationExtension, this.persistenceExtension], + }); + + handleConnection(client: WebSocket, request: IncomingMessage): any { + this.hocuspocus.handleConnection(client, request); + } +} diff --git a/server/src/collaboration/collaboration.module.ts b/server/src/collaboration/collaboration.module.ts new file mode 100644 index 00000000..a286a61b --- /dev/null +++ b/server/src/collaboration/collaboration.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { UserModule } from '../core/user/user.module'; +import { AuthModule } from '../core/auth/auth.module'; +import { CollaborationGateway } from './collaboration.gateway'; +import { AuthenticationExtension } from './extensions/authentication.extension'; +import { PersistenceExtension } from './extensions/persistence.extension'; +import { PageModule } from '../core/page/page.module'; + +@Module({ + providers: [ + CollaborationGateway, + AuthenticationExtension, + PersistenceExtension, + ], + imports: [UserModule, AuthModule, PageModule], +}) +export class CollaborationModule {} diff --git a/server/src/collaboration/extensions/authentication.extension.ts b/server/src/collaboration/extensions/authentication.extension.ts new file mode 100644 index 00000000..675c7cf0 --- /dev/null +++ b/server/src/collaboration/extensions/authentication.extension.ts @@ -0,0 +1,34 @@ +import { Extension, onAuthenticatePayload } from '@hocuspocus/server'; +import { UserService } from '../../core/user/user.service'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { TokenService } from '../../core/auth/services/token.service'; + +@Injectable() +export class AuthenticationExtension implements Extension { + constructor( + private tokenService: TokenService, + private userService: UserService, + ) {} + + async onAuthenticate(data: onAuthenticatePayload) { + const { documentName, token } = data; + + let jwtPayload = null; + + try { + jwtPayload = await this.tokenService.verifyJwt(token); + } catch (error) { + throw new UnauthorizedException('Could not verify jwt token'); + } + + const userId = jwtPayload.sub; + const user = await this.userService.findById(userId); + + //TODO: Check if the page exists and verify user permissions for page. + // if all fails, abort connection + + return { + user, + }; + } +} diff --git a/server/src/collaboration/extensions/persistence.extension.ts b/server/src/collaboration/extensions/persistence.extension.ts new file mode 100644 index 00000000..04162cd7 --- /dev/null +++ b/server/src/collaboration/extensions/persistence.extension.ts @@ -0,0 +1,59 @@ +import { Extension, onLoadDocumentPayload, onStoreDocumentPayload } from '@hocuspocus/server'; +import * as Y from 'yjs'; +import { PageService } from '../../core/page/page.service'; +import { Injectable } from '@nestjs/common'; +import { TiptapTransformer } from '@hocuspocus/transformer'; + +@Injectable() +export class PersistenceExtension implements Extension { + constructor(private readonly pageService: PageService) {} + + async onLoadDocument(data: onLoadDocumentPayload) { + const { documentName, document } = data; + + if (!document.isEmpty('default')) { + return; + } + + const page = await this.pageService.findById(documentName); + + if (!page) { + console.log('page does not exist.'); + //TODO: terminate connection if the page does not exist? + return; + } + + if (page.ydoc) { + const doc = new Y.Doc(); + const dbState = new Uint8Array(page.ydoc); + + Y.applyUpdate(doc, dbState); + return doc; + } + + // if no ydoc state in db convert json in page.content to Ydoc. + const ydoc = TiptapTransformer.toYdoc(page.content, 'default'); + + Y.encodeStateAsUpdate(ydoc); + return ydoc; + } + + async onStoreDocument(data: onStoreDocumentPayload) { + const { documentName, document, context } = data; + + const pageId = documentName; + + const tiptapJson = TiptapTransformer.fromYdoc(document, 'default'); + const ydocState = Buffer.from(Y.encodeStateAsUpdate(document)); + + try { + await this.pageService.updateState( + pageId, + tiptapJson, + ydocState, + ); + } catch (err) { + console.error(`Failed to update page ${documentName}`); + } + } +} diff --git a/server/src/core/auth/services/token.service.ts b/server/src/core/auth/services/token.service.ts index d5ca91c7..8e0dc3e0 100644 --- a/server/src/core/auth/services/token.service.ts +++ b/server/src/core/auth/services/token.service.ts @@ -3,7 +3,7 @@ import { JwtService } from '@nestjs/jwt'; import { EnvironmentService } from '../../../environment/environment.service'; import { User } from '../../user/entities/user.entity'; import { FastifyRequest } from 'fastify'; -import { TokensDto } from "../dto/tokens.dto"; +import { TokensDto } from '../dto/tokens.dto'; @Injectable() export class TokenService { diff --git a/server/src/core/page/entities/page.entity.ts b/server/src/core/page/entities/page.entity.ts index 511a704d..5ea9956b 100644 --- a/server/src/core/page/entities/page.entity.ts +++ b/server/src/core/page/entities/page.entity.ts @@ -20,14 +20,14 @@ export class Page { @Column({ length: 500, nullable: true }) title: string; - @Column({ type: 'text', nullable: true }) + @Column({ type: 'jsonb', nullable: true }) content: string; @Column({ type: 'text', nullable: true }) html: string; - @Column({ type: 'jsonb', nullable: true }) - json: any; + @Column({ type: 'bytea', nullable: true }) + ydoc: any; @Column({ nullable: true }) slug: string; diff --git a/server/src/core/page/page.module.ts b/server/src/core/page/page.module.ts index f78ffb4f..54090be2 100644 --- a/server/src/core/page/page.module.ts +++ b/server/src/core/page/page.module.ts @@ -11,5 +11,6 @@ import { WorkspaceModule } from '../workspace/workspace.module'; imports: [TypeOrmModule.forFeature([Page]), AuthModule, WorkspaceModule], controllers: [PageController], providers: [PageService, PageRepository], + exports: [PageService, PageRepository], }) export class PageModule {} diff --git a/server/src/core/page/page.service.ts b/server/src/core/page/page.service.ts index bbbfbe83..29546e5c 100644 --- a/server/src/core/page/page.service.ts +++ b/server/src/core/page/page.service.ts @@ -22,19 +22,29 @@ export class PageService { page.creatorId = userId; page.workspaceId = workspaceId; - console.log(page); return await this.pageRepository.save(page); } async update(pageId: string, updatePageDto: UpdatePageDto): Promise { + const existingPage = await this.pageRepository.findById(pageId); + if (!existingPage) { + throw new Error(`Page with ID ${pageId} not found`); + } + const page = await this.pageRepository.preload({ id: pageId, ...updatePageDto, } as Page); - return await this.pageRepository.save(page); } + async updateState(pageId: string, content: any, ydoc: any): Promise { + await this.pageRepository.update(pageId, { + content: content, + ydoc: ydoc, + }); + } + async delete(pageId: string): Promise { await this.pageRepository.softDelete(pageId); } diff --git a/server/src/main.ts b/server/src/main.ts index bb0603c1..f0dae83d 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/platform-fastify'; import { ValidationPipe } from '@nestjs/common'; import { TransformHttpResponseInterceptor } from './interceptors/http-response.interceptor'; +import { WsAdapter } from '@nestjs/platform-ws'; async function bootstrap() { const app = await NestFactory.create( @@ -25,6 +26,7 @@ async function bootstrap() { app.enableCors(); app.useGlobalInterceptors(new TransformHttpResponseInterceptor()); + app.useWebSocketAdapter(new WsAdapter(app)); await app.listen(process.env.PORT || 3001); }