Initial scaffold: rNotes collaborative editor

- TipTap + Yjs for real-time collaborative editing with CRDT sync
- Suggestion mode (track changes) with accept/reject workflow
- Inline comments with threaded replies and emoji reactions
- WebSocket sync server for Yjs document synchronization
- PostgreSQL + Prisma for notebooks, notes, comments, suggestions
- EncryptID passkey authentication
- Standard rApp template (Next.js 15, Tailwind 4, shadcn/ui patterns)
- Docker Compose with Traefik routing for rnotes.online
- SpaceSwitcher + AppSwitcher (consistent with all rApps)
This commit is contained in:
Jeff Emmett 2026-03-17 02:45:38 +00:00
commit 70ce3d8954
37 changed files with 3377 additions and 0 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
.next
.git
*.md
backlog

18
.env.example Normal file
View File

@ -0,0 +1,18 @@
# Database
DATABASE_URL=postgresql://rnotes:password@localhost:5432/rnotes
# NextAuth
NEXTAUTH_SECRET=your-secret-here
NEXTAUTH_URL=http://localhost:3000
# EncryptID
ENCRYPTID_SERVER_URL=https://auth.ridentity.online
NEXT_PUBLIC_ENCRYPTID_SERVER_URL=https://auth.ridentity.online
# Domain
ROOT_DOMAIN=localhost:3000
NEXT_PUBLIC_ROOT_DOMAIN=localhost:3000
# Yjs WebSocket sync server
SYNC_SERVER_PORT=4444
NEXT_PUBLIC_SYNC_URL=ws://localhost:4444

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
.next
.env
*.env.local
sync-server/dist

52
Dockerfile Normal file
View File

@ -0,0 +1,52 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy SDK from build context
COPY --from=sdk / /encryptid-sdk/
# Install dependencies
COPY package.json package-lock.json* ./
RUN npm install --legacy-peer-deps
# Copy source
COPY . .
# Generate Prisma client and build
RUN npx prisma generate
RUN npm run build
# ─── Production ───────────────────────────────────────────
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy Next.js standalone output
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 --from=builder /app/node_modules/yjs ./node_modules/yjs
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 Prisma
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
RUN chmod +x entrypoint.sh
USER nextjs
EXPOSE 3000 4444
ENTRYPOINT ["./entrypoint.sh"]
CMD ["node", "server.js"]

70
docker-compose.yml Normal file
View File

@ -0,0 +1,70 @@
services:
rnotes:
build:
context: .
additional_contexts:
sdk: ../encryptid-sdk
container_name: rnotes-frontend
restart: unless-stopped
ports:
- "3100:3000"
- "4444:4444"
environment:
- DATABASE_URL=postgresql://rnotes:${DB_PASSWORD}@rnotes-db:5432/rnotes
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- NEXTAUTH_URL=https://rnotes.online
- ENCRYPTID_SERVER_URL=https://auth.ridentity.online
- NEXT_PUBLIC_ENCRYPTID_SERVER_URL=https://auth.ridentity.online
- NEXT_PUBLIC_SYNC_URL=wss://rnotes.online/sync
- SYNC_SERVER_PORT=4444
depends_on:
rnotes-db:
condition: service_healthy
networks:
- traefik-public
- rnotes-internal
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.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.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"
security_opt:
- no-new-privileges:true
rnotes-db:
image: postgres:16-alpine
container_name: rnotes-db
restart: unless-stopped
environment:
- POSTGRES_DB=rnotes
- POSTGRES_USER=rnotes
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- rnotes-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U rnotes"]
interval: 10s
timeout: 5s
retries: 5
networks:
- rnotes-internal
security_opt:
- no-new-privileges:true
volumes:
rnotes-pgdata:
networks:
traefik-public:
external: true
rnotes-internal:
driver: bridge

11
entrypoint.sh Normal file
View File

@ -0,0 +1,11 @@
#!/bin/sh
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
# Start the Next.js server
exec "$@"

7
next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

69
package.json Normal file
View File

@ -0,0 +1,69 @@
{
"name": "rnotes-online",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "concurrently \"next dev -p 3000\" \"node sync-server/dist/index.js\"",
"dev:next": "next dev -p 3000",
"dev:sync": "npx tsx sync-server/src/index.ts",
"build": "npx prisma generate && next build && npx tsc -p sync-server/tsconfig.json",
"start": "node .next/standalone/server.js",
"db:push": "npx prisma db push",
"db:migrate": "npx prisma migrate dev"
},
"dependencies": {
"@encryptid/sdk": "file:../encryptid-sdk",
"@prisma/client": "^6.19.2",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",
"@tiptap/core": "^2.11.0",
"@tiptap/extension-collaboration": "^2.11.0",
"@tiptap/extension-collaboration-cursor": "^2.11.0",
"@tiptap/extension-color": "^2.11.0",
"@tiptap/extension-highlight": "^2.11.0",
"@tiptap/extension-image": "^2.11.0",
"@tiptap/extension-link": "^2.11.0",
"@tiptap/extension-placeholder": "^2.11.0",
"@tiptap/extension-task-item": "^2.11.0",
"@tiptap/extension-task-list": "^2.11.0",
"@tiptap/extension-text-style": "^2.11.0",
"@tiptap/extension-underline": "^2.11.0",
"@tiptap/pm": "^2.11.0",
"@tiptap/react": "^2.11.0",
"@tiptap/starter-kit": "^2.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"concurrently": "^9.1.2",
"date-fns": "^4.1.0",
"lib0": "^0.2.99",
"lucide-react": "^0.468.0",
"next": "^15.3.0",
"next-auth": "^5.0.0-beta.30",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sonner": "^1.7.4",
"tailwind-merge": "^3.0.2",
"ws": "^8.18.0",
"y-prosemirror": "^1.2.15",
"y-protocols": "^1.0.6",
"y-websocket": "^2.1.0",
"yjs": "^13.6.22",
"zustand": "^5.0.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/ws": "^8.5.14",
"prisma": "^6.19.2",
"tailwindcss": "^4.0.0",
"tsx": "^4.19.4",
"typescript": "^5.7.0"
}
}

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

175
prisma/schema.prisma Normal file
View File

@ -0,0 +1,175 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── Auth ────────────────────────────────────────────────
model User {
id String @id @default(cuid())
did String? @unique
username String? @unique
name String?
email String? @unique
emailVerified DateTime?
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships SpaceMember[]
notebooks Notebook[]
comments Comment[]
suggestions Suggestion[]
reactions Reaction[]
}
// ─── Multi-tenant Spaces ─────────────────────────────────
enum SpaceRole {
ADMIN
MODERATOR
MEMBER
VIEWER
}
model Space {
id String @id @default(cuid())
slug String @unique
name String
description String @default("")
icon String @default("")
visibility String @default("public_read")
ownerDid String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members SpaceMember[]
notebooks Notebook[]
}
model SpaceMember {
id String @id @default(cuid())
userId String
spaceId String
role SpaceRole @default(MEMBER)
joinedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
@@unique([userId, spaceId])
}
// ─── Notebooks & Notes ───────────────────────────────────
model Notebook {
id String @id @default(cuid())
spaceId String
title String
description String @default("")
icon String @default("📓")
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
creator User @relation(fields: [createdBy], references: [id])
notes Note[]
@@index([spaceId])
}
model Note {
id String @id @default(cuid())
notebookId String
title String @default("Untitled")
yjsDocId String @unique @default(cuid())
sortOrder Int @default(0)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade)
comments Comment[]
suggestions Suggestion[]
@@index([notebookId])
}
// ─── Suggestions (Track Changes) ─────────────────────────
enum SuggestionStatus {
PENDING
ACCEPTED
REJECTED
}
enum SuggestionType {
INSERT
DELETE
FORMAT
REPLACE
}
model Suggestion {
id String @id @default(cuid())
noteId String
authorId String
type SuggestionType
status SuggestionStatus @default(PENDING)
fromPos Int
toPos Int
content String?
oldContent String?
attrs Json?
resolvedBy String?
resolvedAt DateTime?
createdAt DateTime @default(now())
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id])
@@index([noteId, status])
}
// ─── Comments & Threads ──────────────────────────────────
model Comment {
id String @id @default(cuid())
noteId String
authorId String
parentId String?
body String
fromPos Int
toPos Int
resolved Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id])
parent Comment? @relation("CommentThread", fields: [parentId], references: [id])
replies Comment[] @relation("CommentThread")
reactions Reaction[]
@@index([noteId])
}
// ─── Emoji Reactions ─────────────────────────────────────
model Reaction {
id String @id @default(cuid())
commentId String
userId String
emoji String
createdAt DateTime @default(now())
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
@@unique([commentId, userId, emoji])
}

22
src/app/api/me/route.ts Normal file
View File

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyEncryptIDToken, extractToken } from '@/lib/encryptid';
export async function GET(req: NextRequest) {
const token = extractToken(req.headers, req.cookies);
if (!token) {
return NextResponse.json({ authenticated: false });
}
const claims = await verifyEncryptIDToken(token);
if (!claims) {
return NextResponse.json({ authenticated: false });
}
return NextResponse.json({
authenticated: true,
user: {
username: claims.username || null,
did: claims.did,
},
});
}

View File

