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 ( +
+ + {label} +
+ ); +} diff --git a/src/components/NoteEditor.tsx b/src/components/NoteEditor.tsx index 1018f8c..2a6dc42 100644 --- a/src/components/NoteEditor.tsx +++ b/src/components/NoteEditor.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Link from '@tiptap/extension-link'; @@ -8,6 +8,12 @@ import Placeholder from '@tiptap/extension-placeholder'; import TaskList from '@tiptap/extension-task-list'; import TaskItem from '@tiptap/extension-task-item'; import Image from '@tiptap/extension-image'; +import Collaboration from '@tiptap/extension-collaboration'; +import CollaborationCaret from '@tiptap/extension-collaboration-caret'; +import * as Y from 'yjs'; +import { WebsocketProvider } from 'y-websocket'; +import { CollabStatus } from './CollabStatus'; +import type { CollabState } from '@/lib/collab-provider'; interface NoteEditorProps { value: string; @@ -15,6 +21,12 @@ interface NoteEditorProps { valueJson?: object; type?: string; placeholder?: string; + /** Enable Y.js collaboration for this note */ + collaborative?: boolean; + /** Note ID used as room name for collab */ + noteId?: string; + /** Current user info for cursor display */ + user?: { name: string; color: string }; } function ToolbarButton({ @@ -44,17 +56,78 @@ function ToolbarButton({ ); } -function RichEditor({ value, onChange, valueJson, placeholder: placeholderText }: Omit) { +const COLLAB_WS_URL = process.env.NEXT_PUBLIC_COLLAB_WS_URL || 'ws://localhost:1234'; + +const COLLAB_COLORS = [ + '#f87171', '#fb923c', '#fbbf24', '#a3e635', '#34d399', + '#22d3ee', '#60a5fa', '#a78bfa', '#f472b6', '#e879f9', +]; + +function pickColor(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) | 0; + return COLLAB_COLORS[Math.abs(hash) % COLLAB_COLORS.length]; +} + +function RichEditor({ + value, onChange, valueJson, placeholder: placeholderText, + collaborative, noteId, user, +}: Omit) { const fileInputRef = useRef(null); const [uploading, setUploading] = useState(false); + const [collabState, setCollabState] = useState({ + connected: false, synced: false, peerCount: 0, + }); - const editor = useEditor({ - extensions: [ + // Create Y.js doc + provider (stable across re-renders) + const { ydoc, provider } = useMemo(() => { + if (!collaborative || !noteId) return { ydoc: null, provider: null }; + const doc = new Y.Doc(); + const prov = new WebsocketProvider(COLLAB_WS_URL, `note-${noteId}`, doc); + + const userName = user?.name || 'Anonymous'; + prov.awareness.setLocalStateField('user', { + name: userName, + color: user?.color || pickColor(userName), + }); + + return { ydoc: doc, provider: prov }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [collaborative, noteId]); + + // Track collab connection state + useEffect(() => { + if (!provider) return; + const update = () => { + setCollabState({ + connected: provider.wsconnected, + synced: provider.synced, + peerCount: Math.max(0, provider.awareness.getStates().size - 1), + }); + }; + provider.on('status', update); + provider.on('sync', update); + provider.awareness.on('update', update); + return () => { + provider.off('status', update); + provider.off('sync', update); + provider.awareness.off('update', update); + provider.destroy(); + ydoc?.destroy(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [provider]); + + // Build extensions — add collaboration when enabled + const extensions = useMemo(() => { + const base = [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, codeBlock: { HTMLAttributes: { class: 'bg-slate-800 rounded-lg p-3 font-mono text-sm' } }, code: { HTMLAttributes: { class: 'bg-slate-800 rounded px-1.5 py-0.5 font-mono text-sm text-amber-300' } }, blockquote: { HTMLAttributes: { class: 'border-l-2 border-amber-500/50 pl-4 text-slate-400' } }, + // Disable built-in history when collab is active (Y.js handles undo/redo) + ...(ydoc ? { history: false } : {}), }), Link.configure({ openOnClick: false, @@ -64,8 +137,39 @@ function RichEditor({ value, onChange, valueJson, placeholder: placeholderText } TaskList, TaskItem.configure({ nested: true }), Image.configure({ inline: true }), - ], - content: valueJson || value || '', + ]; + + if (ydoc && provider) { + base.push( + Collaboration.configure({ document: ydoc }) as any, + CollaborationCaret.configure({ + provider, + user: { + name: user?.name || 'Anonymous', + color: user?.color || pickColor(user?.name || 'Anonymous'), + }, + render: (u: Record) => { + const cursor = document.createElement('span'); + cursor.classList.add('collaboration-cursor'); + cursor.style.borderColor = u.color; + const label = document.createElement('span'); + label.classList.add('collaboration-cursor-label'); + label.style.backgroundColor = u.color; + label.textContent = u.name; + cursor.appendChild(label); + return cursor; + }, + }) as any, + ); + } + + return base; + }, [ydoc, provider, placeholderText, user]); + + const editor = useEditor({ + extensions, + // When collaborative, Y.js is the source of truth — don't set initial content + content: ydoc ? undefined : (valueJson || value || ''), onUpdate: ({ editor }) => { onChange(editor.getHTML(), editor.getJSON()); }, @@ -261,13 +365,23 @@ function RichEditor({ value, onChange, valueJson, placeholder: placeholderText } + {/* Collab status indicator */} + {collaborative && ( +
+ +
+ )} + {/* Editor */} ); } -export function NoteEditor({ value, onChange, valueJson, type, placeholder }: NoteEditorProps) { +export function NoteEditor({ + value, onChange, valueJson, type, placeholder, + collaborative, noteId, user, +}: NoteEditorProps) { const isCode = type === 'CODE'; if (isCode) { @@ -283,5 +397,15 @@ export function NoteEditor({ value, onChange, valueJson, type, placeholder }: No ); } - return ; + return ( + + ); } diff --git a/src/lib/collab-provider.ts b/src/lib/collab-provider.ts new file mode 100644 index 0000000..dc54a2d --- /dev/null +++ b/src/lib/collab-provider.ts @@ -0,0 +1,44 @@ +/** + * Y.js collaboration provider for rNotes + * + * Wraps y-websocket's WebsocketProvider to connect to the y-websocket + * sidecar server for real-time collaborative editing. + */ + +import * as Y from 'yjs' +import { WebsocketProvider } from 'y-websocket' + +const COLLAB_WS_URL = process.env.NEXT_PUBLIC_COLLAB_WS_URL || 'ws://localhost:1234' + +export interface CollabState { + connected: boolean + synced: boolean + peerCount: number +} + +export function createCollabProvider( + noteId: string, + ydoc: Y.Doc, + user: { name: string; color: string }, + onStateChange?: (state: CollabState) => void, +): WebsocketProvider { + const roomName = `note-${noteId}` + const provider = new WebsocketProvider(COLLAB_WS_URL, roomName, ydoc) + + // Set local awareness (cursor name/color) + provider.awareness.setLocalStateField('user', user) + + const emitState = () => { + onStateChange?.({ + connected: provider.wsconnected, + synced: provider.synced, + peerCount: provider.awareness.getStates().size - 1, + }) + } + + provider.on('status', emitState) + provider.on('sync', emitState) + provider.awareness.on('update', emitState) + + return provider +}