diff --git a/docker-compose.yml b/docker-compose.yml index 87d17de..4c5b534 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,8 @@ services: - IPFS_ENABLED=true - IPFS_API_URL=https://ipfs-api.rspace.online - IPFS_GATEWAY_URL=https://ipfs.rspace.online + # Y.js collaboration (client-side env var baked at build time) + - NEXT_PUBLIC_COLLAB_WS_URL=wss://collab-ws.rnotes.online volumes: - uploads_data:/app/uploads labels: @@ -44,6 +46,28 @@ services: - /tmp - /home/nextjs/.npm + # Y.js WebSocket server for real-time collaboration + rnotes-yws: + image: node:22-slim + container_name: rnotes-yws + restart: unless-stopped + command: ["npx", "y-websocket"] + environment: + - HOST=0.0.0.0 + - PORT=1234 + networks: + - traefik-public + - rnotes-internal + labels: + - "traefik.enable=true" + - "traefik.http.routers.rnotes-yws.rule=Host(`collab-ws.rnotes.online`)" + - "traefik.http.routers.rnotes-yws.entrypoints=web" + - "traefik.http.services.rnotes-yws.loadbalancer.server.port=1234" + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + rnotes-postgres: image: postgres:16-alpine container_name: rnotes-postgres diff --git a/package-lock.json b/package-lock.json index 0092a75..6361960 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@prisma/client": "^6.19.2", "@tiptap/core": "^3.19.0", "@tiptap/extension-code-block-lowlight": "^3.19.0", + "@tiptap/extension-collaboration": "^3.22.0", + "@tiptap/extension-collaboration-caret": "^3.22.0", "@tiptap/extension-image": "^3.19.0", "@tiptap/extension-link": "^3.19.0", "@tiptap/extension-placeholder": "^3.19.0", @@ -28,6 +30,8 @@ "next": "14.2.35", "react": "^18", "react-dom": "^18", + "y-websocket": "^3.0.0", + "yjs": "^13.6.30", "zustand": "^5.0.11" }, "devDependencies": { @@ -44,7 +48,7 @@ }, "../encryptid-sdk": { "name": "@encryptid/sdk", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@noble/curves": "^2.0.1", @@ -375,14 +379,16 @@ } }, "node_modules/@tiptap/core": { - "version": "3.19.0", + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.0.tgz", + "integrity": "sha512-EA/XFbvvz0yRyccqrgOwB9RQe6+uJ8NszjLKH9+3xPE2/+Sa2imax0IqWl7YOXkWihdQVrlpP+EpQF9APKx3jg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/pm": "^3.19.0" + "@tiptap/pm": "^3.22.0" } }, "node_modules/@tiptap/extension-blockquote": { @@ -472,6 +478,37 @@ "lowlight": "^2 || ^3" } }, + "node_modules/@tiptap/extension-collaboration": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.22.0.tgz", + "integrity": "sha512-S/nPIth4/Dr5wmxROk4ELWRYhBXxWt7O6cIi8Fca1rYDBdXofjgt4VDO2iCjnOF0LkIekjQudSOFLWadbM2Z+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.0", + "@tiptap/pm": "^3.22.0", + "@tiptap/y-tiptap": "^3.0.2", + "yjs": "^13" + } + }, + "node_modules/@tiptap/extension-collaboration-caret": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration-caret/-/extension-collaboration-caret-3.22.0.tgz", + "integrity": "sha512-M17V2Q3VQLg9VA/1kyyqoC2N55SbOSPDybMiAyBogwpy1JcE8YbLkfpvHMGRi29Dbw3H/+F5wkfCZ4JyS5ASxw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.0", + "@tiptap/pm": "^3.22.0", + "@tiptap/y-tiptap": "^3.0.2" + } + }, "node_modules/@tiptap/extension-document": { "version": "3.19.0", "license": "MIT", @@ -725,7 +762,9 @@ } }, "node_modules/@tiptap/pm": { - "version": "3.19.0", + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.0.tgz", + "integrity": "sha512-O9kpzNnFX5837kFevwAM8yr7ImLHu8noIwIpoci0AwfJjiBMzfZBejhbzxnKEfTpFWnkvZ8rWohlb6CQdJ6Crg==", "license": "MIT", "dependencies": { "prosemirror-changeset": "^2.3.0", @@ -811,6 +850,27 @@ "url": "https://github.com/sponsors/ueberdosis" } }, + "node_modules/@tiptap/y-tiptap": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.2.tgz", + "integrity": "sha512-flMn/YW6zTbc6cvDaUPh/NfLRTXDIqgpBUkYzM74KA1snqQwhOMjnRcnpu4hDFrTnPO6QGzr99vRyXEA7M44WA==", + "license": "MIT", + "peer": true, + "dependencies": { + "lib0": "^0.2.100" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.7.1", + "prosemirror-state": "^1.2.3", + "prosemirror-view": "^1.9.10", + "y-protocols": "^1.0.1", + "yjs": "^13.5.38" + } + }, "node_modules/@types/archiver": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.4.tgz", @@ -1872,6 +1932,16 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -1941,6 +2011,27 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "dev": true, @@ -3478,6 +3569,64 @@ "node": ">=8" } }, + "node_modules/y-protocols": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "node_modules/y-websocket": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/y-websocket/-/y-websocket-3.0.0.tgz", + "integrity": "sha512-mUHy7AzkOZ834T/7piqtlA8Yk6AchqKqcrCXjKW8J1w2lPtRDjz8W5/CvXz9higKAHgKRKqpI3T33YkRFLkPtg==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.102", + "y-protocols": "^1.0.5" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.5.6" + } + }, + "node_modules/yjs": { + "version": "13.6.30", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.30.tgz", + "integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/zip-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", diff --git a/package.json b/package.json index 770b75b..73dfe36 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "@prisma/client": "^6.19.2", "@tiptap/core": "^3.19.0", "@tiptap/extension-code-block-lowlight": "^3.19.0", + "@tiptap/extension-collaboration": "^3.22.0", + "@tiptap/extension-collaboration-caret": "^3.22.0", "@tiptap/extension-image": "^3.19.0", "@tiptap/extension-link": "^3.19.0", "@tiptap/extension-placeholder": "^3.19.0", @@ -32,6 +34,8 @@ "next": "14.2.35", "react": "^18", "react-dom": "^18", + "y-websocket": "^3.0.0", + "yjs": "^13.6.30", "zustand": "^5.0.11" }, "devDependencies": { diff --git a/src/components/CollabStatus.tsx b/src/components/CollabStatus.tsx new file mode 100644 index 0000000..252ce5b --- /dev/null +++ b/src/components/CollabStatus.tsx @@ -0,0 +1,32 @@ +'use client'; + +import type { CollabState } from '@/lib/collab-provider'; + +interface CollabStatusProps { + state: CollabState; +} + +export function CollabStatus({ state }: CollabStatusProps) { + const { connected, synced, peerCount } = state; + + const color = !connected + ? 'bg-red-500' + : !synced + ? 'bg-amber-500 animate-pulse' + : 'bg-emerald-500'; + + const label = !connected + ? 'Offline' + : !synced + ? 'Syncing...' + : peerCount > 0 + ? `${peerCount} online` + : 'Connected'; + + return ( +