Add EncryptID auth, try-it-now API, editor mode dropdown, and simplify sync server

- Add AuthButton component with EncryptID passkey authentication
- Add /api/try endpoint for instant guest demo access (scratch notes)
- Add ModeDropdown component (editing/suggesting/viewing modes)
- Simplify sync server to plain JS using y-websocket setupWSConnection
- Update Dockerfile to copy SDK from build context and avoid sdk/ in COPY
- Update CollaborativeEditor and Toolbar with refactored editor logic
- Update docker-compose and entrypoint for simplified sync server
- Add package-lock.json and next-env.d.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-21 21:19:14 +00:00
parent 70ce3d8954
commit 6523006c8a
15 changed files with 6609 additions and 380 deletions

View File

@ -1,15 +1,20 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy SDK from build context
COPY --from=sdk / /encryptid-sdk/
# Copy SDK to a location outside the app source
COPY sdk/ /opt/encryptid-sdk/
# Install dependencies
COPY package.json package-lock.json* ./
RUN sed -i 's|"file:../encryptid-sdk"|"file:/opt/encryptid-sdk"|' package.json
RUN npm install --legacy-peer-deps
# Copy source
COPY . .
# Copy source files explicitly (avoid copying sdk/)
COPY src/ ./src/
COPY prisma/ ./prisma/
COPY sync-server/ ./sync-server/
COPY public/ ./public/
COPY next.config.ts tsconfig.json postcss.config.mjs entrypoint.sh ./
# Generate Prisma client and build
RUN npx prisma generate
@ -29,20 +34,21 @@ COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# Copy sync server
COPY --from=builder /app/sync-server/dist ./sync-server/dist
# Copy sync server (plain JS, no compilation needed)
COPY --from=builder /app/sync-server/src/index.js ./sync-server/index.js
COPY --from=builder /app/node_modules/yjs ./node_modules/yjs
COPY --from=builder /app/node_modules/y-websocket ./node_modules/y-websocket
COPY --from=builder /app/node_modules/y-protocols ./node_modules/y-protocols
COPY --from=builder /app/node_modules/lib0 ./node_modules/lib0
COPY --from=builder /app/node_modules/ws ./node_modules/ws
COPY --from=builder /app/node_modules/lodash.debounce ./node_modules/lodash.debounce
# Copy Prisma
# Copy Prisma client
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder /app/prisma ./prisma
# Copy entrypoint
COPY entrypoint.sh ./entrypoint.sh
COPY --from=builder /app/entrypoint.sh ./entrypoint.sh
RUN chmod +x entrypoint.sh
USER nextjs

View File

@ -2,8 +2,6 @@ services:
rnotes:
build:
context: .
additional_contexts:
sdk: ../encryptid-sdk
container_name: rnotes-frontend
restart: unless-stopped
ports:
@ -17,6 +15,7 @@ services:
- NEXT_PUBLIC_ENCRYPTID_SERVER_URL=https://auth.ridentity.online
- NEXT_PUBLIC_SYNC_URL=wss://rnotes.online/sync
- SYNC_SERVER_PORT=4444
- HOSTNAME=0.0.0.0
depends_on:
rnotes-db:
condition: service_healthy
@ -26,14 +25,16 @@ services:
labels:
- "traefik.enable=true"
# Main app
- "traefik.http.routers.rnotes.rule=Host(`rnotes.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rnotes.online`)"
- "traefik.http.routers.rnotes.entrypoints=websecure"
- "traefik.http.routers.rnotes.tls.certresolver=letsencrypt"
- "traefik.http.routers.rnotes.rule=Host(`rnotes.online`) || Host(`www.rnotes.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rnotes.online`)"
- "traefik.http.routers.rnotes.entrypoints=web"
- "traefik.http.routers.rnotes.priority=130"
- "traefik.http.routers.rnotes.service=rnotes"
- "traefik.http.services.rnotes.loadbalancer.server.port=3000"
# WebSocket sync
- "traefik.http.routers.rnotes-sync.rule=(Host(`rnotes.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rnotes.online`)) && PathPrefix(`/sync`)"
- "traefik.http.routers.rnotes-sync.entrypoints=websecure"
- "traefik.http.routers.rnotes-sync.tls.certresolver=letsencrypt"
- "traefik.http.routers.rnotes-sync.entrypoints=web"
- "traefik.http.routers.rnotes-sync.priority=200"
- "traefik.http.routers.rnotes-sync.service=rnotes-sync"
- "traefik.http.services.rnotes-sync.loadbalancer.server.port=4444"
- "traefik.http.middlewares.rnotes-sync-strip.stripprefix.prefixes=/sync"
- "traefik.http.routers.rnotes-sync.middlewares=rnotes-sync-strip"

View File

@ -2,10 +2,7 @@
set -e
# Start the Yjs sync server in the background
node sync-server/dist/index.js &
# Run Prisma migrations
npx prisma db push --skip-generate 2>/dev/null || true
node sync-server/index.js &
# Start the Next.js server
exec "$@"

6
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

6044
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

55
src/app/api/try/route.ts Normal file
View File