@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { verifyEncryptIDToken, extractToken } from '@/lib/encryptid';
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ notebookId: string }> }
) {
const { notebookId } = await params;
const token = extractToken(req.headers, req.cookies);
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const claims = await verifyEncryptIDToken(token);
if (!claims) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
let user = await prisma.user.findUnique({ where: { did: claims.did } });
if (!user) {
user = await prisma.user.create({
data: {
did: claims.did,
username: claims.username,
name: claims.username,
},
});
}
const body = await req.json();
// Get next sort order
const lastNote = await prisma.note.findFirst({
where: { notebookId },
orderBy: { sortOrder: 'desc' },
});
const note = await prisma.note.create({
data: {
title: body.title || 'Untitled',
notebookId,
createdBy: user.id,
sortOrder: (lastNote?.sortOrder || 0) + 1,
},
});
return NextResponse.json({
id: note.id,
title: note.title,
yjsDocId: note.yjsDocId,
});
}

View File

@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ notebookId: string }> }
) {
const { notebookId } = await params;
const notebook = await prisma.notebook.findUnique({
where: { id: notebookId },
include: {
notes: {
select: { id: true, title: true, updatedAt: true },
orderBy: { sortOrder: 'asc' },
},
},
});
if (!notebook) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json({
id: notebook.id,
title: notebook.title,
icon: notebook.icon,
description: notebook.description,
notes: notebook.notes.map((n) => ({
id: n.id,
title: n.title,
updatedAt: n.updatedAt.toISOString(),
})),
});
}

View File

@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ noteId: string }> }
) {
const { noteId } = await params;
const note = await prisma.note.findUnique({
where: { id: noteId },
include: {
notebook: { select: { id: true, title: true, space: { select: { slug: true } } } },
},
});
if (!note) {
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
}
return NextResponse.json({
id: note.id,
title: note.title,
yjsDocId: note.yjsDocId,
notebookId: note.notebook.id,
notebookTitle: note.notebook.title,
spaceSlug: note.notebook.space.slug,
});
}
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ noteId: string }> }
) {
const { noteId } = await params;
const body = await req.json();
const note = await prisma.note.update({
where: { id: noteId },
data: {
title: body.title,
updatedAt: new Date(),
},
});
return NextResponse.json({ id: note.id, title: note.title });
}

View File

@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { verifyEncryptIDToken, extractToken } from '@/lib/encryptid';
export async function GET(req: NextRequest) {
const token = extractToken(req.headers, req.cookies);
let userId: string | null = null;
if (token) {
const claims = await verifyEncryptIDToken(token);
if (claims) {
const user = await prisma.user.findUnique({ where: { did: claims.did } });
userId = user?.id || null;
}
}
// Return notebooks the user has access to
const notebooks = await prisma.notebook.findMany({
include: {
_count: { select: { notes: true } },
space: { select: { slug: true } },
},
orderBy: { updatedAt: 'desc' },
});
return NextResponse.json({
notebooks: notebooks.map((nb) => ({
id: nb.id,
title: nb.title,
description: nb.description,
icon: nb.icon,
noteCount: nb._count.notes,
collaborators: 0,
updatedAt: nb.updatedAt.toISOString(),
spaceSlug: nb.space.slug,
})),
});
}
export async function POST(req: NextRequest) {
const token = extractToken(req.headers, req.cookies);
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const claims = await verifyEncryptIDToken(token);
if (!claims) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Ensure user exists
let user = await prisma.user.findUnique({ where: { did: claims.did } });
if (!user) {
user = await prisma.user.create({
data: {
did: claims.did,
username: claims.username,
name: claims.username,
},
});
}
// Ensure default space exists
let space = await prisma.space.findUnique({ where: { slug: 'default' } });
if (!space) {
space = await prisma.space.create({
data: {
slug: 'default',
name: 'rNotes',
ownerDid: claims.did,
},
});
}
const body = await req.json();
const notebook = await prisma.notebook.create({
data: {
title: body.title || 'Untitled Notebook',
description: body.description || '',
icon: body.icon || '📓',
spaceId: space.id,
createdBy: user.id,
},
include: {
_count: { select: { notes: true } },
space: { select: { slug: true } },
},
});
// Create a first note in the notebook
await prisma.note.create({
data: {
title: 'Getting Started',
notebookId: notebook.id,
createdBy: user.id,
},
});
return NextResponse.json({
id: notebook.id,
title: notebook.title,
description: notebook.description,
icon: notebook.icon,
noteCount: 1,
collaborators: 0,
updatedAt: notebook.updatedAt.toISOString(),
spaceSlug: notebook.space.slug,
});
}

