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:
commit
70ce3d8954
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
*.md
|
||||
backlog
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
.next
|
||||
.env
|
||||
*.env.local
|
||||
sync-server/dist
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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 "$@"
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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: [] });
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">▾</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()}
|
||||
>
|
||||
↗
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">▾</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
|
@ -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).*)'],
|
||||
};
|
||||
|
|
@ -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>`);
|
||||
});
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Reference in New Issue