@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
/**
* POST /api/try Create a scratch note for instant demo access.
* No auth required. Creates a guest user + default space + scratch notebook + note.
* Returns the note URL so the frontend can redirect immediately.
*/
export async function POST(req: NextRequest) {
// Ensure default space
let space = await prisma.space.findUnique({ where: { slug: 'default' } });
if (!space) {
space = await prisma.space.create({
data: { slug: 'default', name: 'rNotes' },
});
}
// Ensure guest user
let guest = await prisma.user.findUnique({ where: { username: 'guest' } });
if (!guest) {
guest = await prisma.user.create({
data: { username: 'guest', name: 'Guest', did: 'did:guest:anonymous' },
});
}
// Find or create the Scratch Pad notebook
let scratchPad = await prisma.notebook.findFirst({
where: { spaceId: space.id, title: 'Scratch Pad' },
});
if (!scratchPad) {
scratchPad = await prisma.notebook.create({
data: {
title: 'Scratch Pad',
description: 'Try the editor — no sign-in required',
icon: '🗒️',
spaceId: space.id,
createdBy: guest.id,
},
});
}
// Create a new scratch note
const note = await prisma.note.create({
data: {
title: 'Scratch Note',
notebookId: scratchPad.id,
createdBy: guest.id,
},
});
return NextResponse.json({
noteId: note.id,
url: `/s/default/n/${note.id}`,
});
}

View File

@ -1,7 +1,25 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Header } from '@/components/Header';
export default function LandingPage() {
const router = useRouter();
const [trying, setTrying] = useState(false);
const handleTry = async () => {
setTrying(true);
try {
const res = await fetch('/api/try', { method: 'POST' });
if (res.ok) {
const { url } = await res.json();
router.push(url);
}
} catch {}
setTrying(false);
};
return (
<div className="min-h-screen flex flex-col">
<Header />
@ -26,18 +44,19 @@ export default function LandingPage() {
</p>
<div className="flex items-center justify-center gap-3">
<button
onClick={handleTry}
disabled={trying}
className="px-6 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{trying ? 'Creating note...' : 'Try it Now'}
</button>
<Link
href="/dashboard"
className="px-6 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
>
Get Started
</Link>
<a
href="https://rstack.online"
className="px-6 py-2.5 border border-slate-700 rounded-lg text-slate-300 hover:bg-white/[0.04] transition-colors"
>
Learn More
</a>
Dashboard
</Link>
</div>
</section>

View File

@ -0,0 +1,112 @@
'use client';
import { useState, useEffect } from 'react';
const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || 'https://auth.ridentity.online';
interface AuthState {
authenticated: boolean;
username: string | null;
did: string | null;
}
function getStoredSession(): AuthState {
if (typeof window === 'undefined') return { authenticated: false, username: null, did: null };
try {
const stored = localStorage.getItem('encryptid_session');
if (!stored) return { authenticated: false, username: null, did: null };
const parsed = JSON.parse(stored);
const claims = parsed?.claims || parsed;
if (claims?.sub) {
return {
authenticated: true,
did: claims.sub,
username: claims.eid?.username || claims.username || null,
};
}
} catch {}
return { authenticated: false, username: null, did: null };
}
export function AuthButton() {
const [auth, setAuth] = useState<AuthState>({ authenticated: false, username: null, did: null });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
setAuth(getStoredSession());
}, []);
const handleLogin = async () => {
setLoading(true);
setError('');
try {
// Dynamic import to avoid SSR issues with WebAuthn
const { EncryptIDClient } = await import('@encryptid/sdk/client');
const client = new EncryptIDClient(ENCRYPTID_SERVER);
const result = await client.authenticate();
// Store token as cookie for API routes
document.cookie = `encryptid_token=${result.token};path=/;max-age=900;SameSite=Lax`;
// Store session in localStorage for SpaceSwitcher/AppSwitcher
localStorage.setItem('encryptid_token', result.token);
localStorage.setItem('encryptid_session', JSON.stringify({
claims: { sub: result.did, eid: { username: result.username } }
}));
setAuth({ authenticated: true, did: result.did, username: result.username });
} catch (e: unknown) {
if (e instanceof DOMException && (e.name === 'NotAllowedError' || e.name === 'AbortError')) {
setError('Passkey not found — register first at ridentity.online');
} else {
setError(e instanceof Error ? e.message : 'Sign in failed');
}
} finally {
setLoading(false);
}
};
const handleLogout = () => {
document.cookie = 'encryptid_token=;path=/;max-age=0;SameSite=Lax';
localStorage.removeItem('encryptid_token');
localStorage.removeItem('encryptid_session');
setAuth({ authenticated: false, username: null, did: null });
};
if (auth.authenticated) {
return (
<div className="flex items-center gap-3">
<div className="text-sm">
<span className="text-white/60">Signed in as </span>
<span className="text-primary font-medium">{auth.username || auth.did?.slice(0, 12) + '...'}</span>
</div>
<button
onClick={handleLogout}
className="text-xs text-white/40 hover:text-white/60 transition-colors"
>
Sign out
</button>
</div>
);
}
return (
<div className="flex items-center gap-2">
<button
onClick={handleLogin}
disabled={loading}
className="text-sm text-white/60 hover:text-primary transition-colors flex items-center gap-1.5"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="w-4 h-4">
<circle cx={12} cy={10} r={3} />
<path d="M12 13v8" />
<path d="M9 18h6" />
<circle cx={12} cy={10} r={7} />
</svg>
{loading ? 'Signing in...' : 'Sign in'}
</button>
{error && <span className="text-xs text-red-400 max-w-[200px] truncate">{error}</span>}
</div>
);
}

View File

@ -3,6 +3,7 @@
import Link from 'next/link';
import { AppSwitcher } from '@/components/AppSwitcher';
import { SpaceSwitcher } from '@/components/SpaceSwitcher';
import { AuthButton } from '@/components/AuthButton';
interface BreadcrumbItem {
label: string;
@ -38,8 +39,9 @@ export function Header({ breadcrumbs, actions }: HeaderProps) {
</div>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-3">
{actions}
<AuthButton />
</div>
</div>
</header>

View File

@ -5,8 +5,8 @@ import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
import Underline from '@tiptap/extension-underline';
import Link from '@tiptap/extension-link';
import UnderlineExt from '@tiptap/extension-underline';
import LinkExt from '@tiptap/extension-link';
import ImageExt from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder';
import Highlight from '@tiptap/extension-highlight';
@ -20,8 +20,8 @@ import { SuggestionMark, SuggestionMode } from './SuggestionExtension';
import { CommentMark } from './CommentExtension';
import { Toolbar } from './Toolbar';
import { CommentsSidebar, type CommentThread } from './CommentsSidebar';
import { MessageSquarePlus, PanelRightClose, PanelRightOpen, Users, Wifi, WifiOff } from 'lucide-react';
import { cn } from '@/lib/utils';
import { PanelRightClose, PanelRightOpen, Users, Wifi, WifiOff } from 'lucide-react';
import type { EditorMode } from './ModeDropdown';
// ─── User colors for collaboration cursors ───────────────
@ -58,7 +58,7 @@ export function CollaborativeEditor({
syncUrl,
onTitleChange,
}: CollaborativeEditorProps) {
const [suggestionMode, setSuggestionMode] = useState(false);
const [mode, setMode] = useState<EditorMode>('editing');
const [showComments, setShowComments] = useState(true);
const [showResolved, setShowResolved] = useState(false);
const [activeCommentId, setActiveCommentId] = useState<string | null>(null);
@ -70,12 +70,14 @@ export function CollaborativeEditor({
// ─── Yjs document + WebSocket provider ─────────────────
const { ydoc, provider } = useMemo(() => {
const { ydoc, provider, ycomments } = useMemo(() => {
const ydoc = new Y.Doc();
const provider = new WebsocketProvider(syncUrl, yjsDocId, ydoc, {
connect: true,
});
return { ydoc, provider };
// Shared Y.Array for multiplayer comments
const ycomments = ydoc.getArray<CommentThread>('comments');
return { ydoc, provider, ycomments };
}, [syncUrl, yjsDocId]);
useEffect(() => {
@ -83,7 +85,7 @@ export function CollaborativeEditor({
setConnected(status === 'connected');
};
const onPeers = () => {
setPeers(provider.awareness.getStates().size - 1);
setPeers(Math.max(0, provider.awareness.getStates().size - 1));
};
provider.on('status', onStatus);
@ -95,13 +97,21 @@ export function CollaborativeEditor({
color: userColor,
});
// Sync comments from Yjs
const syncComments = () => {
setComments(ycomments.toArray());
};
ycomments.observe(syncComments);
syncComments();
return () => {
ycomments.unobserve(syncComments);
provider.off('status', onStatus);
provider.awareness.off('change', onPeers);
provider.destroy();
ydoc.destroy();
};
}, [provider, ydoc, userName, userColor]);
}, [provider, ydoc, ycomments, userName, userColor]);
// ─── TipTap editor ────────────────────────────────────
@ -117,8 +127,8 @@ export function CollaborativeEditor({
provider,
user: { name: userName, color: userColor },
}),
Underline,
Link.configure({ openOnClick: false }),
UnderlineExt,
LinkExt.configure({ openOnClick: false }),
ImageExt,
Placeholder.configure({
placeholder: 'Start writing...',
@ -130,46 +140,49 @@ export function CollaborativeEditor({
Color,
SuggestionMark,
SuggestionMode.configure({
enabled: suggestionMode,
enabled: false,
userId,
userName,
userColor,
}),
CommentMark,
],
editable: mode !== 'viewing',
editorProps: {
attributes: {
class: 'tiptap',
},
},
onUpdate: ({ editor }) => {
// Extract title from first heading or first line
const firstNode = editor.state.doc.firstChild;
onUpdate: ({ editor: ed }) => {
const firstNode = ed.state.doc.firstChild;
if (firstNode) {
const text = firstNode.textContent?.trim() || 'Untitled';
onTitleChange?.(text);
onTitleChange?.(firstNode.textContent?.trim() || 'Untitled');
}
},
}, [ydoc, provider]);
// Update suggestion mode dynamically
// ─── Mode changes ──────────────────────────────────────
useEffect(() => {
if (!editor) return;
// Re-configure the suggestion mode extension
// Toggle editable
editor.setEditable(mode !== 'viewing');
// Toggle suggestion mode
editor.extensionManager.extensions.forEach((ext) => {
if (ext.name === 'suggestionMode') {
ext.options.enabled = suggestionMode;
ext.options.enabled = mode === 'suggesting';
}
});
}, [editor, suggestionMode]);
}, [editor, mode]);
// ─── Suggestion helpers ────────────────────────────────
const pendingSuggestions = useMemo(() => {
if (!editor) return 0;
let count = 0;
const { doc } = editor.state;
doc.descendants((node) => {
editor.state.doc.descendants((node) => {
node.marks.forEach((mark) => {
if (mark.type.name === 'suggestion' && mark.attrs.status === 'pending') {
count++;
@ -177,30 +190,24 @@ export function CollaborativeEditor({
});
});
return count;
}, [editor?.state.doc]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, editor?.state.doc]);
const handleAcceptAll = useCallback(() => {
if (!editor) return;
const { tr, doc } = editor.state;
const marks: { from: number; to: number; mark: typeof doc.type.schema.marks.suggestion }[] = [];
const items: { from: number; to: number; type: string }[] = [];
doc.descendants((node, pos) => {
node.marks.forEach((mark) => {
if (mark.type.name === 'suggestion' && mark.attrs.status === 'pending') {
if (mark.attrs.type === 'delete') {
// For delete suggestions, remove the content
marks.push({ from: pos, to: pos + node.nodeSize, mark: mark as any });
} else {
// For insert suggestions, just remove the mark (keep the text)
marks.push({ from: pos, to: pos + node.nodeSize, mark: mark as any });
}
items.push({ from: pos, to: pos + node.nodeSize, type: mark.attrs.type });
}
});
});
// Process in reverse to maintain positions
marks.reverse().forEach(({ from, to, mark }) => {
if ((mark as any).attrs.type === 'delete') {
items.reverse().forEach(({ from, to, type }) => {
if (type === 'delete') {
tr.delete(from, to);
} else {
tr.removeMark(from, to, editor.state.schema.marks.suggestion);
@ -213,22 +220,20 @@ export function CollaborativeEditor({
const handleRejectAll = useCallback(() => {
if (!editor) return;
const { tr, doc } = editor.state;
const marks: { from: number; to: number; mark: any }[] = [];
const items: { from: number; to: number; type: string }[] = [];
doc.descendants((node, pos) => {
node.marks.forEach((mark) => {
if (mark.type.name === 'suggestion' && mark.attrs.status === 'pending') {
marks.push({ from: pos, to: pos + node.nodeSize, mark });
items.push({ from: pos, to: pos + node.nodeSize, type: mark.attrs.type });
}
});
});
marks.reverse().forEach(({ from, to, mark }) => {
if (mark.attrs.type === 'insert') {
// For insert suggestions, remove the inserted text
items.reverse().forEach(({ from, to, type }) => {
if (type === 'insert') {
tr.delete(from, to);
} else {
// For delete suggestions, just remove the mark (keep the text)
tr.removeMark(from, to, editor.state.schema.marks.suggestion);
}
});
@ -236,12 +241,12 @@ export function CollaborativeEditor({
editor.view.dispatch(tr);
}, [editor]);
// ─── Comment helpers ───────────────────────────────────
// ─── Comment helpers (synced via Yjs) ──────────────────
const handleAddComment = useCallback(() => {
if (!editor) return;
const { from, to } = editor.state.selection;
if (from === to) return; // Need a selection
if (from === to) return;
const body = window.prompt('Add a comment:');
if (!body?.trim()) return;
@ -261,68 +266,68 @@ export function CollaborativeEditor({
reactions: {},
};
// Add comment mark to the text
editor
.chain()
.focus()
.setMark('comment', { commentId, resolved: false })
.run();
// Add mark to editor
editor.chain().focus().setMark('comment', { commentId, resolved: false }).run();
setComments((prev) => [...prev, newComment]);
// Add to shared Yjs array (syncs to all peers)
ycomments.push([newComment]);
setActiveCommentId(commentId);
setShowComments(true);
}, [editor, noteId, userId, userName]);
}, [editor, noteId, userId, userName, ycomments]);
const handleReply = useCallback((commentId: string, body: string) => {
setComments((prev) =>
prev.map((c) =>
c.id === commentId
? {
...c,
replies: [
...c.replies,
{
id: crypto.randomUUID(),
authorId: userId,
authorName: userName,
body,
createdAt: new Date().toISOString(),
},
],
}
: c
)
);
}, [userId, userName]);
const idx = ycomments.toArray().findIndex((c) => c.id === commentId);
if (idx === -1) return;
const comment = ycomments.get(idx);
const updated: CommentThread = {
...comment,
replies: [
...comment.replies,
{
id: crypto.randomUUID(),
authorId: userId,
authorName: userName,
body,
createdAt: new Date().toISOString(),
},
],
};
ydoc.transact(() => {
ycomments.delete(idx, 1);
ycomments.insert(idx, [updated]);
});
}, [userId, userName, ycomments, ydoc]);
const handleResolve = useCallback((commentId: string) => {
setComments((prev) =>
prev.map((c) => (c.id === commentId ? { ...c, resolved: true } : c))
);
const idx = ycomments.toArray().findIndex((c) => c.id === commentId);
if (idx === -1) return;
// Update the mark in the editor
const comment = ycomments.get(idx);
ydoc.transact(() => {
ycomments.delete(idx, 1);
ycomments.insert(idx, [{ ...comment, resolved: true }]);
});
// Update mark
if (editor) {
const { doc, tr } = editor.state;
doc.descendants((node, pos) => {
node.marks.forEach((mark) => {
if (mark.type.name === 'comment' && mark.attrs.commentId === commentId) {
tr.removeMark(pos, pos + node.nodeSize, mark.type);
tr.addMark(
pos,
pos + node.nodeSize,
mark.type.create({ commentId, resolved: true })
);
tr.addMark(pos, pos + node.nodeSize, mark.type.create({ commentId, resolved: true }));
}
});
});
editor.view.dispatch(tr);
}
}, [editor]);
}, [editor, ycomments, ydoc]);
const handleDeleteComment = useCallback((commentId: string) => {
setComments((prev) => prev.filter((c) => c.id !== commentId));
const idx = ycomments.toArray().findIndex((c) => c.id === commentId);
if (idx !== -1) ycomments.delete(idx, 1);
// Remove the mark from the editor
if (editor) {
const { doc, tr } = editor.state;
doc.descendants((node, pos) => {
@ -334,61 +339,70 @@ export function CollaborativeEditor({
});
editor.view.dispatch(tr);
}
}, [editor]);
}, [editor, ycomments]);
const handleReact = useCallback((commentId: string, emoji: string) => {
setComments((prev) =>
prev.map((c) => {
if (c.id !== commentId) return c;
const reactions = { ...c.reactions };
const authors = reactions[emoji] ? [...reactions[emoji]] : [];
const idx = authors.indexOf(userId);
if (idx >= 0) {
authors.splice(idx, 1);
if (authors.length === 0) delete reactions[emoji];
else reactions[emoji] = authors;
} else {
reactions[emoji] = [...authors, userId];
}
return { ...c, reactions };
})
);
}, [userId]);
const idx = ycomments.toArray().findIndex((c) => c.id === commentId);
if (idx === -1) return;
const comment = ycomments.get(idx);
const reactions = { ...comment.reactions };
const authors = reactions[emoji] ? [...reactions[emoji]] : [];
const aidx = authors.indexOf(userId);
if (aidx >= 0) {
authors.splice(aidx, 1);
if (authors.length === 0) delete reactions[emoji];
else reactions[emoji] = authors;
} else {
reactions[emoji] = [...authors, userId];
}
ydoc.transact(() => {
ycomments.delete(idx, 1);
ycomments.insert(idx, [{ ...comment, reactions }]);
});
}, [userId, ycomments, ydoc]);
const handleClickComment = useCallback((commentId: string) => {
setActiveCommentId(commentId);
// Scroll editor to the comment position
if (editor) {
const comment = comments.find((c) => c.id === commentId);
if (comment) {
editor.commands.setTextSelection({
from: comment.fromPos,
to: comment.toPos,
});
editor.commands.scrollIntoView();
try {
editor.commands.setTextSelection({ from: comment.fromPos, to: comment.toPos });
editor.commands.scrollIntoView();
} catch {
// Position may be stale
}
}
}
}, [editor, comments]);
// ─── Mode banner text ──────────────────────────────────
const modeBanner = mode === 'suggesting'
? { bg: 'bg-green-500/[0.08] border-green-500/20 text-green-400', text: 'Suggesting — your edits will appear as suggestions for others to review' }
: mode === 'viewing'
? { bg: 'bg-slate-500/[0.08] border-slate-500/20 text-slate-400', text: 'Viewing — read-only mode, switch to Editing or Suggesting to make changes' }
: null;
// ─── Render ────────────────────────────────────────────
return (
<div className="flex flex-col h-[calc(100vh-53px)]">
<Toolbar
editor={editor}
suggestionMode={suggestionMode}
onToggleSuggestionMode={() => setSuggestionMode(!suggestionMode)}
mode={mode}
onModeChange={setMode}
onAddComment={handleAddComment}
pendingSuggestions={pendingSuggestions}
onAcceptAll={handleAcceptAll}
onRejectAll={handleRejectAll}
/>
{/* Mode indicator bar */}
{suggestionMode && (
<div className="flex items-center gap-2 px-4 py-1.5 bg-green-500/[0.08] border-b border-green-500/20 text-green-400 text-xs">
<FileEdit size={12} />
<span>Suggestion mode your edits will appear as suggestions for review</span>
{modeBanner && (
<div className={`flex items-center gap-2 px-4 py-1.5 border-b text-xs ${modeBanner.bg}`}>
<span>{modeBanner.text}</span>
</div>
)}
@ -450,13 +464,3 @@ export function CollaborativeEditor({
</div>
);
}
// Re-export for the toolbar (used in this file but imported from lucide)
function FileEdit({ size }: { size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20h9" />
<path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.855z" />
</svg>
);
}

View File

@ -0,0 +1,80 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { Pencil, LightbulbIcon, Eye, ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
export type EditorMode = 'editing' | 'suggesting' | 'viewing';
const MODES: { id: EditorMode; label: string; icon: typeof Pencil; desc: string; color: string }[] = [
{ id: 'editing', label: 'Editing', icon: Pencil, desc: 'Edit the document directly', color: 'text-blue-400' },
{ id: 'suggesting', label: 'Suggesting', icon: LightbulbIcon, desc: 'Suggest changes for review', color: 'text-green-400' },
{ id: 'viewing', label: 'Viewing', icon: Eye, desc: 'Read-only view', color: 'text-slate-400' },
];
interface ModeDropdownProps {
mode: EditorMode;
onChange: (mode: EditorMode) => void;
}
export function ModeDropdown({ mode, onChange }: ModeDropdownProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, []);
const current = MODES.find((m) => m.id === mode)!;
const Icon = current.icon;
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className={cn(
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm font-medium transition-colors',
'hover:bg-white/[0.06] border border-transparent',
mode === 'editing' && 'text-blue-400 bg-blue-500/[0.08] border-blue-500/20',
mode === 'suggesting' && 'text-green-400 bg-green-500/[0.08] border-green-500/20',
mode === 'viewing' && 'text-slate-400 bg-white/[0.04]',
)}
>
<Icon size={14} />
<span>{current.label}</span>
<ChevronDown size={12} className="opacity-50" />
</button>
{open && (
<div className="absolute top-full right-0 mt-1 w-56 rounded-xl bg-slate-800 border border-white/10 shadow-xl shadow-black/30 z-[200] overflow-hidden">
{MODES.map((m) => {
const MIcon = m.icon;
return (
<button
key={m.id}
onClick={() => { onChange(m.id); setOpen(false); }}
className={cn(
'w-full flex items-start gap-2.5 px-3.5 py-2.5 text-left transition-colors hover:bg-white/[0.05]',
mode === m.id && 'bg-white/[0.07]'
)}
>
<MIcon size={16} className={cn('mt-0.5 flex-shrink-0', m.color)} />
<div>
<div className={cn('text-sm font-medium', m.color)}>{m.label}</div>
<div className="text-[11px] text-slate-500">{m.desc}</div>
</div>
{mode === m.id && (
<span className="ml-auto text-primary text-sm">&#10003;</span>
)}
</button>
);
})}
</div>
)}
</div>
);
}

View File

@ -7,21 +7,22 @@ import {
List, ListOrdered, ListChecks,
Quote, Code, Minus, Undo2, Redo2,
Image, Link2,
MessageSquare, FileEdit, Check, X,
MessageSquare, Check, X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { ModeDropdown, type EditorMode } from './ModeDropdown';
interface ToolbarProps {
editor: Editor | null;
suggestionMode: boolean;
onToggleSuggestionMode: () => void;
mode: EditorMode;
onModeChange: (mode: EditorMode) => void;
onAddComment: () => void;
pendingSuggestions: number;
onAcceptAll: () => void;
onRejectAll: () => void;
}
function ToolbarButton({
function Btn({
onClick,
active,
disabled,
@ -65,8 +66,8 @@ function Divider() {
export function Toolbar({
editor,
suggestionMode,
onToggleSuggestionMode,
mode,
onModeChange,
onAddComment,
pendingSuggestions,
onAcceptAll,
@ -74,130 +75,127 @@ export function Toolbar({
}: ToolbarProps) {
if (!editor) return null;
const isViewing = mode === 'viewing';
return (
<div className="flex items-center gap-0.5 px-3 py-1.5 border-b border-slate-800 bg-slate-900/50 backdrop-blur-sm flex-wrap">
{/* Undo / Redo */}
<ToolbarButton onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Undo">
<Btn onClick={() => editor.chain().focus().undo().run()} disabled={isViewing || !editor.can().undo()} title="Undo">
<Undo2 size={16} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Redo">
</Btn>
<Btn onClick={() => editor.chain().focus().redo().run()} disabled={isViewing || !editor.can().redo()} title="Redo">
<Redo2 size={16} />
</ToolbarButton>
</Btn>
<Divider />
{/* Text formatting */}
<ToolbarButton onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Bold">
<Btn onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} disabled={isViewing} title="Bold">
<Bold size={16} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Italic">
</Btn>
<Btn onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} disabled={isViewing} title="Italic">
<Italic size={16} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} title="Underline">
</Btn>
<Btn onClick={() => editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} disabled={isViewing} title="Underline">
<Underline size={16} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} title="Strikethrough">
</Btn>
<Btn onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} disabled={isViewing} title="Strikethrough">
<Strikethrough size={16} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().toggleCode().run()} active={editor.isActive('code')} title="Inline code">
</Btn>
<Btn onClick={() => editor.chain().focus().toggleCode().run()} active={editor.isActive('code')} disabled={isViewing} title="Inline code">
<Code size={16} />
</ToolbarButton>
</Btn>
<Divider />
{/* Headings */}
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive('heading', { level: 1 })} title="Heading 1">
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive('heading', { level: 1 })} disabled={isViewing} title="Heading 1">
<Heading1 size={16} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} title="Heading 2">
</Btn>
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} disabled={isViewing} title="Heading 2">
<Heading2 size={16} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Heading 3">
</Btn>
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} disabled={isViewing} title="Heading 3">
<Heading3 size={16} />
</ToolbarButton>
</Btn>
<Divider />
{/* Lists */}
<ToolbarButton onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Bullet list">
<Btn onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} disabled={isViewing} title="Bullet list">
<List size={16} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Numbered list">
</Btn>
<Btn onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} disabled={isViewing} title="Numbered list">
<ListOrdered size={16} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().toggleTaskList().run()} active={editor.isActive('taskList')} title="Task list">
</Btn>
<Btn onClick={() => editor.chain().focus().toggleTaskList().run()} active={editor.isActive('taskList')} disabled={isViewing} title="Task list">
<ListChecks size={16} />
</ToolbarButton>
</Btn>
<Divider />
{/* Block elements */}
<ToolbarButton onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Blockquote">
<Btn onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} disabled={isViewing} title="Blockquote">
<Quote size={16} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().setHorizontalRule().run()} title="Horizontal rule">
</Btn>
<Btn onClick={() => editor.chain().focus().setHorizontalRule().run()} disabled={isViewing} title="Horizontal rule">
<Minus size={16} />
</ToolbarButton>
</Btn>
<Divider />
{/* Link */}
<ToolbarButton
<Btn
onClick={() => {
const url = window.prompt('URL:');
if (url) editor.chain().focus().setLink({ href: url }).run();
}}
active={editor.isActive('link')}
disabled={isViewing}
title="Insert link"
>
<Link2 size={16} />
</ToolbarButton>
</Btn>
{/* Image */}
<ToolbarButton
<Btn
onClick={() => {
const url = window.prompt('Image URL:');
if (url) editor.chain().focus().setImage({ src: url }).run();
}}
disabled={isViewing}
title="Insert image"
>
<Image size={16} />
</ToolbarButton>
</Btn>
<div className="flex-1" />
{/* Collaboration tools */}
<ToolbarButton onClick={onAddComment} title="Add comment" variant="accent">
{/* Comment button */}
<Btn onClick={onAddComment} title="Add comment (select text first)" variant="accent">
<MessageSquare size={16} />
</ToolbarButton>
</Btn>
{/* Suggestion accept/reject */}
{pendingSuggestions > 0 && (
<>
<Divider />
<span className="text-xs text-slate-400 mx-1">
{pendingSuggestions} pending
</span>
<Btn onClick={onAcceptAll} title="Accept all suggestions" variant="success">
<Check size={14} />
</Btn>
<Btn onClick={onRejectAll} title="Reject all suggestions" variant="danger">
<X size={14} />
</Btn>
</>
)}
<Divider />
{/* Suggestion mode toggle */}
<ToolbarButton
onClick={onToggleSuggestionMode}
active={suggestionMode}
title={suggestionMode ? 'Switch to editing mode' : 'Switch to suggestion mode'}
>
<FileEdit size={16} />
<span className="text-xs ml-1 hidden sm:inline">
{suggestionMode ? 'Suggesting' : 'Editing'}
</span>
</ToolbarButton>
{pendingSuggestions > 0 && (
<>
<span className="text-xs text-slate-400 ml-1">
{pendingSuggestions} pending
</span>
<ToolbarButton onClick={onAcceptAll} title="Accept all suggestions" variant="success">
<Check size={14} />
</ToolbarButton>
<ToolbarButton onClick={onRejectAll} title="Reject all suggestions" variant="danger">
<X size={14} />
</ToolbarButton>
</>
)}
{/* Mode dropdown */}
<ModeDropdown mode={mode} onChange={onModeChange} />
</div>
);
}

31
sync-server/src/index.js Normal file
View File

@ -0,0 +1,31 @@
/**
* Yjs WebSocket sync server for rNotes.
*
* Uses y-websocket's setupWSConnection for full protocol compatibility.
*/
const http = require("http");
const { WebSocketServer } = require("ws");
const { setupWSConnection } = require("y-websocket/bin/utils");
const PORT = parseInt(process.env.SYNC_SERVER_PORT || "4444", 10);
const server = http.createServer((req, res) => {
if (req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
res.writeHead(200);
res.end("rNotes sync server");
});
const wss = new WebSocketServer({ server });
wss.on("connection", (ws, req) => {
setupWSConnection(ws, req);
});
server.listen(PORT, () => {
console.log(`rNotes sync server listening on port ${PORT}`);
});

View File

@ -1,123 +1,21 @@
/**
* Yjs WebSocket sync server for rNotes.
*
* Each note document is identified by its yjsDocId.
* The server holds documents in memory, persists to LevelDB,
* and broadcasts changes to all connected clients.
* Uses y-websocket's setupWSConnection for full compatibility
* with the y-websocket client WebsocketProvider.
*/
import http from "http";
import { WebSocketServer, WebSocket } from "ws";
import * as Y from "yjs";
import { encoding, decoding, mutex } from "lib0";
import { WebSocketServer } from "ws";
// @ts-ignore — y-websocket/bin/utils has no types
import { setupWSConnection } from "y-websocket/bin/utils";
const PORT = parseInt(process.env.SYNC_SERVER_PORT || "4444", 10);
// ─── In-memory document store ────────────────────────────
interface SharedDoc {
doc: Y.Doc;
conns: Map<WebSocket, Set<number>>;
awareness: Map<number, { clock: number; state: unknown }>;
mux: mutex.mutex;
}
const docs = new Map<string, SharedDoc>();
function getDoc(name: string): SharedDoc {
let shared = docs.get(name);
if (shared) return shared;
const doc = new Y.Doc();
shared = {
doc,
conns: new Map(),
awareness: new Map(),
mux: mutex.createMutex(),
};
docs.set(name, shared);
return shared;
}
// ─── Yjs sync protocol (simplified) ─────────────────────
const MSG_SYNC = 0;
const MSG_AWARENESS = 1;
const SYNC_STEP1 = 0;
const SYNC_STEP2 = 1;
const SYNC_UPDATE = 2;
function sendToAll(shared: SharedDoc, message: Uint8Array, exclude?: WebSocket) {
shared.conns.forEach((_, conn) => {
if (conn !== exclude && conn.readyState === WebSocket.OPEN) {
conn.send(message);
}
});
}
function handleSyncMessage(
shared: SharedDoc,
conn: WebSocket,
buf: Uint8Array
) {
const decoder = decoding.createDecoder(buf);
const msgType = decoding.readVarUint(decoder);
if (msgType === MSG_SYNC) {
const syncType = decoding.readVarUint(decoder);
if (syncType === SYNC_STEP1) {
// Client sends state vector, server responds with diff
const sv = decoding.readVarUint8Array(decoder);
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MSG_SYNC);
encoding.writeVarUint(encoder, SYNC_STEP2);
encoding.writeVarUint8Array(
encoder,
Y.encodeStateAsUpdate(shared.doc, sv)
);
if (conn.readyState === WebSocket.OPEN) {
conn.send(encoding.toUint8Array(encoder));
}
} else if (syncType === SYNC_STEP2 || syncType === SYNC_UPDATE) {
const update = decoding.readVarUint8Array(decoder);
Y.applyUpdate(shared.doc, update);
// Broadcast update to all other clients
if (syncType === SYNC_UPDATE) {
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MSG_SYNC);
encoding.writeVarUint(encoder, SYNC_UPDATE);
encoding.writeVarUint8Array(encoder, update);
sendToAll(shared, encoding.toUint8Array(encoder), conn);
}
}
} else if (msgType === MSG_AWARENESS) {
// Broadcast awareness (cursors, selections) to all peers
sendToAll(shared, buf, conn);
}
}
function sendSyncStep1(shared: SharedDoc, conn: WebSocket) {
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, MSG_SYNC);
encoding.writeVarUint(encoder, SYNC_STEP1);
encoding.writeVarUint8Array(
encoder,
Y.encodeStateVector(shared.doc)
);
if (conn.readyState === WebSocket.OPEN) {
conn.send(encoding.toUint8Array(encoder));
}
}
// ─── WebSocket server ────────────────────────────────────
const server = http.createServer((req, res) => {
if (req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", docs: docs.size }));
res.end(JSON.stringify({ status: "ok" }));
return;
}
res.writeHead(200);
@ -126,53 +24,11 @@ const server = http.createServer((req, res) => {
const wss = new WebSocketServer({ server });
wss.on("connection", (conn, req) => {
// Document name from URL path: /ws/<docId>
const url = new URL(req.url || "/", `http://${req.headers.host}`);
const docName = url.pathname.replace(/^\/ws\//, "").replace(/^\//, "");
if (!docName) {
conn.close(4000, "Missing document name");
return;
}
const shared = getDoc(docName);
shared.conns.set(conn, new Set());
// Send initial sync step 1 to the new client
sendSyncStep1(shared, conn);
conn.on("message", (data: Buffer | ArrayBuffer | Buffer[]) => {
const buf = data instanceof ArrayBuffer
? new Uint8Array(data)
: new Uint8Array(data as Buffer);
shared.mux(() => {
handleSyncMessage(shared, conn, buf);
});
});
conn.on("close", () => {
shared.conns.delete(conn);
// Clean up empty docs after a delay
if (shared.conns.size === 0) {
setTimeout(() => {
const current = docs.get(docName);
if (current && current.conns.size === 0) {
current.doc.destroy();
docs.delete(docName);
}
}, 30000);
}
});
conn.on("error", () => {
shared.conns.delete(conn);
});
wss.on("connection", (ws, req) => {
setupWSConnection(ws, req);
});
server.listen(PORT, () => {
console.log(`rNotes sync server listening on port ${PORT}`);
console.log(`WebSocket endpoint: ws://0.0.0.0:${PORT}/ws/<docId>`);
console.log(`WebSocket endpoint: ws://0.0.0.0:${PORT}`);
});

View File

@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@ -11,13 +15,27 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "sync-server"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules",
"sync-server"
]
}