commit 70ce3d8954ec07769df7609e8378f9d46ecbd8b0 Author: Jeff Emmett Date: Tue Mar 17 02:45:38 2026 +0000 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) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..02eead4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.next +.git +*.md +backlog diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cbf6ab3 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c18151 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +.next +.env +*.env.local +sync-server/dist diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7c8980a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..73ad56b --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..c15646d --- /dev/null +++ b/entrypoint.sh @@ -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 "$@" diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..68a6c64 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..d4afc8b --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..79bcf13 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..8c41c84 --- /dev/null +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts new file mode 100644 index 0000000..1dbe71a --- /dev/null +++ b/src/app/api/me/route.ts @@ -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, + }, + }); +} diff --git a/src/app/api/notebooks/[notebookId]/notes/route.ts b/src/app/api/notebooks/[notebookId]/notes/route.ts new file mode 100644 index 0000000..40b5e61 --- /dev/null +++ b/src/app/api/notebooks/[notebookId]/notes/route.ts @@ -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, + }); +} diff --git a/src/app/api/notebooks/[notebookId]/route.ts b/src/app/api/notebooks/[notebookId]/route.ts new file mode 100644 index 0000000..22aa774 --- /dev/null +++ b/src/app/api/notebooks/[notebookId]/route.ts @@ -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(), + })), + }); +} diff --git a/src/app/api/notebooks/notes/[noteId]/route.ts b/src/app/api/notebooks/notes/[noteId]/route.ts new file mode 100644 index 0000000..a6915bd --- /dev/null +++ b/src/app/api/notebooks/notes/[noteId]/route.ts @@ -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 }); +} diff --git a/src/app/api/notebooks/route.ts b/src/app/api/notebooks/route.ts new file mode 100644 index 0000000..f16b4d4 --- /dev/null +++ b/src/app/api/notebooks/route.ts @@ -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, + }); +} diff --git a/src/app/api/spaces/route.ts b/src/app/api/spaces/route.ts new file mode 100644 index 0000000..894e612 --- /dev/null +++ b/src/app/api/spaces/route.ts @@ -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 = {}; + 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: [] }); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..cd4df3a --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -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([]); + 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 ( +
+
+ New Notebook + + } + /> + +
+ {loading ? ( +
Loading notebooks...
+ ) : notebooks.length === 0 ? ( +
+
📓
+

No notebooks yet

+

Create your first notebook to start writing collaboratively.

+ +
+ ) : ( +
+ {notebooks.map((nb) => ( + +
+ {nb.icon} + + {nb.noteCount} {nb.noteCount === 1 ? 'note' : 'notes'} + +
+

+ {nb.title} +

+ {nb.description && ( +

{nb.description}

+ )} +
+ + + {new Date(nb.updatedAt).toLocaleDateString()} + + {nb.collaborators > 0 && ( + + + {nb.collaborators} + + )} +
+ + ))} + + {/* Add button card */} + +
+ )} +
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..5c60dac --- /dev/null +++ b/src/app/globals.css @@ -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; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..b02454a --- /dev/null +++ b/src/app/layout.tsx @@ -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,📝" }, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..592c2e7 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,75 @@ +import Link from 'next/link'; +import { Header } from '@/components/Header'; + +export default function LandingPage() { + return ( +
+
+ +
+ {/* Hero */} +
+
+ 📝 Real-time collaborative notes +
+ +

+ Write together.{' '} + + Think together. + +

+ +

+ Google Docs-style collaborative editing with suggestions, comments, + approvals, and emoji reactions — all self-hosted and community-owned. +

+ +
+ + Get Started + + + Learn More + +
+
+ + {/* Features */} +
+
+ {[ + { 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) => ( +
+
{f.icon}
+

{f.title}

+

{f.desc}

+
+ ))} +
+
+
+ + +
+ ); +} diff --git a/src/app/s/[slug]/n/[noteId]/page.tsx b/src/app/s/[slug]/n/[noteId]/page.tsx new file mode 100644 index 0000000..67cf875 --- /dev/null +++ b/src/app/s/[slug]/n/[noteId]/page.tsx @@ -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(null); + const [user, setUser] = useState(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 ( +
+
+
+ Loading note... +
+
+ ); + } + + if (!note) { + return ( +
+
+
+ Note not found +
+
+ ); + } + + // Guest mode for unauthenticated users + const userId = user?.id || `guest-${Math.random().toString(36).slice(2, 8)}`; + const userName = user?.username || 'Guest'; + + return ( +
+
+ +
+ ); +} diff --git a/src/app/s/[slug]/notebook/[notebookId]/page.tsx b/src/app/s/[slug]/notebook/[notebookId]/page.tsx new file mode 100644 index 0000000..fd82677 --- /dev/null +++ b/src/app/s/[slug]/notebook/[notebookId]/page.tsx @@ -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(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 ( +
+
+
Loading...
+
+ ); + } + + if (!notebook) { + return ( +
+
+
Notebook not found
+
+ ); + } + + return ( +
+
+ New Note + + } + /> + +
+
+ {notebook.icon} +
+

{notebook.title}

+ {notebook.description && ( +

{notebook.description}

+ )} +
+
+ + {notebook.notes.length === 0 ? ( +
+
📄
+

No notes yet

+ +
+ ) : ( +
+ {notebook.notes.map((note) => ( + + + + {note.title} + + + + {new Date(note.updatedAt).toLocaleDateString()} + + + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx new file mode 100644 index 0000000..3bc9495 --- /dev/null +++ b/src/components/AppSwitcher.tsx @@ -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 = { + 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 . 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(null); + const ref = useRef(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(); + 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 ( +
+ {/* Trigger button */} + + + {/* Dropdown */} + {open && ( +
+ {/* rStack header */} +
+ + r* + +
+
rStack
+
Self-hosted community app suite
+
+
+ + {/* Categories */} + {CATEGORY_ORDER.map((cat) => { + const items = groups.get(cat); + if (!items || items.length === 0) return null; + return ( + + ); + })} + + {/* Footer */} + +
+ )} +
+ ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..497fe8e --- /dev/null +++ b/src/components/Header.tsx @@ -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 ( +
+
+
+ + + {breadcrumbs && breadcrumbs.length > 0 && ( +
+ {breadcrumbs.map((b, i) => ( + + / + {b.href ? ( + + {b.label} + + ) : ( + {b.label} + )} + + ))} +
+ )} +
+
+ {actions} +
+
+
+ ); +} diff --git a/src/components/SpaceSwitcher.tsx b/src/components/SpaceSwitcher.tsx new file mode 100644 index 0000000..dcc1581 --- /dev/null +++ b/src/components/SpaceSwitcher.tsx @@ -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 . */ + 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([]); + const [loaded, setLoaded] = useState(false); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [username, setUsername] = useState(null); + const ref = useRef(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 = {}; + 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: . */ + 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 ( +
+ + + {open && ( +
+ {!loaded ? ( +
Loading spaces...
+ ) : !isAuthenticated && spaces.length === 0 ? ( + <> +
+ Sign in to see your spaces +
+ + ) : ( + <> + {/* Personal space — always first when logged in */} + {personalSpace && ( + <> +
+ Personal +
+ setOpen(false)} + > + {personalSpace.icon} + {username} + + owner + + + + )} + + {/* Other spaces the user belongs to */} + {mySpaces.length > 0 && ( + <> + {personalSpace &&
} +
+ Your spaces +
+ {mySpaces.map((s) => ( + setOpen(false)} + > + {s.icon || '🌐'} + {s.name} + {s.role && ( + + {s.role} + + )} + + ))} + + )} + + {/* Public spaces */} + {publicSpaces.length > 0 && ( + <> + {(personalSpace || mySpaces.length > 0) &&
} +
+ Public spaces +
+ {publicSpaces.map((s) => ( + setOpen(false)} + > + {s.icon || '🌐'} + {s.name} + + ))} + + )} + + + )} +
+ ); +} diff --git a/src/components/editor/CollaborativeEditor.tsx b/src/components/editor/CollaborativeEditor.tsx new file mode 100644 index 0000000..02494e9 --- /dev/null +++ b/src/components/editor/CollaborativeEditor.tsx @@ -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(null); + const [comments, setComments] = useState([]); + 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 ( +
+ setSuggestionMode(!suggestionMode)} + onAddComment={handleAddComment} + pendingSuggestions={pendingSuggestions} + onAcceptAll={handleAcceptAll} + onRejectAll={handleRejectAll} + /> + + {/* Mode indicator bar */} + {suggestionMode && ( +
+ + Suggestion mode — your edits will appear as suggestions for review +
+ )} + +
+ {/* Editor */} +
+
+ +
+
+ + {/* Comments sidebar */} + {showComments && ( +
+ setShowResolved(!showResolved)} + /> +
+ )} +
+ + {/* Status bar */} +
+
+ + {connected ? ( + <> Connected + ) : ( + <> Disconnected + )} + + {peers > 0 && ( + + {peers} {peers === 1 ? 'collaborator' : 'collaborators'} + + )} +
+ +
+ +
+
+
+ ); +} + +// Re-export for the toolbar (used in this file but imported from lucide) +function FileEdit({ size }: { size: number }) { + return ( + + + + + ); +} diff --git a/src/components/editor/CommentExtension.ts b/src/components/editor/CommentExtension.ts new file mode 100644 index 0000000..de2828c --- /dev/null +++ b/src/components/editor/CommentExtension.ts @@ -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, + ]; + }, +}); diff --git a/src/components/editor/CommentsSidebar.tsx b/src/components/editor/CommentsSidebar.tsx new file mode 100644 index 0000000..a7b8610 --- /dev/null +++ b/src/components/editor/CommentsSidebar.tsx @@ -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; // 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(null); + const [replyText, setReplyText] = useState(''); + const [reactingTo, setReactingTo] = useState(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 ( +
+ No comments yet. Select text and click the comment button to start a discussion. +
+ ); + } + + return ( +
+
+ + Comments ({filtered.length}) + + +
+ +
+ {filtered.map((comment) => ( +
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 */} +
+ + {comment.authorName} + + + {formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })} + +
+ + {/* Body */} +

{comment.body}

+ + {/* Reactions */} + {Object.keys(comment.reactions).length > 0 && ( +
+ {Object.entries(comment.reactions).map(([emoji, authors]) => ( + + ))} +
+ )} + + {/* Replies */} + {comment.replies.map((reply) => ( +
+
+ {reply.authorName} + + {formatDistanceToNow(new Date(reply.createdAt), { addSuffix: true })} + +
+

{reply.body}

+
+ ))} + + {/* Actions */} +
+ + + {!comment.resolved && ( + + )} + {comment.authorId === currentUserId && ( + + )} +
+ + {/* Reaction picker */} + {reactingTo === comment.id && ( +
+ {QUICK_REACTIONS.map((emoji) => ( + + ))} +
+ )} + + {/* Reply input */} + {replyingTo === comment.id && ( +
+ 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()} + /> + +
+ )} +
+ ))} +
+
+ ); +} diff --git a/src/components/editor/SuggestionExtension.ts b/src/components/editor/SuggestionExtension.ts new file mode 100644 index 0000000..a02883c --- /dev/null +++ b/src/components/editor/SuggestionExtension.ts @@ -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({ + 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; + }, + }, + }), + ]; + }, +}); diff --git a/src/components/editor/Toolbar.tsx b/src/components/editor/Toolbar.tsx new file mode 100644 index 0000000..ade22f6 --- /dev/null +++ b/src/components/editor/Toolbar.tsx @@ -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 ( + + ); +} + +function Divider() { + return
; +} + +export function Toolbar({ + editor, + suggestionMode, + onToggleSuggestionMode, + onAddComment, + pendingSuggestions, + onAcceptAll, + onRejectAll, +}: ToolbarProps) { + if (!editor) return null; + + return ( +
+ {/* Undo / Redo */} + editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Undo"> + + + editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Redo"> + + + + + + {/* Text formatting */} + editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Bold"> + + + editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Italic"> + + + editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} title="Underline"> + + + editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} title="Strikethrough"> + + + editor.chain().focus().toggleCode().run()} active={editor.isActive('code')} title="Inline code"> + + + + + + {/* Headings */} + editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive('heading', { level: 1 })} title="Heading 1"> + + + editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} title="Heading 2"> + + + editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Heading 3"> + + + + + + {/* Lists */} + editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Bullet list"> + + + editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Numbered list"> + + + editor.chain().focus().toggleTaskList().run()} active={editor.isActive('taskList')} title="Task list"> + + + + + + {/* Block elements */} + editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Blockquote"> + + + editor.chain().focus().setHorizontalRule().run()} title="Horizontal rule"> + + + + + + {/* Link */} + { + const url = window.prompt('URL:'); + if (url) editor.chain().focus().setLink({ href: url }).run(); + }} + active={editor.isActive('link')} + title="Insert link" + > + + + + {/* Image */} + { + const url = window.prompt('Image URL:'); + if (url) editor.chain().focus().setImage({ src: url }).run(); + }} + title="Insert image" + > + + + +
+ + {/* Collaboration tools */} + + + + + + + {/* Suggestion mode toggle */} + + + + {suggestionMode ? 'Suggesting' : 'Editing'} + + + + {pendingSuggestions > 0 && ( + <> + + {pendingSuggestions} pending + + + + + + + + + )} +
+ ); +} diff --git a/src/lib/encryptid.ts b/src/lib/encryptid.ts new file mode 100644 index 0000000..126b366 --- /dev/null +++ b/src/lib/encryptid.ts @@ -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 { + 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; +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..12fe87f --- /dev/null +++ b/src/lib/prisma.ts @@ -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; diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..a5ef193 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..3a36f5d --- /dev/null +++ b/src/middleware.ts @@ -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).*)'], +}; diff --git a/sync-server/src/index.ts b/sync-server/src/index.ts new file mode 100644 index 0000000..8a7ed4c --- /dev/null +++ b/sync-server/src/index.ts @@ -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>; + awareness: Map; + mux: mutex.mutex; +} + +const docs = new Map(); + +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/ + 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/`); +}); diff --git a/sync-server/tsconfig.json b/sync-server/tsconfig.json new file mode 100644 index 0000000..da96906 --- /dev/null +++ b/sync-server/tsconfig.json @@ -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"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..83f9ca9 --- /dev/null +++ b/tsconfig.json @@ -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"] +}