View File

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
const RSPACE_API = process.env.RSPACE_API_URL || 'https://rspace.online/api';
export async function GET(req: NextRequest) {
const token =
req.headers.get('Authorization')?.replace('Bearer ', '') ||
req.cookies.get('encryptid_token')?.value;
try {
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${RSPACE_API}/spaces`, {
headers,
next: { revalidate: 30 },
});
if (res.ok) {
const data = await res.json();
return NextResponse.json(data);
}
} catch {
// rSpace unavailable
}
return NextResponse.json({ spaces: [] });
}

125
src/app/dashboard/page.tsx Normal file
View File

@ -0,0 +1,125 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Header } from '@/components/Header';
import { Plus, BookOpen, Clock, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Notebook {
id: string;
title: string;
description: string;
icon: string;
noteCount: number;
collaborators: number;
updatedAt: string;
spaceSlug: string;
}
export default function DashboardPage() {
const [notebooks, setNotebooks] = useState<Notebook[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/notebooks')
.then((r) => r.json())
.then((data) => setNotebooks(data.notebooks || []))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleCreate = async () => {
const title = window.prompt('Notebook name:');
if (!title?.trim()) return;
const res = await fetch('/api/notebooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title.trim() }),
});
if (res.ok) {
const nb = await res.json();
setNotebooks((prev) => [nb, ...prev]);
}
};
return (
<div className="min-h-screen flex flex-col">
<Header
breadcrumbs={[{ label: 'Notebooks' }]}
actions={
<button
onClick={handleCreate}
className="flex items-center gap-1.5 px-3 py-1.5 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus size={16} /> New Notebook
</button>
}
/>
<main className="flex-1 max-w-5xl mx-auto w-full px-6 py-8">
{loading ? (
<div className="text-center text-slate-400 py-20">Loading notebooks...</div>
) : notebooks.length === 0 ? (
<div className="text-center py-20">
<div className="text-5xl mb-4">📓</div>
<h2 className="text-xl font-semibold mb-2">No notebooks yet</h2>
<p className="text-slate-400 mb-6">Create your first notebook to start writing collaboratively.</p>
<button
onClick={handleCreate}
className="px-5 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
>
Create Notebook
</button>
</div>
) : (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{notebooks.map((nb) => (
<Link
key={nb.id}
href={`/s/${nb.spaceSlug || 'default'}/notebook/${nb.id}`}
className="group block p-5 rounded-xl bg-card border border-slate-800 hover:border-primary/30 hover:bg-card/80 transition-all no-underline"
>
<div className="flex items-start justify-between mb-3">
<span className="text-2xl">{nb.icon}</span>
<span className="text-[10px] text-slate-500 bg-slate-800 px-2 py-0.5 rounded-full">
{nb.noteCount} {nb.noteCount === 1 ? 'note' : 'notes'}
</span>
</div>
<h3 className="font-semibold text-slate-200 group-hover:text-white transition-colors mb-1">
{nb.title}
</h3>
{nb.description && (
<p className="text-sm text-slate-400 line-clamp-2 mb-3">{nb.description}</p>
)}
<div className="flex items-center gap-3 text-[11px] text-slate-500">
<span className="flex items-center gap-1">
<Clock size={11} />
{new Date(nb.updatedAt).toLocaleDateString()}
</span>
{nb.collaborators > 0 && (
<span className="flex items-center gap-1">
<Users size={11} />
{nb.collaborators}
</span>
)}
</div>
</Link>
))}
{/* Add button card */}
<button
onClick={handleCreate}
className="flex flex-col items-center justify-center p-5 rounded-xl border border-dashed border-slate-700 hover:border-primary/40 hover:bg-primary/[0.03] transition-all min-h-[140px]"
>
<Plus size={24} className="text-slate-500 mb-2" />
<span className="text-sm text-slate-400">New Notebook</span>
</button>
</div>
)}
</main>
</div>
);
}

182
src/app/globals.css Normal file
View File

@ -0,0 +1,182 @@
@import "tailwindcss";
@theme {
--color-background: oklch(0.145 0.015 285);
--color-foreground: oklch(0.95 0.01 285);
--color-card: oklch(0.18 0.015 285);
--color-card-foreground: oklch(0.95 0.01 285);
--color-popover: oklch(0.18 0.015 285);
--color-popover-foreground: oklch(0.95 0.01 285);
--color-primary: oklch(0.72 0.14 195);
--color-primary-foreground: oklch(0.15 0.02 285);
--color-secondary: oklch(0.25 0.015 285);
--color-secondary-foreground: oklch(0.9 0.01 285);
--color-muted: oklch(0.22 0.015 285);
--color-muted-foreground: oklch(0.65 0.015 285);
--color-accent: oklch(0.25 0.015 285);
--color-accent-foreground: oklch(0.9 0.01 285);
--color-destructive: oklch(0.6 0.2 25);
--color-destructive-foreground: oklch(0.98 0.01 285);
--color-border: oklch(0.28 0.015 285);
--color-input: oklch(0.28 0.015 285);
--color-ring: oklch(0.72 0.14 195);
--color-suggestion-insert: oklch(0.55 0.15 155);
--color-suggestion-delete: oklch(0.55 0.15 25);
--color-comment-highlight: oklch(0.65 0.15 85);
--radius: 0.625rem;
}
* {
border-color: var(--color-border);
}
body {
background: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-geist-sans), system-ui, sans-serif;
}
/* ─── TipTap Editor ───────────────────────────────────── */
.tiptap {
outline: none;
min-height: 60vh;
padding: 2rem 0;
}
.tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--color-muted-foreground);
pointer-events: none;
height: 0;
}
.tiptap h1 { font-size: 2em; font-weight: 700; margin: 1em 0 0.4em; line-height: 1.2; }
.tiptap h2 { font-size: 1.5em; font-weight: 600; margin: 0.8em 0 0.3em; line-height: 1.3; }
.tiptap h3 { font-size: 1.25em; font-weight: 600; margin: 0.6em 0 0.2em; line-height: 1.4; }
.tiptap p { margin: 0.5em 0; line-height: 1.7; }
.tiptap ul,
.tiptap ol { padding-left: 1.5em; margin: 0.5em 0; }
.tiptap li { margin: 0.25em 0; }
.tiptap ul { list-style-type: disc; }
.tiptap ol { list-style-type: decimal; }
.tiptap blockquote {
border-left: 3px solid var(--color-primary);
padding-left: 1em;
margin: 0.5em 0;
color: var(--color-muted-foreground);
}
.tiptap pre {
background: oklch(0.12 0.015 285);
border-radius: var(--radius);
padding: 0.75em 1em;
margin: 0.5em 0;
overflow-x: auto;
}
.tiptap code {
background: oklch(0.22 0.015 285);
border-radius: 0.25em;
padding: 0.15em 0.4em;
font-size: 0.9em;
font-family: var(--font-geist-mono), monospace;
}
.tiptap pre code {
background: none;
padding: 0;
border-radius: 0;
}
.tiptap hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 1.5em 0;
}
.tiptap img {
max-width: 100%;
border-radius: var(--radius);
margin: 0.5em 0;
}
.tiptap a {
color: var(--color-primary);
text-decoration: underline;
text-underline-offset: 2px;
}
/* Task lists */
.tiptap ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
}
.tiptap ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
gap: 0.5em;
}
.tiptap ul[data-type="taskList"] li > label {
flex-shrink: 0;
margin-top: 0.25em;
}
/* ─── Suggestion marks ────────────────────────────────── */
.suggestion-insert {
background: oklch(0.55 0.15 155 / 0.2);
border-bottom: 2px solid var(--color-suggestion-insert);
cursor: pointer;
}
.suggestion-delete {
background: oklch(0.55 0.15 25 / 0.2);
text-decoration: line-through;
border-bottom: 2px solid var(--color-suggestion-delete);
cursor: pointer;
}
/* ─── Comment highlights ──────────────────────────────── */
.comment-highlight {
background: oklch(0.65 0.15 85 / 0.15);
border-bottom: 2px solid var(--color-comment-highlight);
cursor: pointer;
}
.comment-highlight.active {
background: oklch(0.65 0.15 85 / 0.3);
}
/* ─── Collaboration cursors ───────────────────────────── */
.collaboration-cursor__caret {
border-left: 2px solid;
border-right: none;
margin-left: -1px;
margin-right: -1px;
pointer-events: none;
position: relative;
word-break: normal;
}
.collaboration-cursor__label {
border-radius: 3px 3px 3px 0;
font-size: 11px;
font-weight: 600;
left: -1px;
line-height: 1;
padding: 2px 6px;
position: absolute;
bottom: 100%;
white-space: nowrap;
pointer-events: none;
user-select: none;
}

22
src/app/layout.tsx Normal file
View File

@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
export const metadata: Metadata = {
title: "rNotes — Collaborative Notes",
description: "Real-time collaborative note-taking with suggestions, comments, and approvals",
icons: { icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><text y='28' font-size='28'>📝</text></svg>" },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{children}
</body>
</html>
);
}

75
src/app/page.tsx Normal file
View File

@ -0,0 +1,75 @@
import Link from 'next/link';
import { Header } from '@/components/Header';
export default function LandingPage() {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
{/* Hero */}
<section className="max-w-4xl mx-auto px-6 pt-20 pb-16 text-center">
<div className="inline-flex items-center gap-2 px-3 py-1 mb-6 rounded-full bg-primary/10 border border-primary/20 text-primary text-sm">
<span>📝</span> Real-time collaborative notes
</div>
<h1 className="text-4xl sm:text-5xl font-bold leading-tight mb-4">
Write together.{' '}
<span className="bg-gradient-to-r from-primary to-cyan-300 bg-clip-text text-transparent">
Think together.
</span>
</h1>
<p className="text-lg text-slate-400 max-w-2xl mx-auto mb-8">
Google Docs-style collaborative editing with suggestions, comments,
approvals, and emoji reactions all self-hosted and community-owned.
</p>
<div className="flex items-center justify-center gap-3">
<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>
</div>
</section>
{/* Features */}
<section className="max-w-5xl mx-auto px-6 pb-20">
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[
{ icon: '✍️', title: 'Real-time Co-editing', desc: 'See cursors, selections, and changes from collaborators instantly via CRDT sync' },
{ icon: '💡', title: 'Suggestions Mode', desc: 'Propose edits without changing the document — authors review, accept, or reject' },
{ icon: '💬', title: 'Inline Comments', desc: 'Select text and start threaded discussions right where they matter' },
{ icon: '🎉', title: 'Emoji Reactions', desc: 'React to comments with emojis — quick feedback without cluttering the thread' },
{ icon: '✅', title: 'Approvals', desc: 'Accept or reject suggestions individually or in bulk with one click' },
{ icon: '📚', title: 'Notebooks', desc: 'Organize notes into notebooks within your space — keep everything structured' },
{ icon: '🔐', title: 'Passkey Auth', desc: 'Sign in with WebAuthn passkeys via EncryptID — no passwords, no tracking' },
{ icon: '🔌', title: 'Offline-First', desc: 'Keep writing when disconnected — changes sync automatically when back online' },
{ icon: '📦', title: 'rStack Ecosystem', desc: 'Part of the self-hosted community app suite — integrates with all rApps' },
].map((f) => (
<div key={f.title} className="p-5 rounded-xl bg-card border border-slate-800 hover:border-slate-700 transition-colors">
<div className="text-2xl mb-2">{f.icon}</div>
<h3 className="font-semibold mb-1">{f.title}</h3>
<p className="text-sm text-slate-400">{f.desc}</p>
</div>
))}
</div>
</section>
</main>
<footer className="border-t border-slate-800 py-6 text-center text-xs text-slate-500">
<a href="https://rstack.online" className="hover:text-primary transition-colors">
rstack.online self-hosted, community-run
</a>
</footer>
</div>
);
}

View File

@ -0,0 +1,109 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { Header } from '@/components/Header';
import { CollaborativeEditor } from '@/components/editor/CollaborativeEditor';
const SYNC_URL = process.env.NEXT_PUBLIC_SYNC_URL || 'ws://localhost:4444';
interface NoteData {
id: string;
title: string;
yjsDocId: string;
notebookId: string;
notebookTitle: string;
}
interface UserInfo {
id: string;
username: string;
}
export default function NotePage() {
const params = useParams<{ slug: string; noteId: string }>();
const [note, setNote] = useState<NoteData | null>(null);
const [user, setUser] = useState<UserInfo | null>(null);
const [title, setTitle] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
// Load note data
fetch(`/api/notebooks/notes/${params.noteId}`)
.then((r) => r.json())
.then((data) => {
setNote(data);
setTitle(data.title || 'Untitled');
})
.catch(() => {})
.finally(() => setLoading(false));
// Get current user
fetch('/api/me')
.then((r) => r.json())
.then((data) => {
if (data.authenticated && data.user) {
setUser({ id: data.user.did || data.user.username, username: data.user.username });
}
})
.catch(() => {});
}, [params.noteId]);
// Auto-save title changes
useEffect(() => {
if (!note || !title || title === note.title) return;
const timeout = setTimeout(() => {
fetch(`/api/notebooks/notes/${params.noteId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
}).catch(() => {});
}, 1000);
return () => clearTimeout(timeout);
}, [title, note, params.noteId]);
if (loading) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<div className="flex-1 flex items-center justify-center text-slate-400">
Loading note...
</div>
</div>
);
}
if (!note) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<div className="flex-1 flex items-center justify-center text-slate-400">
Note not found
</div>
</div>
);
}
// Guest mode for unauthenticated users
const userId = user?.id || `guest-${Math.random().toString(36).slice(2, 8)}`;
const userName = user?.username || 'Guest';
return (
<div className="min-h-screen flex flex-col">
<Header
breadcrumbs={[
{ label: note.notebookTitle, href: `/s/${params.slug}/notebook/${note.notebookId}` },
{ label: title },
]}
/>
<CollaborativeEditor
noteId={note.id}
yjsDocId={note.yjsDocId}
userId={userId}
userName={userName}
syncUrl={SYNC_URL}
onTitleChange={setTitle}
/>
</div>
);
}

View File

