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:
parent
70ce3d8954
commit
6523006c8a
24
Dockerfile
24
Dockerfile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 "$@"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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}`,
|
||||
});
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">✓</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
|
|
@ -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}`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue