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}
+
);
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}
+ >
);
}
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);
}