@ -0,0 +1,129 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { Header } from '@/components/Header';
import { Plus, FileText, Clock, Trash2 } from 'lucide-react';
interface NoteItem {
id: string;
title: string;
updatedAt: string;
}
interface NotebookData {
id: string;
title: string;
icon: string;
description: string;
notes: NoteItem[];
}
export default function NotebookPage() {
const params = useParams<{ slug: string; notebookId: string }>();
const router = useRouter();
const [notebook, setNotebook] = useState<NotebookData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/notebooks/${params.notebookId}`)
.then((r) => r.json())
.then(setNotebook)
.catch(() => {})
.finally(() => setLoading(false));
}, [params.notebookId]);
const handleCreateNote = async () => {
const res = await fetch(`/api/notebooks/${params.notebookId}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Untitled' }),
});
if (res.ok) {
const note = await res.json();
router.push(`/s/${params.slug}/n/${note.id}`);
}
};
if (loading) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<div className="flex-1 flex items-center justify-center text-slate-400">Loading...</div>
</div>
);
}
if (!notebook) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<div className="flex-1 flex items-center justify-center text-slate-400">Notebook not found</div>
</div>
);
}
return (
<div className="min-h-screen flex flex-col">
<Header
breadcrumbs={[
{ label: 'Notebooks', href: '/dashboard' },
{ label: notebook.title },
]}
actions={
<button
onClick={handleCreateNote}
className="flex items-center gap-1.5 px-3 py-1.5 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus size={16} /> New Note
</button>
}
/>
<main className="flex-1 max-w-4xl mx-auto w-full px-6 py-8">
<div className="flex items-center gap-3 mb-6">
<span className="text-3xl">{notebook.icon}</span>
<div>
<h1 className="text-xl font-bold">{notebook.title}</h1>
{notebook.description && (
<p className="text-sm text-slate-400">{notebook.description}</p>
)}
</div>
</div>
{notebook.notes.length === 0 ? (
<div className="text-center py-16">
<div className="text-4xl mb-3">📄</div>
<p className="text-slate-400 mb-4">No notes yet</p>
<button
onClick={handleCreateNote}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
>
Create First Note
</button>
</div>
) : (
<div className="space-y-1">
{notebook.notes.map((note) => (
<Link
key={note.id}
href={`/s/${params.slug}/n/${note.id}`}
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-white/[0.03] transition-colors group no-underline"
>
<FileText size={18} className="text-slate-500 flex-shrink-0" />
<span className="flex-1 text-sm font-medium text-slate-200 group-hover:text-white transition-colors">
{note.title}
</span>
<span className="text-[11px] text-slate-500 flex items-center gap-1">
<Clock size={11} />
{new Date(note.updatedAt).toLocaleDateString()}
</span>
</Link>
))}
</div>
)}
</main>
</div>
);
}

View File

@ -0,0 +1,267 @@
'use client';
import { useState, useRef, useEffect } from 'react';
export interface AppModule {
id: string;
name: string;
badge: string; // favicon-style abbreviation: rS, rN, rP, etc.
color: string; // Tailwind bg class for the pastel badge
emoji: string; // function emoji shown right of title
description: string;
domain?: string;
}
const MODULES: AppModule[] = [
// Creating
{ id: 'space', name: 'rSpace', badge: 'rS', color: 'bg-teal-300', emoji: '🎨', description: 'Real-time collaborative canvas', domain: 'rspace.online' },
{ id: 'notes', name: 'rNotes', badge: 'rN', color: 'bg-amber-300', emoji: '📝', description: 'Group note-taking & knowledge capture', domain: 'rnotes.online' },
{ id: 'pubs', name: 'rPubs', badge: 'rP', color: 'bg-rose-300', emoji: '📖', description: 'Collaborative publishing platform', domain: 'rpubs.online' },
{ id: 'tube', name: 'rTube', badge: 'rTu', color: 'bg-pink-300', emoji: '🎬', description: 'Community video platform', domain: 'rtube.online' },
{ id: 'swag', name: 'rSwag', badge: 'rSw', color: 'bg-red-200', emoji: '👕', description: 'Community merch & swag store', domain: 'rswag.online' },
// Planning
{ id: 'cal', name: 'rCal', badge: 'rC', color: 'bg-sky-300', emoji: '📅', description: 'Collaborative scheduling & events', domain: 'rcal.online' },
{ id: 'events', name: 'rEvents', badge: 'rEv', color: 'bg-violet-200', emoji: '🎪', description: 'Event aggregation & discovery', domain: 'revents.online' },
{ id: 'trips', name: 'rTrips', badge: 'rT', color: 'bg-emerald-300', emoji: '✈️', description: 'Group travel planning in real time', domain: 'rtrips.online' },
{ id: 'maps', name: 'rMaps', badge: 'rM', color: 'bg-green-300', emoji: '🗺️', description: 'Collaborative real-time mapping', domain: 'rmaps.online' },
// Communicating
{ id: 'chats', name: 'rChats', badge: 'rCh', color: 'bg-emerald-200', emoji: '💬', description: 'Real-time encrypted messaging', domain: 'rchats.online' },
{ id: 'inbox', name: 'rInbox', badge: 'rI', color: 'bg-indigo-300', emoji: '📬', description: 'Private group messaging', domain: 'rinbox.online' },
{ id: 'mail', name: 'rMail', badge: 'rMa', color: 'bg-blue-200', emoji: '✉️', description: 'Community email & newsletters', domain: 'rmail.online' },
{ id: 'forum', name: 'rForum', badge: 'rFo', color: 'bg-amber-200', emoji: '💭', description: 'Threaded community discussions', domain: 'rforum.online' },
// Deciding
{ id: 'choices', name: 'rChoices', badge: 'rCo', color: 'bg-fuchsia-300', emoji: '⚖️', description: 'Collaborative decision making', domain: 'rchoices.online' },
{ id: 'vote', name: 'rVote', badge: 'rV', color: 'bg-violet-300', emoji: '🗳️', description: 'Real-time polls & governance', domain: 'rvote.online' },
// Funding & Commerce
{ id: 'funds', name: 'rFunds', badge: 'rF', color: 'bg-lime-300', emoji: '💸', description: 'Collaborative fundraising & grants', domain: 'rfunds.online' },
{ id: 'wallet', name: 'rWallet', badge: 'rW', color: 'bg-yellow-300', emoji: '💰', description: 'Multi-chain crypto wallet', domain: 'rwallet.online' },
{ id: 'cart', name: 'rCart', badge: 'rCt', color: 'bg-orange-300', emoji: '🛒', description: 'Group commerce & shared shopping', domain: 'rcart.online' },
{ id: 'auctions', name: 'rAuctions', badge: 'rA', color: 'bg-red-300', emoji: '🔨', description: 'Live auction platform', domain: 'rauctions.online' },
// Sharing
{ id: 'photos', name: 'rPhotos', badge: 'rPh', color: 'bg-pink-200', emoji: '📸', description: 'Community photo commons', domain: 'rphotos.online' },
{ id: 'network', name: 'rNetwork', badge: 'rNe', color: 'bg-blue-300', emoji: '🕸️', description: 'Community network & social graph', domain: 'rnetwork.online' },
{ id: 'files', name: 'rFiles', badge: 'rFi', color: 'bg-cyan-300', emoji: '📁', description: 'Collaborative file storage', domain: 'rfiles.online' },
{ id: 'socials', name: 'rSocials', badge: 'rSo', color: 'bg-sky-200', emoji: '📢', description: 'Social media management', domain: 'rsocials.online' },
// Observing
{ id: 'data', name: 'rData', badge: 'rD', color: 'bg-purple-300', emoji: '📊', description: 'Analytics & insights dashboard', domain: 'rdata.online' },
// Learning
{ id: 'books', name: 'rBooks', badge: 'rB', color: 'bg-amber-200', emoji: '📚', description: 'Collaborative library', domain: 'rbooks.online' },
// Work & Productivity
{ id: 'work', name: 'rWork', badge: 'rWo', color: 'bg-slate-300', emoji: '📋', description: 'Project & task management', domain: 'rwork.online' },
// Identity & Infrastructure
{ id: 'ids', name: 'rIDs', badge: 'rId', color: 'bg-emerald-300', emoji: '🔐', description: 'Passkey identity & zero-knowledge auth', domain: 'ridentity.online' },
{ id: 'stack', name: 'rStack', badge: 'r*', color: 'bg-gradient-to-br from-cyan-300 via-violet-300 to-rose-300', emoji: '📦', description: 'Open-source community infrastructure', domain: 'rstack.online' },
];
const MODULE_CATEGORIES: Record<string, string> = {
space: 'Creating',
notes: 'Creating',
pubs: 'Creating',
tube: 'Creating',
swag: 'Creating',
cal: 'Planning',
events: 'Planning',
trips: 'Planning',
maps: 'Planning',
chats: 'Communicating',
inbox: 'Communicating',
mail: 'Communicating',
forum: 'Communicating',
choices: 'Deciding',
vote: 'Deciding',
funds: 'Funding & Commerce',
wallet: 'Funding & Commerce',
cart: 'Funding & Commerce',
auctions: 'Funding & Commerce',
photos: 'Sharing',
network: 'Sharing',
files: 'Sharing',
socials: 'Sharing',
data: 'Observing',
books: 'Learning',
work: 'Work & Productivity',
ids: 'Identity & Infrastructure',
stack: 'Identity & Infrastructure',
};
const CATEGORY_ORDER = [
'Creating',
'Planning',
'Communicating',
'Deciding',
'Funding & Commerce',
'Sharing',
'Observing',
'Learning',
'Work & Productivity',
'Identity & Infrastructure',
];
/** Read the username from the EncryptID session in localStorage */
function getSessionUsername(): string | null {
if (typeof window === 'undefined') return null;
try {
const stored = localStorage.getItem('encryptid_session');
if (!stored) return null;
const parsed = JSON.parse(stored);
const claims = parsed?.claims || parsed;
return claims?.eid?.username || claims?.username || null;
} catch {
return null;
}
}
/** Build the URL for a module, using username subdomain if logged in */
function getModuleUrl(m: AppModule, username: string | null): string {
if (!m.domain) return '#';
if (username) {
// Generate <username>.<domain> URL
return `https://${username}.${m.domain}`;
}
return `https://${m.domain}`;
}
interface AppSwitcherProps {
current?: string;
}
export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) {
const [open, setOpen] = useState(false);
const [username, setUsername] = useState<string | null>(null);
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);
}, []);
// Read username from EncryptID session in localStorage
useEffect(() => {
const sessionUsername = getSessionUsername();
if (sessionUsername) {
setUsername(sessionUsername);
} else {
// Fallback: check /api/me
fetch('/api/me')
.then((r) => r.json())
.then((data) => {
if (data.authenticated && data.user?.username) {
setUsername(data.user.username);
}
})
.catch(() => {});
}
}, []);
const currentMod = MODULES.find((m) => m.id === current);
// Group modules by category
const groups = new Map<string, AppModule[]>();
for (const m of MODULES) {
const cat = MODULE_CATEGORIES[m.id] || 'Other';
if (!groups.has(cat)) groups.set(cat, []);
groups.get(cat)!.push(m);
}
return (
<div className="relative" ref={ref}>
{/* Trigger button */}
<button
onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm font-semibold bg-white/[0.08] hover:bg-white/[0.12] text-slate-200 transition-colors"
>
{currentMod && (
<span className={`w-6 h-6 rounded-md ${currentMod.color} flex items-center justify-center text-[10px] font-black text-slate-900 leading-none flex-shrink-0`}>
{currentMod.badge}
</span>
)}
<span>{currentMod?.name || 'rStack'}</span>
<span className="text-[0.7em] opacity-60">&#9662;</span>
</button>
{/* Dropdown */}
{open && (
<div className="absolute top-full left-0 mt-1.5 w-[300px] max-h-[70vh] overflow-y-auto rounded-xl bg-slate-800 border border-white/10 shadow-xl shadow-black/30 z-[200]">
{/* rStack header */}
<div className="px-3.5 py-3 border-b border-white/[0.08] flex items-center gap-2.5">
<span className="w-7 h-7 rounded-lg bg-gradient-to-br from-cyan-300 via-violet-300 to-rose-300 flex items-center justify-center text-[11px] font-black text-slate-900 leading-none">
r*
</span>
<div>
<div className="text-sm font-bold text-white">rStack</div>
<div className="text-[10px] text-slate-400">Self-hosted community app suite</div>
</div>
</div>
{/* Categories */}
{CATEGORY_ORDER.map((cat) => {
const items = groups.get(cat);
if (!items || items.length === 0) return null;
return (
<div key={cat}>
<div className="px-3.5 pt-3 pb-1 text-[0.6rem] font-bold uppercase tracking-widest text-slate-500 select-none">
{cat}
</div>
{items.map((m) => (
<div
key={m.id}
className={`flex items-center group ${
m.id === current ? 'bg-white/[0.07]' : 'hover:bg-white/[0.04]'
} transition-colors`}
>
<a
href={getModuleUrl(m, username)}
className="flex items-center gap-2.5 flex-1 px-3.5 py-2 text-slate-200 no-underline min-w-0"
onClick={() => setOpen(false)}
>
{/* Pastel favicon badge */}
<span className={`w-7 h-7 rounded-md ${m.color} flex items-center justify-center text-[10px] font-black text-slate-900 leading-none flex-shrink-0`}>
{m.badge}
</span>
{/* Name + description */}
<div className="flex flex-col min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold">{m.name}</span>
<span className="text-sm flex-shrink-0">{m.emoji}</span>
</div>
<span className="text-[11px] text-slate-400 truncate">{m.description}</span>
</div>
</a>
{m.domain && (
<a
href={`https://${m.domain}`}
target="_blank"
rel="noopener noreferrer"
className="w-8 flex items-center justify-center text-xs text-cyan-400 opacity-0 group-hover:opacity-50 hover:!opacity-100 transition-opacity flex-shrink-0"
title={m.domain}
onClick={(e) => e.stopPropagation()}
>
&#8599;
</a>
)}
</div>
))}
</div>
);
})}
{/* Footer */}
<div className="px-3.5 py-2.5 border-t border-white/[0.08] text-center">
<a
href="https://rstack.online"
className="text-[11px] text-slate-500 hover:text-cyan-400 transition-colors no-underline"
onClick={() => setOpen(false)}
>
rstack.online self-hosted, community-run
</a>
</div>
</div>
)}
</div>
);
}

47
src/components/Header.tsx Normal file
View File

@ -0,0 +1,47 @@
'use client';
import Link from 'next/link';
import { AppSwitcher } from '@/components/AppSwitcher';
import { SpaceSwitcher } from '@/components/SpaceSwitcher';
interface BreadcrumbItem {
label: string;
href?: string;
}
interface HeaderProps {
breadcrumbs?: BreadcrumbItem[];
actions?: React.ReactNode;
}
export function Header({ breadcrumbs, actions }: HeaderProps) {
return (
<header className="border-b border-slate-800 sticky top-0 z-50 bg-background/90 backdrop-blur-sm">
<div className="max-w-6xl mx-auto px-4 py-2.5 flex items-center justify-between gap-3">
<div className="flex items-center gap-1 min-w-0">
<AppSwitcher current="notes" />
<SpaceSwitcher />
{breadcrumbs && breadcrumbs.length > 0 && (
<div className="flex items-center gap-1 text-sm text-slate-400 ml-1 min-w-0">
{breadcrumbs.map((b, i) => (
<span key={i} className="flex items-center gap-1 min-w-0">
<span className="opacity-40">/</span>
{b.href ? (
<Link href={b.href} className="hover:text-slate-200 transition-colors truncate max-w-[160px]">
{b.label}
</Link>
) : (
<span className="text-slate-300 truncate max-w-[160px]">{b.label}</span>
)}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-2">
{actions}
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,263 @@
'use client';
import { useState, useRef, useEffect } from 'react';
interface SpaceInfo {
slug: string;
name: string;
icon?: string;
role?: string;
}
interface SpaceSwitcherProps {
/** Current app domain, e.g. 'rcal.online'. Space links become <space>.<domain> */
domain?: string;
}
/** Read the EncryptID token from localStorage (set by token-relay across r*.online) */
function getEncryptIDToken(): string | null {
if (typeof window === 'undefined') return null;
try {
return localStorage.getItem('encryptid_token');
} catch {
return null;
}
}
/** Read the username from the EncryptID session in localStorage */
function getSessionUsername(): string | null {
if (typeof window === 'undefined') return null;
try {
const stored = localStorage.getItem('encryptid_session');
if (!stored) return null;
const parsed = JSON.parse(stored);
const claims = parsed?.claims || parsed;
return claims?.eid?.username || claims?.username || null;
} catch {
return null;
}
}
/** Read the current space_id from the cookie set by middleware */
function getCurrentSpaceId(): string {
if (typeof document === 'undefined') return 'default';
const match = document.cookie.match(/(?:^|;\s*)space_id=([^;]*)/);
return match ? decodeURIComponent(match[1]) : 'default';
}
export function SpaceSwitcher({ domain }: SpaceSwitcherProps) {
const [open, setOpen] = useState(false);
const [spaces, setSpaces] = useState<SpaceInfo[]>([]);
const [loaded, setLoaded] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [username, setUsername] = useState<string | null>(null);
const ref = useRef<HTMLDivElement>(null);
// Derive domain from window.location if not provided
const appDomain = domain || (typeof window !== 'undefined'
? window.location.hostname.split('.').slice(-2).join('.')
: 'rspace.online');
const currentSpaceId = getCurrentSpaceId();
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);
}, []);
// Check auth status on mount
useEffect(() => {
const token = getEncryptIDToken();
const sessionUsername = getSessionUsername();
if (token) {
setIsAuthenticated(true);
if (sessionUsername) {
setUsername(sessionUsername);
}
} else {
// Fallback: check /api/me
fetch('/api/me')
.then((r) => r.json())
.then((data) => {
if (data.authenticated) {
setIsAuthenticated(true);
if (data.user?.username) setUsername(data.user.username);
}
})
.catch(() => {});
}
}, []);
const loadSpaces = async () => {
if (loaded) return;
try {
// Pass EncryptID token so the proxy can forward it to rSpace
const token = getEncryptIDToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch('/api/spaces', { headers });
if (res.ok) {
const data = await res.json();
// Handle both flat array and { spaces: [...] } response formats
const raw: Array<{ id?: string; slug?: string; name: string; icon?: string; role?: string }> =
Array.isArray(data) ? data : (data.spaces || []);
setSpaces(raw.map((s) => ({
slug: s.slug || s.id || '',
name: s.name,
icon: s.icon,
role: s.role,
})));
}
} catch {
// API not available
}
setLoaded(true);
};
const handleOpen = async () => {
const nowOpen = !open;
setOpen(nowOpen);
if (nowOpen && !loaded) {
await loadSpaces();
}
};
/** Build URL for a space: <space>.<current-app-domain> */
const spaceUrl = (slug: string) => `https://${slug}.${appDomain}`;
// Build personal space entry for logged-in user
const personalSpace: SpaceInfo | null =
isAuthenticated && username
? { slug: username, name: 'Personal', icon: '👤', role: 'owner' }
: null;
// Deduplicate: remove personal space from fetched list if it already appears
const dedupedSpaces = personalSpace
? spaces.filter((s) => s.slug !== personalSpace.slug)
: spaces;
const mySpaces = dedupedSpaces.filter((s) => s.role);
const publicSpaces = dedupedSpaces.filter((s) => !s.role);
// Determine what to show in the button
const currentLabel = currentSpaceId === 'default'
? (personalSpace ? 'personal' : 'public')
: currentSpaceId;
return (
<div className="relative" ref={ref}>
<button
onClick={(e) => { e.stopPropagation(); handleOpen(); }}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm font-medium text-slate-400 hover:bg-white/[0.05] transition-colors"
>
<span className="opacity-40 font-light mr-0.5">/</span>
<span className="max-w-[160px] truncate">{currentLabel}</span>
<span className="text-[0.7em] opacity-50">&#9662;</span>
</button>
{open && (
<div className="absolute top-full left-0 mt-1.5 min-w-[240px] max-h-[400px] overflow-y-auto rounded-xl bg-slate-800 border border-white/10 shadow-xl shadow-black/30 z-[200]">
{!loaded ? (
<div className="px-4 py-4 text-center text-sm text-slate-400">Loading spaces...</div>
) : !isAuthenticated && spaces.length === 0 ? (
<>
<div className="px-4 py-4 text-center text-sm text-slate-400">
Sign in to see your spaces
</div>
</>
) : (
<>
{/* Personal space — always first when logged in */}
{personalSpace && (
<>
<div className="px-3.5 pt-2.5 pb-1 text-[0.65rem] font-bold uppercase tracking-wider text-slate-500 select-none">
Personal
</div>
<a
href={spaceUrl(personalSpace.slug)}
className={`flex items-center gap-2.5 px-3.5 py-2.5 text-slate-200 no-underline transition-colors hover:bg-white/[0.05] ${
currentSpaceId === personalSpace.slug ? 'bg-white/[0.07]' : ''
}`}
onClick={() => setOpen(false)}
>
<span className="text-base">{personalSpace.icon}</span>
<span className="text-sm font-medium flex-1">{username}</span>
<span className="text-[0.6rem] font-bold uppercase bg-cyan-500/15 text-cyan-300 px-1.5 py-0.5 rounded tracking-wide">
owner
</span>
</a>
</>
)}
{/* Other spaces the user belongs to */}
{mySpaces.length > 0 && (
<>
{personalSpace && <div className="h-px bg-white/[0.08] my-1" />}
<div className="px-3.5 pt-2.5 pb-1 text-[0.65rem] font-bold uppercase tracking-wider text-slate-500 select-none">
Your spaces
</div>
{mySpaces.map((s) => (
<a
key={s.slug}
href={spaceUrl(s.slug)}
className={`flex items-center gap-2.5 px-3.5 py-2.5 text-slate-200 no-underline transition-colors hover:bg-white/[0.05] ${
currentSpaceId === s.slug ? 'bg-white/[0.07]' : ''
}`}
onClick={() => setOpen(false)}
>
<span className="text-base">{s.icon || '🌐'}</span>
<span className="text-sm font-medium flex-1">{s.name}</span>
{s.role && (
<span className="text-[0.6rem] font-bold uppercase bg-cyan-500/15 text-cyan-300 px-1.5 py-0.5 rounded tracking-wide">
{s.role}
</span>
)}
</a>
))}
</>
)}
{/* Public spaces */}
{publicSpaces.length > 0 && (
<>
{(personalSpace || mySpaces.length > 0) && <div className="h-px bg-white/[0.08] my-1" />}
<div className="px-3.5 pt-2.5 pb-1 text-[0.65rem] font-bold uppercase tracking-wider text-slate-500 select-none">
Public spaces
</div>
{publicSpaces.map((s) => (
<a
key={s.slug}
href={spaceUrl(s.slug)}
className={`flex items-center gap-2.5 px-3.5 py-2.5 text-slate-200 no-underline transition-colors hover:bg-white/[0.05] ${
currentSpaceId === s.slug ? 'bg-white/[0.07]' : ''
}`}
onClick={() => setOpen(false)}
>
<span className="text-base">{s.icon || '🌐'}</span>
<span className="text-sm font-medium flex-1">{s.name}</span>
</a>
))}
</>
)}
<div className="h-px bg-white/[0.08] my-1" />
<a
href="https://rspace.online/new"
className="flex items-center px-3.5 py-2.5 text-sm font-semibold text-cyan-400 hover:bg-cyan-500/[0.08] transition-colors no-underline"
onClick={() => setOpen(false)}
>
+ Create new space
</a>
</>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,462 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
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 ImageExt from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder';
import Highlight from '@tiptap/extension-highlight';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import TextStyle from '@tiptap/extension-text-style';
import Color from '@tiptap/extension-color';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
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';
// ─── User colors for collaboration cursors ───────────────
const CURSOR_COLORS = [
'#f87171', '#fb923c', '#fbbf24', '#a3e635',
'#34d399', '#22d3ee', '#818cf8', '#c084fc',
'#f472b6', '#fb7185', '#38bdf8', '#4ade80',
];
function pickColor(userId: string): string {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0;
}
return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
}
// ─── Props ───────────────────────────────────────────────
interface CollaborativeEditorProps {
noteId: string;
yjsDocId: string;
userId: string;
userName: string;
syncUrl: string;
onTitleChange?: (title: string) => void;
}
export function CollaborativeEditor({
noteId,
yjsDocId,
userId,
userName,
syncUrl,
onTitleChange,
}: CollaborativeEditorProps) {
const [suggestionMode, setSuggestionMode] = useState(false);
const [showComments, setShowComments] = useState(true);
const [showResolved, setShowResolved] = useState(false);
const [activeCommentId, setActiveCommentId] = useState<string | null>(null);
const [comments, setComments] = useState<CommentThread[]>([]);
const [connected, setConnected] = useState(false);
const [peers, setPeers] = useState(0);
const userColor = useMemo(() => pickColor(userId), [userId]);
// ─── Yjs document + WebSocket provider ─────────────────
const { ydoc, provider } = useMemo(() => {
const ydoc = new Y.Doc();
const provider = new WebsocketProvider(syncUrl, yjsDocId, ydoc, {
connect: true,
});
return { ydoc, provider };
}, [syncUrl, yjsDocId]);
useEffect(() => {
const onStatus = ({ status }: { status: string }) => {
setConnected(status === 'connected');
};
const onPeers = () => {
setPeers(provider.awareness.getStates().size - 1);
};
provider.on('status', onStatus);
provider.awareness.on('change', onPeers);
// Set awareness user info
provider.awareness.setLocalStateField('user', {
name: userName,
color: userColor,
});
return () => {
provider.off('status', onStatus);
provider.awareness.off('change', onPeers);
provider.destroy();
ydoc.destroy();
};
}, [provider, ydoc, userName, userColor]);
// ─── TipTap editor ────────────────────────────────────
const editor = useEditor({
extensions: [
StarterKit.configure({
history: false, // Yjs handles undo/redo
}),
Collaboration.configure({
document: ydoc,
}),
CollaborationCursor.configure({
provider,
user: { name: userName, color: userColor },
}),
Underline,
Link.configure({ openOnClick: false }),
ImageExt,
Placeholder.configure({
placeholder: 'Start writing...',
}),
Highlight.configure({ multicolor: true }),
TaskList,
TaskItem.configure({ nested: true }),
TextStyle,
Color,
SuggestionMark,
SuggestionMode.configure({
enabled: suggestionMode,
userId,
userName,
userColor,
}),
CommentMark,
],
editorProps: {
attributes: {
class: 'tiptap',
},
},
onUpdate: ({ editor }) => {
// Extract title from first heading or first line
const firstNode = editor.state.doc.firstChild;
if (firstNode) {
const text = firstNode.textContent?.trim() || 'Untitled';
onTitleChange?.(text);
}
},
}, [ydoc, provider]);
// Update suggestion mode dynamically
useEffect(() => {
if (!editor) return;
// Re-configure the suggestion mode extension
editor.extensionManager.extensions.forEach((ext) => {
if (ext.name === 'suggestionMode') {
ext.options.enabled = suggestionMode;
}
});
}, [editor, suggestionMode]);
// ─── Suggestion helpers ────────────────────────────────
const pendingSuggestions = useMemo(() => {
if (!editor) return 0;
let count = 0;
const { doc } = editor.state;
doc.descendants((node) => {
node.marks.forEach((mark) => {
if (mark.type.name === 'suggestion' && mark.attrs.status === 'pending') {
count++;
}
});
});
return count;
}, [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 }[] = [];
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 });
}
}
});
});
// Process in reverse to maintain positions
marks.reverse().forEach(({ from, to, mark }) => {
if ((mark as any).attrs.type === 'delete') {
tr.delete(from, to);
} else {
tr.removeMark(from, to, editor.state.schema.marks.suggestion);
}
});
editor.view.dispatch(tr);
}, [editor]);
const handleRejectAll = useCallback(() => {
if (!editor) return;
const { tr, doc } = editor.state;
const marks: { from: number; to: number; mark: any }[] = [];
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 });
}
});
});
marks.reverse().forEach(({ from, to, mark }) => {
if (mark.attrs.type === 'insert') {
// For insert suggestions, remove the inserted text
tr.delete(from, to);
} else {
// For delete suggestions, just remove the mark (keep the text)
tr.removeMark(from, to, editor.state.schema.marks.suggestion);
}
});
editor.view.dispatch(tr);
}, [editor]);
// ─── Comment helpers ───────────────────────────────────
const handleAddComment = useCallback(() => {
if (!editor) return;
const { from, to } = editor.state.selection;
if (from === to) return; // Need a selection
const body = window.prompt('Add a comment:');
if (!body?.trim()) return;
const commentId = crypto.randomUUID();
const newComment: CommentThread = {
id: commentId,
noteId,
authorId: userId,
authorName: userName,
body: body.trim(),
fromPos: from,
toPos: to,
resolved: false,
createdAt: new Date().toISOString(),
replies: [],
reactions: {},
};
// Add comment mark to the text
editor
.chain()
.focus()
.setMark('comment', { commentId, resolved: false })
.run();
setComments((prev) => [...prev, newComment]);
setActiveCommentId(commentId);
setShowComments(true);
}, [editor, noteId, userId, userName]);
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 handleResolve = useCallback((commentId: string) => {
setComments((prev) =>
prev.map((c) => (c.id === commentId ? { ...c, resolved: true } : c))
);
// Update the mark in the editor
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 })
);
}
});
});
editor.view.dispatch(tr);
}
}, [editor]);
const handleDeleteComment = useCallback((commentId: string) => {
setComments((prev) => prev.filter((c) => c.id !== commentId));
// Remove the mark from the editor
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);
}
});
});
editor.view.dispatch(tr);
}
}, [editor]);
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 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();
}
}
}, [editor, comments]);
// ─── Render ────────────────────────────────────────────
return (
<div className="flex flex-col h-[calc(100vh-53px)]">
<Toolbar
editor={editor}
suggestionMode={suggestionMode}
onToggleSuggestionMode={() => setSuggestionMode(!suggestionMode)}
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>
</div>
)}
<div className="flex flex-1 min-h-0">
{/* Editor */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto px-6 sm:px-10">
<EditorContent editor={editor} />
</div>
</div>
{/* Comments sidebar */}
{showComments && (
<div className="w-80 border-l border-slate-800 flex-shrink-0 bg-slate-900/50 overflow-hidden">
<CommentsSidebar
comments={comments}
activeCommentId={activeCommentId}
currentUserId={userId}
currentUserName={userName}
onReply={handleReply}
onResolve={handleResolve}
onDelete={handleDeleteComment}
onReact={handleReact}
onClickComment={handleClickComment}
showResolved={showResolved}
onToggleResolved={() => setShowResolved(!showResolved)}
/>
</div>
)}
</div>
{/* Status bar */}
<div className="flex items-center justify-between px-4 py-1.5 border-t border-slate-800 text-xs text-slate-500 bg-slate-900/80">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
{connected ? (
<><Wifi size={12} className="text-green-400" /> Connected</>
) : (
<><WifiOff size={12} className="text-red-400" /> Disconnected</>
)}
</span>
{peers > 0 && (
<span className="flex items-center gap-1">
<Users size={12} /> {peers} {peers === 1 ? 'collaborator' : 'collaborators'}
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowComments(!showComments)}
className="p-1 hover:text-slate-300 transition-colors"
title={showComments ? 'Hide comments' : 'Show comments'}
>
{showComments ? <PanelRightClose size={14} /> : <PanelRightOpen size={14} />}
</button>
</div>
</div>
</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,46 @@
/**
* TipTap extension for inline comments.
*
* Comments are anchored to text ranges via marks. The comment data
* itself lives in the component state / database the mark just
* stores the commentId for linking.
*/
import { Mark } from "@tiptap/core";
export interface CommentMarkAttrs {
commentId: string;
resolved: boolean;
}
export const CommentMark = Mark.create({
name: "comment",
addAttributes() {
return {
commentId: { default: null },
resolved: { default: false },
};
},
inclusive: false,
excludes: "",
parseHTML() {
return [{ tag: 'span[data-comment]' }];
},
renderHTML({ HTMLAttributes }) {
return [
"span",
{
"data-comment": "",
"data-comment-id": HTMLAttributes.commentId,
class: HTMLAttributes.resolved
? "comment-highlight opacity-50"
: "comment-highlight",
},
0,
];
},
});

View File

@ -0,0 +1,258 @@
'use client';
import { useState } from 'react';
import { formatDistanceToNow } from 'date-fns';
import { Check, Reply, Smile, Trash2, X } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface CommentThread {
id: string;
noteId: string;
authorId: string;
authorName: string;
body: string;
fromPos: number;
toPos: number;
resolved: boolean;
createdAt: string;
replies: CommentReply[];
reactions: Record<string, string[]>; // emoji → authorIds
}
export interface CommentReply {
id: string;
authorId: string;
authorName: string;
body: string;
createdAt: string;
}
const QUICK_REACTIONS = ['👍', '👎', '❤️', '😄', '🎉', '🤔', '👀', '🔥'];
interface CommentsSidebarProps {
comments: CommentThread[];
activeCommentId: string | null;
currentUserId: string;
currentUserName: string;
onReply: (commentId: string, body: string) => void;
onResolve: (commentId: string) => void;
onDelete: (commentId: string) => void;
onReact: (commentId: string, emoji: string) => void;
onClickComment: (commentId: string) => void;
showResolved: boolean;
onToggleResolved: () => void;
}
export function CommentsSidebar({
comments,
activeCommentId,
currentUserId,
currentUserName,
onReply,
onResolve,
onDelete,
onReact,
onClickComment,
showResolved,
onToggleResolved,
}: CommentsSidebarProps) {
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [replyText, setReplyText] = useState('');
const [reactingTo, setReactingTo] = useState<string | null>(null);
const filtered = showResolved
? comments
: comments.filter((c) => !c.resolved);
const handleReply = (commentId: string) => {
if (!replyText.trim()) return;
onReply(commentId, replyText.trim());
setReplyText('');
setReplyingTo(null);
};
if (comments.length === 0) {
return (
<div className="p-4 text-center text-sm text-slate-500">
No comments yet. Select text and click the comment button to start a discussion.
</div>
);
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-3 py-2 border-b border-slate-800">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Comments ({filtered.length})
</span>
<button
onClick={onToggleResolved}
className="text-xs text-slate-400 hover:text-slate-200 transition-colors"
>
{showResolved ? 'Hide resolved' : 'Show resolved'}
</button>
</div>
<div className="flex-1 overflow-y-auto">
{filtered.map((comment) => (
<div
key={comment.id}
onClick={() => onClickComment(comment.id)}
className={cn(
'border-b border-slate-800/50 px-3 py-3 cursor-pointer transition-colors',
activeCommentId === comment.id
? 'bg-amber-500/[0.05] border-l-2 border-l-amber-500'
: 'hover:bg-white/[0.02]',
comment.resolved && 'opacity-60'
)}
>
{/* Author + time */}
<div className="flex items-center justify-between mb-1.5">
<span className="text-sm font-medium text-slate-200">
{comment.authorName}
</span>
<span className="text-[10px] text-slate-500">
{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
</span>
</div>
{/* Body */}
<p className="text-sm text-slate-300 mb-2">{comment.body}</p>
{/* Reactions */}
{Object.keys(comment.reactions).length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{Object.entries(comment.reactions).map(([emoji, authors]) => (
<button
key={emoji}
onClick={(e) => {
e.stopPropagation();
onReact(comment.id, emoji);
}}
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs border transition-colors',
authors.includes(currentUserId)
? 'border-primary/30 bg-primary/10'
: 'border-slate-700 hover:border-slate-600'
)}
>
<span>{emoji}</span>
<span className="text-slate-400">{authors.length}</span>
</button>
))}
</div>
)}
{/* Replies */}
{comment.replies.map((reply) => (
<div key={reply.id} className="ml-3 pl-3 border-l border-slate-700/50 mt-2">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-xs font-medium text-slate-300">{reply.authorName}</span>
<span className="text-[10px] text-slate-500">
{formatDistanceToNow(new Date(reply.createdAt), { addSuffix: true })}
</span>
</div>
<p className="text-xs text-slate-400">{reply.body}</p>
</div>
))}
{/* Actions */}
<div className="flex items-center gap-1 mt-2">
<button
onClick={(e) => {
e.stopPropagation();
setReactingTo(reactingTo === comment.id ? null : comment.id);
}}
className="p-1 text-slate-500 hover:text-slate-300 transition-colors rounded"
title="React"
>
<Smile size={14} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setReplyingTo(replyingTo === comment.id ? null : comment.id);
}}
className="p-1 text-slate-500 hover:text-slate-300 transition-colors rounded"
title="Reply"
>
<Reply size={14} />
</button>
{!comment.resolved && (
<button
onClick={(e) => {
e.stopPropagation();
onResolve(comment.id);
}}
className="p-1 text-slate-500 hover:text-green-400 transition-colors rounded"
title="Resolve"
>
<Check size={14} />
</button>
)}
{comment.authorId === currentUserId && (
<button
onClick={(e) => {
e.stopPropagation();
onDelete(comment.id);
}}
className="p-1 text-slate-500 hover:text-red-400 transition-colors rounded"
title="Delete"
>
<Trash2 size={14} />
</button>
)}
</div>
{/* Reaction picker */}
{reactingTo === comment.id && (
<div className="flex flex-wrap gap-1 mt-2 p-1.5 bg-slate-800 rounded-lg">
{QUICK_REACTIONS.map((emoji) => (
<button
key={emoji}
onClick={(e) => {
e.stopPropagation();
onReact(comment.id, emoji);
setReactingTo(null);
}}
className="p-1.5 hover:bg-white/10 rounded transition-colors text-base"
>
{emoji}
</button>
))}
</div>
)}
{/* Reply input */}
{replyingTo === comment.id && (
<div className="mt-2 flex gap-1.5">
<input
type="text"
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleReply(comment.id);
if (e.key === 'Escape') setReplyingTo(null);
}}
placeholder="Reply..."
className="flex-1 bg-slate-800 border border-slate-700 rounded-md px-2.5 py-1.5 text-sm text-slate-200 placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-primary/50"
autoFocus
onClick={(e) => e.stopPropagation()}
/>
<button
onClick={(e) => {
e.stopPropagation();
handleReply(comment.id);
}}
className="px-2.5 py-1.5 bg-primary/20 text-primary text-sm rounded-md hover:bg-primary/30 transition-colors"
>
Send
</button>
</div>
)}
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,167 @@
/**
* TipTap extension for Google Docs-style suggestions (track changes).
*
* When suggestion mode is active, edits are wrapped in suggestion marks
* rather than directly modifying the document. Each suggestion carries
* metadata about the author, type, and status.
*/
import { Extension, Mark } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
// ─── Suggestion Mark ─────────────────────────────────────
export interface SuggestionAttrs {
id: string;
authorId: string;
authorName: string;
type: "insert" | "delete" | "format";
status: "pending" | "accepted" | "rejected";
createdAt: string;
color: string;
}
export const SuggestionMark = Mark.create({
name: "suggestion",
addAttributes() {
return {
id: { default: null },
authorId: { default: null },
authorName: { default: "Unknown" },
type: { default: "insert" },
status: { default: "pending" },
createdAt: { default: () => new Date().toISOString() },
color: { default: "#4ade80" },
};
},
parseHTML() {
return [{ tag: 'span[data-suggestion]' }];
},
renderHTML({ HTMLAttributes }) {
const type = HTMLAttributes.type || "insert";
return [
"span",
{
"data-suggestion": "",
"data-suggestion-id": HTMLAttributes.id,
"data-suggestion-type": type,
class: type === "delete" ? "suggestion-delete" : "suggestion-insert",
title: `${HTMLAttributes.authorName}${type}`,
},
0,
];
},
});
// ─── Suggestion Mode Plugin ──────────────────────────────
const suggestionModeKey = new PluginKey("suggestionMode");
export interface SuggestionModeOptions {
enabled: boolean;
userId: string;
userName: string;
userColor: string;
onSuggestionCreate?: (suggestion: SuggestionAttrs) => void;
}
export const SuggestionMode = Extension.create<SuggestionModeOptions>({
name: "suggestionMode",
addOptions() {
return {
enabled: false,
userId: "",
userName: "",
userColor: "#4ade80",
onSuggestionCreate: undefined,
};
},
addProseMirrorPlugins() {
const opts = this.options;
return [
new Plugin({
key: suggestionModeKey,
props: {
handleTextInput(view, from, to, text) {
if (!opts.enabled) return false;
const { state } = view;
const id = crypto.randomUUID();
const attrs: SuggestionAttrs = {
id,
authorId: opts.userId,
authorName: opts.userName,
type: "insert",
status: "pending",
createdAt: new Date().toISOString(),
color: opts.userColor,
};
const suggestionMarkType = state.schema.marks.suggestion;
if (!suggestionMarkType) return false;
const mark = suggestionMarkType.create(attrs);
const tr = state.tr
.insertText(text, from, to)
.addMark(from, from + text.length, mark);
view.dispatch(tr);
opts.onSuggestionCreate?.(attrs);
return true;
},
handleKeyDown(view, event) {
if (!opts.enabled) return false;
// Intercept delete/backspace in suggestion mode
if (event.key === "Backspace" || event.key === "Delete") {
const { state } = view;
const { from, to, empty } = state.selection;
if (empty && event.key === "Backspace" && from === 0) return false;
const deleteFrom = empty
? event.key === "Backspace" ? from - 1 : from
: from;
const deleteTo = empty
? event.key === "Backspace" ? from : from + 1
: to;
if (deleteFrom < 0) return false;
const id = crypto.randomUUID();
const attrs: SuggestionAttrs = {
id,
authorId: opts.userId,
authorName: opts.userName,
type: "delete",
status: "pending",
createdAt: new Date().toISOString(),
color: opts.userColor,
};
const suggestionMarkType = state.schema.marks.suggestion;
if (!suggestionMarkType) return false;
const mark = suggestionMarkType.create(attrs);
const tr = state.tr.addMark(deleteFrom, deleteTo, mark);
view.dispatch(tr);
opts.onSuggestionCreate?.(attrs);
return true;
}
return false;
},
},
}),
];
},
});

View File

@ -0,0 +1,203 @@
'use client';
import { Editor } from '@tiptap/react';
import {
Bold, Italic, Underline, Strikethrough,
Heading1, Heading2, Heading3,
List, ListOrdered, ListChecks,
Quote, Code, Minus, Undo2, Redo2,
Image, Link2,
MessageSquare, FileEdit, Check, X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface ToolbarProps {
editor: Editor | null;
suggestionMode: boolean;
onToggleSuggestionMode: () => void;
onAddComment: () => void;
pendingSuggestions: number;
onAcceptAll: () => void;
onRejectAll: () => void;
}
function ToolbarButton({
onClick,
active,
disabled,
title,
children,
variant,
}: {
onClick: () => void;
active?: boolean;
disabled?: boolean;
title: string;
children: React.ReactNode;
variant?: 'default' | 'success' | 'danger' | 'accent';
}) {
return (
<button
onClick={onClick}
disabled={disabled}
title={title}
className={cn(
'p-1.5 rounded-md transition-colors disabled:opacity-30',
active
? 'bg-primary/20 text-primary'
: variant === 'success'
? 'text-green-400 hover:bg-green-500/10'
: variant === 'danger'
? 'text-red-400 hover:bg-red-500/10'
: variant === 'accent'
? 'text-amber-400 hover:bg-amber-500/10'
: 'text-slate-400 hover:bg-white/[0.06] hover:text-slate-200'
)}
>
{children}
</button>
);
}
function Divider() {
return <div className="w-px h-5 bg-white/10 mx-1" />;
}
export function Toolbar({
editor,
suggestionMode,
onToggleSuggestionMode,
onAddComment,
pendingSuggestions,
onAcceptAll,
onRejectAll,
}: ToolbarProps) {
if (!editor) return null;
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">
<Undo2 size={16} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Redo">
<Redo2 size={16} />
</ToolbarButton>
<Divider />
{/* Text formatting */}
<ToolbarButton onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Bold">
<Bold size={16} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Italic">
<Italic size={16} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} title="Underline">
<Underline size={16} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} title="Strikethrough">
<Strikethrough size={16} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().toggleCode().run()} active={editor.isActive('code')} title="Inline code">
<Code size={16} />
</ToolbarButton>
<Divider />
{/* Headings */}
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive('heading', { level: 1 })} 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">
<Heading2 size={16} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Heading 3">
<Heading3 size={16} />
</ToolbarButton>
<Divider />
{/* Lists */}
<ToolbarButton onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Bullet list">
<List size={16} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Numbered list">
<ListOrdered size={16} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().toggleTaskList().run()} active={editor.isActive('taskList')} title="Task list">
<ListChecks size={16} />
</ToolbarButton>
<Divider />
{/* Block elements */}
<ToolbarButton onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Blockquote">
<Quote size={16} />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().setHorizontalRule().run()} title="Horizontal rule">
<Minus size={16} />
</ToolbarButton>
<Divider />
{/* Link */}
<ToolbarButton
onClick={() => {
const url = window.prompt('URL:');
if (url) editor.chain().focus().setLink({ href: url }).run();
}}
active={editor.isActive('link')}
title="Insert link"
>
<Link2 size={16} />
</ToolbarButton>
{/* Image */}
<ToolbarButton
onClick={() => {
const url = window.prompt('Image URL:');
if (url) editor.chain().focus().setImage({ src: url }).run();
}}
title="Insert image"
>
<Image size={16} />
</ToolbarButton>
<div className="flex-1" />
{/* Collaboration tools */}
<ToolbarButton onClick={onAddComment} title="Add comment" variant="accent">
<MessageSquare size={16} />
</ToolbarButton>
<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>
</>
)}
</div>
);
}

42
src/lib/encryptid.ts Normal file
View File

@ -0,0 +1,42 @@
const ENCRYPTID_URL =
process.env.ENCRYPTID_SERVER_URL || "https://auth.ridentity.online";
export interface EncryptIDClaims {
sub: string;
did: string;
username?: string;
exp?: number;
}
export async function verifyEncryptIDToken(
token: string
): Promise<EncryptIDClaims | null> {
try {
const res = await fetch(`${ENCRYPTID_URL}/api/session/verify`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
const data = await res.json();
if (!data.valid) return null;
return {
sub: data.did || data.userId,
did: data.did || data.userId,
username: data.username || undefined,
};
} catch {
return null;
}
}
export function extractToken(
headers: Headers,
cookies?: { get: (name: string) => { value: string } | undefined }
): string | null {
const auth = headers.get("Authorization");
if (auth?.startsWith("Bearer ")) return auth.slice(7);
if (cookies) {
const c = cookies.get("encryptid_token");
if (c) return c.value;
}
return null;
}

11
src/lib/prisma.ts Normal file
View File

@ -0,0 +1,11 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

33
src/middleware.ts Normal file
View File

@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const host = request.headers.get('host') || '';
const hostname = host.split(':')[0].toLowerCase();
// Determine space from subdomain or custom domain
let spaceId = 'default';
if (hostname.endsWith('.rnotes.online')) {
spaceId = hostname.replace('.rnotes.online', '');
}
// Local dev: check for space query param as override
if (hostname === 'localhost' || hostname === '127.0.0.1') {
const url = new URL(request.url);
const spaceParam = url.searchParams.get('_space');
if (spaceParam) spaceId = spaceParam;
}
const response = NextResponse.next();
response.cookies.set('space_id', spaceId, {
path: '/',
sameSite: 'lax',
httpOnly: false,
maxAge: 86400,
});
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

178
sync-server/src/index.ts Normal file
View File

@ -0,0 +1,178 @@
/**
* 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.
*/
import http from "http";
import { WebSocketServer, WebSocket } from "ws";
import * as Y from "yjs";
import { encoding, decoding, mutex } from "lib0";
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 }));
return;
}
res.writeHead(200);
res.end("rNotes sync server");
});
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);
});
});
server.listen(PORT, () => {
console.log(`rNotes sync server listening on port ${PORT}`);
console.log(`WebSocket endpoint: ws://0.0.0.0:${PORT}/ws/<docId>`);
});

14
sync-server/tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts"]
}

23
tsconfig.json Normal file
View File

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