Add full application: schema, API routes, components, pages

- Prisma schema: User, Notebook, Note (6 types), Tag, SharedAccess
- API: CRUD for notebooks/notes, full-text search, canvas sync
- Components: CanvasEmbed, NoteEditor, NoteCard, NotebookCard, SearchBar, TagBadge
- Pages: landing, notebooks list/new/detail/canvas, notes new/detail
- Canvas integration: bidirectional sync with rSpace (folk-notebook, folk-note shapes)
- Amber/orange theme consistent across all pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 13:00:44 -07:00
parent 445a282ef1
commit 118710999b
30 changed files with 4404 additions and 0 deletions

2000
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

142
prisma/schema.prisma Normal file
View File

@ -0,0 +1,142 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── Users ──────────────────────────────────────────────────────────
model User {
id String @id @default(cuid())
did String @unique // EncryptID DID
username String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
notebooks NotebookCollaborator[]
notes Note[]
sharedByMe SharedAccess[] @relation("SharedBy")
}
// ─── Notebooks ──────────────────────────────────────────────────────
model Notebook {
id String @id @default(cuid())
title String
slug String @unique
description String? @db.Text
coverColor String @default("#f59e0b")
canvasSlug String?
canvasShapeId String?
isPublic Boolean @default(false)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
collaborators NotebookCollaborator[]
notes Note[]
sharedAccess SharedAccess[]
@@index([slug])
}
enum CollaboratorRole {
OWNER
EDITOR
VIEWER
}
model NotebookCollaborator {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
notebookId String
notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade)
role CollaboratorRole @default(VIEWER)
joinedAt DateTime @default(now())
@@unique([userId, notebookId])
@@index([notebookId])
}
// ─── Notes ──────────────────────────────────────────────────────────
model Note {
id String @id @default(cuid())
notebookId String?
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: SetNull)
authorId String?
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
title String
content String @db.Text
contentPlain String? @db.Text
type NoteType @default(NOTE)
url String?
language String?
mimeType String?
fileUrl String?
fileSize Int?
isPinned Boolean @default(false)
canvasShapeId String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tags NoteTag[]
@@index([notebookId])
@@index([authorId])
@@index([type])
@@index([isPinned])
}
enum NoteType {
NOTE
CLIP
BOOKMARK
CODE
IMAGE
FILE
}
// ─── Tags ───────────────────────────────────────────────────────────
model Tag {
id String @id @default(cuid())
name String @unique
color String? @default("#6b7280")
createdAt DateTime @default(now())
notes NoteTag[]
}
model NoteTag {
id String @id @default(cuid())
noteId String
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
tagId String
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@unique([noteId, tagId])
@@index([tagId])
@@index([noteId])
}
// ─── Shared Access ──────────────────────────────────────────────────
model SharedAccess {
id String @id @default(cuid())
notebookId String
notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade)
sharedById String
sharedBy User @relation("SharedBy", fields: [sharedById], references: [id], onDelete: Cascade)
targetDid String
role CollaboratorRole @default(VIEWER)
createdAt DateTime @default(now())
@@unique([notebookId, targetDid])
@@index([targetDid])
}

View File

@ -0,0 +1,5 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({ status: 'ok', service: 'rnotes-online' });
}

View File

@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { pushShapesToCanvas } from '@/lib/canvas-sync';
/**
* POST /api/notebooks/[id]/canvas
*
* Creates an rSpace community for the notebook and populates it
* with initial shapes from the notebook's notes.
*/
export async function POST(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const notebook = await prisma.notebook.findUnique({
where: { id: params.id },
include: {
notes: {
include: { tags: { include: { tag: true } } },
orderBy: [{ isPinned: 'desc' }, { sortOrder: 'asc' }, { updatedAt: 'desc' }],
},
},
});
if (!notebook) {
return NextResponse.json({ error: 'Notebook not found' }, { status: 404 });
}
const canvasSlug = notebook.canvasSlug || notebook.slug;
const shapes: Record<string, unknown>[] = [];
// Notebook shape (top-left)
shapes.push({
type: 'folk-notebook',
x: 50,
y: 50,
width: 350,
height: 300,
notebookTitle: notebook.title,
description: notebook.description || '',
noteCount: notebook.notes.length,
coverColor: notebook.coverColor,
notebookId: notebook.id,
});
// Note shapes (grid layout, 4 columns)
const colWidth = 320;
const rowHeight = 220;
const cols = 4;
const startX = 450;
const startY = 50;
notebook.notes.forEach((note, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
shapes.push({
type: 'folk-note',
x: startX + col * colWidth,
y: startY + row * rowHeight,
width: 300,
height: 200,
noteTitle: note.title,
noteType: note.type,
snippet: (note.contentPlain || note.content || '').slice(0, 200),
url: note.url || '',
tags: note.tags.map((nt) => nt.tag.name),
noteId: note.id,
});
});
await pushShapesToCanvas(canvasSlug, shapes);
// Store canvasSlug if not set
if (!notebook.canvasSlug) {
await prisma.notebook.update({
where: { id: notebook.id },
data: { canvasSlug },
});
}
return NextResponse.json({
canvasSlug,
shapesCreated: shapes.length,
canvasUrl: `https://${canvasSlug}.rspace.online`,
});
} catch (error) {
console.error('Create canvas error:', error);
return NextResponse.json({ error: 'Failed to create canvas' }, { status: 500 });
}
}

View File

@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { stripHtml } from '@/lib/strip-html';
export async function GET(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const notes = await prisma.note.findMany({
where: { notebookId: params.id },
include: {
tags: { include: { tag: true } },
},
orderBy: [{ isPinned: 'desc' }, { sortOrder: 'asc' }, { updatedAt: 'desc' }],
});
return NextResponse.json(notes);
} catch (error) {
console.error('List notebook notes error:', error);
return NextResponse.json({ error: 'Failed to list notes' }, { status: 500 });
}
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const body = await request.json();
const { title, content, type, url, language, tags } = body;
if (!title?.trim()) {
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
}
const contentPlain = content ? stripHtml(content) : null;
// Find or create tags
const tagRecords = [];
if (tags && Array.isArray(tags)) {
for (const tagName of tags) {
const name = tagName.trim().toLowerCase();
if (!name) continue;
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name },
});
tagRecords.push(tag);
}
}
const note = await prisma.note.create({
data: {
notebookId: params.id,
title: title.trim(),
content: content || '',
contentPlain,
type: type || 'NOTE',
url: url || null,
language: language || null,
tags: {
create: tagRecords.map((tag) => ({
tagId: tag.id,
})),
},
},
include: {
tags: { include: { tag: true } },
},
});
return NextResponse.json(note, { status: 201 });
} catch (error) {
console.error('Create note error:', error);
return NextResponse.json({ error: 'Failed to create note' }, { status: 500 });
}
}

View File

@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const notebook = await prisma.notebook.findUnique({
where: { id: params.id },
include: {
notes: {
include: {
tags: { include: { tag: true } },
},
orderBy: [{ isPinned: 'desc' }, { sortOrder: 'asc' }, { updatedAt: 'desc' }],
},
collaborators: {
include: { user: { select: { id: true, username: true } } },
},
_count: { select: { notes: true } },
},
});
if (!notebook) {
return NextResponse.json({ error: 'Notebook not found' }, { status: 404 });
}
return NextResponse.json(notebook);
} catch (error) {
console.error('Get notebook error:', error);
return NextResponse.json({ error: 'Failed to get notebook' }, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const body = await request.json();
const { title, description, coverColor, isPublic } = body;
const notebook = await prisma.notebook.update({
where: { id: params.id },
data: {
...(title !== undefined && { title: title.trim() }),
...(description !== undefined && { description: description?.trim() || null }),
...(coverColor !== undefined && { coverColor }),
...(isPublic !== undefined && { isPublic }),
},
});
return NextResponse.json(notebook);
} catch (error) {
console.error('Update notebook error:', error);
return NextResponse.json({ error: 'Failed to update notebook' }, { status: 500 });
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
await prisma.notebook.delete({ where: { id: params.id } });
return NextResponse.json({ ok: true });
} catch (error) {
console.error('Delete notebook error:', error);
return NextResponse.json({ error: 'Failed to delete notebook' }, { status: 500 });
}
}

View File

@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { generateSlug } from '@/lib/slug';
import { nanoid } from 'nanoid';
export async function GET() {
try {
const notebooks = await prisma.notebook.findMany({
include: {
_count: { select: { notes: true } },
collaborators: {
include: { user: { select: { id: true, username: true } } },
},
},
orderBy: [{ sortOrder: 'asc' }, { updatedAt: 'desc' }],
});
return NextResponse.json(notebooks);
} catch (error) {
console.error('List notebooks error:', error);
return NextResponse.json({ error: 'Failed to list notebooks' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { title, description, coverColor } = body;
if (!title?.trim()) {
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
}
const baseSlug = generateSlug(title);
const slug = baseSlug || nanoid(8);
// Ensure unique slug
const existing = await prisma.notebook.findUnique({ where: { slug } });
const finalSlug = existing ? `${slug}-${nanoid(4)}` : slug;
const notebook = await prisma.notebook.create({
data: {
title: title.trim(),
slug: finalSlug,
description: description?.trim() || null,
coverColor: coverColor || '#f59e0b',
},
});
return NextResponse.json(notebook, { status: 201 });
} catch (error) {
console.error('Create notebook error:', error);
return NextResponse.json({ error: 'Failed to create notebook' }, { status: 500 });
}
}

View File

@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { stripHtml } from '@/lib/strip-html';
export async function GET(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const note = await prisma.note.findUnique({
where: { id: params.id },
include: {
tags: { include: { tag: true } },
notebook: { select: { id: true, title: true, slug: true } },
author: { select: { id: true, username: true } },
},
});
if (!note) {
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
}
return NextResponse.json(note);
} catch (error) {
console.error('Get note error:', error);
return NextResponse.json({ error: 'Failed to get note' }, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const body = await request.json();
const { title, content, type, url, language, isPinned, notebookId, tags } = body;
const data: Record<string, unknown> = {};
if (title !== undefined) data.title = title.trim();
if (content !== undefined) {
data.content = content;
data.contentPlain = stripHtml(content);
}
if (type !== undefined) data.type = type;
if (url !== undefined) data.url = url || null;
if (language !== undefined) data.language = language || null;
if (isPinned !== undefined) data.isPinned = isPinned;
if (notebookId !== undefined) data.notebookId = notebookId || null;
// Handle tag updates: replace all tags
if (tags !== undefined && Array.isArray(tags)) {
// Delete existing tag links
await prisma.noteTag.deleteMany({ where: { noteId: params.id } });
// Find or create tags and link
for (const tagName of tags) {
const name = tagName.trim().toLowerCase();
if (!name) continue;
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name },
});
await prisma.noteTag.create({
data: { noteId: params.id, tagId: tag.id },
});
}
}
const note = await prisma.note.update({
where: { id: params.id },
data,
include: {
tags: { include: { tag: true } },
notebook: { select: { id: true, title: true, slug: true } },
},
});
return NextResponse.json(note);
} catch (error) {
console.error('Update note error:', error);
return NextResponse.json({ error: 'Failed to update note' }, { status: 500 });
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
await prisma.note.delete({ where: { id: params.id } });
return NextResponse.json({ ok: true });
} catch (error) {
console.error('Delete note error:', error);
return NextResponse.json({ error: 'Failed to delete note' }, { status: 500 });
}
}

View File

@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { stripHtml } from '@/lib/strip-html';
import { NoteType } from '@prisma/client';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const notebookId = searchParams.get('notebookId');
const type = searchParams.get('type');
const tag = searchParams.get('tag');
const pinned = searchParams.get('pinned');
const where: Record<string, unknown> = {};
if (notebookId) where.notebookId = notebookId;
if (type) where.type = type as NoteType;
if (pinned === 'true') where.isPinned = true;
if (tag) {
where.tags = { some: { tag: { name: tag.toLowerCase() } } };
}
const notes = await prisma.note.findMany({
where,
include: {
tags: { include: { tag: true } },
notebook: { select: { id: true, title: true, slug: true } },
},
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
take: 100,
});
return NextResponse.json(notes);
} catch (error) {
console.error('List notes error:', error);
return NextResponse.json({ error: 'Failed to list notes' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { title, content, type, notebookId, url, language, tags } = body;
if (!title?.trim()) {
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
}
const contentPlain = content ? stripHtml(content) : null;
// Find or create tags
const tagRecords = [];
if (tags && Array.isArray(tags)) {
for (const tagName of tags) {
const name = tagName.trim().toLowerCase();
if (!name) continue;
const tag = await prisma.tag.upsert({
where: { name },
update: {},
create: { name },
});
tagRecords.push(tag);
}
}
const note = await prisma.note.create({
data: {
title: title.trim(),
content: content || '',
contentPlain,
type: type || 'NOTE',
notebookId: notebookId || null,
url: url || null,
language: language || null,
tags: {
create: tagRecords.map((tag) => ({
tagId: tag.id,
})),
},
},
include: {
tags: { include: { tag: true } },
notebook: { select: { id: true, title: true, slug: true } },
},
});
return NextResponse.json(note, { status: 201 });
} catch (error) {
console.error('Create note error:', error);
return NextResponse.json({ error: 'Failed to create note' }, { status: 500 });
}
}

View File

@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const q = searchParams.get('q')?.trim();
const type = searchParams.get('type');
const notebookId = searchParams.get('notebookId');
if (!q) {
return NextResponse.json({ error: 'Query parameter q is required' }, { status: 400 });
}
// Use Prisma contains for search (works without GIN index)
// Can upgrade to raw SQL full-text search once GIN index is in place
const where: Record<string, unknown> = {
OR: [
{ title: { contains: q, mode: 'insensitive' } },
{ contentPlain: { contains: q, mode: 'insensitive' } },
{ content: { contains: q, mode: 'insensitive' } },
],
};
if (type) where.type = type;
if (notebookId) where.notebookId = notebookId;
const notes = await prisma.note.findMany({
where,
include: {
tags: { include: { tag: true } },
notebook: { select: { id: true, title: true } },
},
orderBy: { updatedAt: 'desc' },
take: 50,
});
const results = notes.map((note) => {
// Build snippet from contentPlain
const plain = note.contentPlain || note.content || '';
const idx = plain.toLowerCase().indexOf(q.toLowerCase());
const start = Math.max(0, idx - 50);
const end = Math.min(plain.length, idx + q.length + 100);
const snippet = (start > 0 ? '...' : '') + plain.slice(start, end) + (end < plain.length ? '...' : '');
return {
id: note.id,
title: note.title,
snippet,
type: note.type,
notebookId: note.notebookId,
notebookTitle: note.notebook?.title || null,
updatedAt: note.updatedAt.toISOString(),
tags: note.tags.map((nt) => ({
id: nt.tag.id,
name: nt.tag.name,
color: nt.tag.color,
})),
};
});
return NextResponse.json(results);
} catch (error) {
console.error('Search notes error:', error);
return NextResponse.json({ error: 'Failed to search notes' }, { status: 500 });
}
}

76
src/app/api/sync/route.ts Normal file
View File

@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
/**
* POST /api/sync
*
* Receives shape update events from the rSpace canvas (via postMessage CanvasEmbed fetch)
* and updates the corresponding DB records.
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { shapeId, type, data } = body;
if (!shapeId || !type) {
return NextResponse.json({ error: 'Missing shapeId or type' }, { status: 400 });
}
const shapeType = data?.type as string | undefined;
if (type === 'shape-deleted') {
// Clear canvasShapeId references (don't delete the DB record)
await Promise.all([
prisma.note.updateMany({
where: { canvasShapeId: shapeId },
data: { canvasShapeId: null },
}),
prisma.notebook.updateMany({
where: { canvasShapeId: shapeId },
data: { canvasShapeId: null },
}),
]);
return NextResponse.json({ ok: true, action: 'unlinked' });
}
// shape-updated: try to match and update
if (shapeType === 'folk-note') {
const note = await prisma.note.findFirst({
where: { canvasShapeId: shapeId },
});
if (note) {
await prisma.note.update({
where: { id: note.id },
data: {
title: (data.noteTitle as string) || note.title,
},
});
return NextResponse.json({ ok: true, action: 'updated', entity: 'note', id: note.id });
}
}
if (shapeType === 'folk-notebook') {
const notebook = await prisma.notebook.findFirst({
where: { canvasShapeId: shapeId },
});
if (notebook) {
await prisma.notebook.update({
where: { id: notebook.id },
data: {
title: (data.notebookTitle as string) || notebook.title,
description: (data.description as string) ?? notebook.description,
},
});
return NextResponse.json({ ok: true, action: 'updated', entity: 'notebook', id: notebook.id });
}
}
return NextResponse.json({ ok: true, action: 'no-match' });
} catch (error) {
console.error('Canvas sync error:', error);
return NextResponse.json({ error: 'Failed to sync canvas update' }, { status: 500 });
}
}

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

@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
})
export const metadata: Metadata = {
title: 'rNotes - Universal Knowledge Capture',
description: 'Capture notes, clips, bookmarks, code, and files. Organize in notebooks, tag freely, and collaborate on a visual canvas shared across r*Spaces.',
openGraph: {
title: 'rNotes - Universal Knowledge Capture',
description: 'Capture notes, clips, bookmarks, code, and files with a collaborative canvas.',
type: 'website',
url: 'https://rnotes.online',
},
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body className={`${inter.variable} font-sans antialiased`}>
{children}
</body>
</html>
)
}

View File

@ -0,0 +1,64 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { CanvasEmbed } from '@/components/CanvasEmbed';
export default function FullScreenCanvas() {
const params = useParams();
const router = useRouter();
const [canvasSlug, setCanvasSlug] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/notebooks/${params.id}`)
.then((res) => res.json())
.then((nb) => setCanvasSlug(nb.canvasSlug))
.catch(console.error)
.finally(() => setLoading(false));
}, [params.id]);
if (loading) {
return (
<div className="h-screen bg-[#0a0a0a] flex items-center justify-center">
<svg className="animate-spin h-8 w-8 text-amber-400" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
);
}
if (!canvasSlug) {
return (
<div className="h-screen bg-[#0a0a0a] flex items-center justify-center text-white">
<div className="text-center">
<p className="text-slate-400 mb-4">No canvas linked to this notebook yet.</p>
<button
onClick={() => router.back()}
className="text-amber-400 hover:text-amber-300"
>
Back to Notebook
</button>
</div>
</div>
);
}
return (
<div className="h-screen bg-[#0a0a0a] relative">
<div className="absolute top-4 left-4 z-20">
<button
onClick={() => router.push(`/notebooks/${params.id}`)}
className="px-4 py-2 bg-slate-800/90 hover:bg-slate-700 border border-slate-600/50 rounded-lg text-sm text-white backdrop-blur-sm transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Notebook
</button>
</div>
<CanvasEmbed canvasSlug={canvasSlug} className="h-full w-full" />
</div>
);
}

View File

@ -0,0 +1,225 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { NoteCard } from '@/components/NoteCard';
import { CanvasEmbed } from '@/components/CanvasEmbed';
interface NoteData {
id: string;
title: string;
type: string;
contentPlain: string | null;
isPinned: boolean;
updatedAt: string;
url: string | null;
tags: { tag: { id: string; name: string; color: string | null } }[];
}
interface NotebookData {
id: string;
title: string;
description: string | null;
coverColor: string;
canvasSlug: string | null;
isPublic: boolean;
notes: NoteData[];
_count: { notes: number };
}
export default function NotebookDetailPage() {
const params = useParams();
const router = useRouter();
const [notebook, setNotebook] = useState<NotebookData | null>(null);
const [loading, setLoading] = useState(true);
const [showCanvas, setShowCanvas] = useState(false);
const [creatingCanvas, setCreatingCanvas] = useState(false);
const [tab, setTab] = useState<'notes' | 'pinned'>('notes');
const fetchNotebook = useCallback(() => {
fetch(`/api/notebooks/${params.id}`)
.then((res) => res.json())
.then(setNotebook)
.catch(console.error)
.finally(() => setLoading(false));
}, [params.id]);
useEffect(() => {
fetchNotebook();
}, [fetchNotebook]);
const handleCreateCanvas = async () => {
if (creatingCanvas) return;
setCreatingCanvas(true);
try {
const res = await fetch(`/api/notebooks/${params.id}/canvas`, { method: 'POST' });
if (res.ok) {
fetchNotebook();
setShowCanvas(true);
}
} catch (error) {
console.error('Failed to create canvas:', error);
} finally {
setCreatingCanvas(false);
}
};
const handleDelete = async () => {
if (!confirm('Delete this notebook and all its notes?')) return;
await fetch(`/api/notebooks/${params.id}`, { method: 'DELETE' });
router.push('/notebooks');
};
if (loading) {
return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
<svg className="animate-spin h-8 w-8 text-amber-400" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
);
}
if (!notebook) {
return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center text-white">
Notebook not found
</div>
);
}
const filteredNotes = tab === 'pinned'
? notebook.notes.filter((n) => n.isPinned)
: notebook.notes;
return (
<div className="min-h-screen bg-[#0a0a0a]">
<nav className="border-b border-slate-800 px-6 py-4">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/" className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-sm font-bold text-black">
rN
</div>
</Link>
<span className="text-slate-600">/</span>
<Link href="/notebooks" className="text-slate-400 hover:text-white transition-colors">Notebooks</Link>
<span className="text-slate-600">/</span>
<span className="text-white">{notebook.title}</span>
</div>
<div className="flex items-center gap-3">
{notebook.canvasSlug ? (
<button
onClick={() => setShowCanvas(!showCanvas)}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
showCanvas
? 'bg-amber-500/20 text-amber-400 border border-amber-500/30'
: 'bg-slate-800 text-slate-400 border border-slate-700 hover:text-white'
}`}
>
{showCanvas ? 'Hide Canvas' : 'Show Canvas'}
</button>
) : (
<button
onClick={handleCreateCanvas}
disabled={creatingCanvas}
className="px-3 py-1.5 text-sm bg-slate-800 text-slate-400 border border-slate-700 rounded-lg hover:text-white transition-colors"
>
{creatingCanvas ? 'Creating...' : 'Create Canvas'}
</button>
)}
<Link
href={`/notes/new?notebookId=${notebook.id}`}
className="px-4 py-2 bg-amber-500 hover:bg-amber-400 text-black text-sm font-medium rounded-lg transition-colors"
>
Add Note
</Link>
<button
onClick={handleDelete}
className="px-3 py-1.5 text-sm text-red-400 hover:text-red-300 border border-red-900/30 hover:border-red-800 rounded-lg transition-colors"
>
Delete
</button>
</div>
</div>
</nav>
<div className={`flex ${showCanvas ? 'gap-0' : ''}`}>
{/* Notes panel */}
<main className={`${showCanvas ? 'w-3/5' : 'w-full'} max-w-6xl mx-auto px-6 py-8`}>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: notebook.coverColor }} />
<h1 className="text-3xl font-bold text-white">{notebook.title}</h1>
</div>
{notebook.description && (
<p className="text-slate-400 ml-7">{notebook.description}</p>
)}
<p className="text-sm text-slate-500 ml-7 mt-1">{notebook._count.notes} notes</p>
</div>
{/* Tabs */}
<div className="flex gap-4 border-b border-slate-800 mb-6">
<button
onClick={() => setTab('notes')}
className={`pb-3 text-sm font-medium transition-colors ${
tab === 'notes'
? 'text-amber-400 border-b-2 border-amber-400'
: 'text-slate-400 hover:text-white'
}`}
>
All Notes
</button>
<button
onClick={() => setTab('pinned')}
className={`pb-3 text-sm font-medium transition-colors ${
tab === 'pinned'
? 'text-amber-400 border-b-2 border-amber-400'
: 'text-slate-400 hover:text-white'
}`}
>
Pinned
</button>
</div>
{/* Notes grid */}
{filteredNotes.length === 0 ? (
<div className="text-center py-12 text-slate-400">
{tab === 'pinned' ? 'No pinned notes' : 'No notes yet. Add one!'}
</div>
) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-3">
{filteredNotes.map((note) => (
<NoteCard
key={note.id}
id={note.id}
title={note.title}
type={note.type}
contentPlain={note.contentPlain}
isPinned={note.isPinned}
updatedAt={note.updatedAt}
url={note.url}
tags={note.tags.map((nt) => ({
id: nt.tag.id,
name: nt.tag.name,
color: nt.tag.color,
}))}
/>
))}
</div>
)}
</main>
{/* Canvas sidebar */}
{showCanvas && notebook.canvasSlug && (
<div className="w-2/5 border-l border-slate-800 sticky top-0 h-screen">
<CanvasEmbed canvasSlug={notebook.canvasSlug} className="h-full" />
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,120 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
const COVER_COLORS = [
'#f59e0b', '#ef4444', '#8b5cf6', '#3b82f6',
'#10b981', '#ec4899', '#f97316', '#6366f1',
];
export default function NewNotebookPage() {
const router = useRouter();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [coverColor, setCoverColor] = useState('#f59e0b');
const [saving, setSaving] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || saving) return;
setSaving(true);
try {
const res = await fetch('/api/notebooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description, coverColor }),
});
const notebook = await res.json();
if (res.ok) {
router.push(`/notebooks/${notebook.id}`);
}
} catch (error) {
console.error('Failed to create notebook:', error);
} finally {
setSaving(false);
}
};
return (
<div className="min-h-screen bg-[#0a0a0a]">
<nav className="border-b border-slate-800 px-6 py-4">
<div className="max-w-6xl mx-auto flex items-center gap-3">
<Link href="/" className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-sm font-bold text-black">
rN
</div>
</Link>
<span className="text-slate-600">/</span>
<Link href="/notebooks" className="text-slate-400 hover:text-white transition-colors">Notebooks</Link>
<span className="text-slate-600">/</span>
<span className="text-white">New</span>
</div>
</nav>
<main className="max-w-2xl mx-auto px-6 py-12">
<h1 className="text-3xl font-bold text-white mb-8">Create Notebook</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="My Research Notes"
className="w-full px-4 py-3 bg-slate-800/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/50"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Description (optional)</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What's this notebook about?"
rows={3}
className="w-full px-4 py-3 bg-slate-800/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/50 resize-y"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Cover Color</label>
<div className="flex gap-3">
{COVER_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setCoverColor(color)}
className={`w-8 h-8 rounded-full transition-all ${
coverColor === color ? 'ring-2 ring-white ring-offset-2 ring-offset-[#0a0a0a] scale-110' : 'hover:scale-105'
}`}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={!title.trim() || saving}
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 disabled:bg-slate-700 disabled:text-slate-400 text-black font-semibold rounded-lg transition-colors"
>
{saving ? 'Creating...' : 'Create Notebook'}
</button>
<Link
href="/notebooks"
className="px-6 py-3 border border-slate-700 hover:border-slate-600 text-white rounded-lg transition-colors"
>
Cancel
</Link>
</div>
</form>
</main>
</div>
);
}

View File

@ -0,0 +1,93 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { NotebookCard } from '@/components/NotebookCard';
import { SearchBar } from '@/components/SearchBar';
interface NotebookData {
id: string;
title: string;
description: string | null;
coverColor: string;
updatedAt: string;
_count: { notes: number };
}
export default function NotebooksPage() {
const [notebooks, setNotebooks] = useState<NotebookData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/notebooks')
.then((res) => res.json())
.then(setNotebooks)
.catch(console.error)
.finally(() => setLoading(false));
}, []);
return (
<div className="min-h-screen bg-[#0a0a0a]">
<nav className="border-b border-slate-800 px-6 py-4">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/" className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-sm font-bold text-black">
rN
</div>
<span className="text-lg font-semibold text-white">rNotes</span>
</Link>
<span className="text-slate-600">/</span>
<span className="text-slate-400">Notebooks</span>
</div>
<div className="flex items-center gap-4">
<div className="w-64">
<SearchBar />
</div>
<Link
href="/notebooks/new"
className="px-4 py-2 bg-amber-500 hover:bg-amber-400 text-black text-sm font-medium rounded-lg transition-colors"
>
New Notebook
</Link>
</div>
</div>
</nav>
<main className="max-w-6xl mx-auto px-6 py-8">
{loading ? (
<div className="flex items-center justify-center py-20">
<svg className="animate-spin h-8 w-8 text-amber-400" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
) : notebooks.length === 0 ? (
<div className="text-center py-20">
<p className="text-slate-400 mb-4">No notebooks yet. Create your first one!</p>
<Link
href="/notebooks/new"
className="inline-flex px-6 py-3 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-lg transition-colors"
>
Create Notebook
</Link>
</div>
) : (
<div className="grid md:grid-cols-3 gap-4">
{notebooks.map((nb) => (
<NotebookCard
key={nb.id}
id={nb.id}
title={nb.title}
description={nb.description}
coverColor={nb.coverColor}
noteCount={nb._count.notes}
updatedAt={nb.updatedAt}
/>
))}
</div>
)}
</main>
</div>
);
}

253
src/app/notes/[id]/page.tsx Normal file
View File

@ -0,0 +1,253 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { NoteEditor } from '@/components/NoteEditor';
import { TagBadge } from '@/components/TagBadge';
const TYPE_COLORS: Record<string, string> = {
NOTE: 'bg-amber-500/20 text-amber-400',
CLIP: 'bg-purple-500/20 text-purple-400',
BOOKMARK: 'bg-blue-500/20 text-blue-400',
CODE: 'bg-green-500/20 text-green-400',
IMAGE: 'bg-pink-500/20 text-pink-400',
FILE: 'bg-slate-500/20 text-slate-400',
};
interface NoteData {
id: string;
title: string;
content: string;
contentPlain: string | null;
type: string;
url: string | null;
language: string | null;
isPinned: boolean;
canvasShapeId: string | null;
createdAt: string;
updatedAt: string;
notebook: { id: string; title: string; slug: string } | null;
tags: { tag: { id: string; name: string; color: string | null } }[];
}
export default function NoteDetailPage() {
const params = useParams();
const router = useRouter();
const [note, setNote] = useState<NoteData | null>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [editTitle, setEditTitle] = useState('');
const [editContent, setEditContent] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
fetch(`/api/notes/${params.id}`)
.then((res) => res.json())
.then((data) => {
setNote(data);
setEditTitle(data.title);
setEditContent(data.content);
})
.catch(console.error)
.finally(() => setLoading(false));
}, [params.id]);
const handleSave = async () => {
if (saving) return;
setSaving(true);
try {
const res = await fetch(`/api/notes/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: editTitle, content: editContent }),
});
if (res.ok) {
const updated = await res.json();
setNote(updated);
setEditing(false);
}
} catch (error) {
console.error('Failed to save:', error);
} finally {
setSaving(false);
}
};
const handleTogglePin = async () => {
if (!note) return;
const res = await fetch(`/api/notes/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isPinned: !note.isPinned }),
});
if (res.ok) {
const updated = await res.json();
setNote(updated);
}
};
const handleDelete = async () => {
if (!confirm('Delete this note?')) return;
await fetch(`/api/notes/${params.id}`, { method: 'DELETE' });
if (note?.notebook) {
router.push(`/notebooks/${note.notebook.id}`);
} else {
router.push('/');
}
};
if (loading) {
return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
<svg className="animate-spin h-8 w-8 text-amber-400" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
);
}
if (!note) {
return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center text-white">
Note not found
</div>
);
}
return (
<div className="min-h-screen bg-[#0a0a0a]">
<nav className="border-b border-slate-800 px-6 py-4">
<div className="max-w-4xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/" className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-sm font-bold text-black">
rN
</div>
</Link>
<span className="text-slate-600">/</span>
{note.notebook ? (
<>
<Link href={`/notebooks/${note.notebook.id}`} className="text-slate-400 hover:text-white transition-colors">
{note.notebook.title}
</Link>
<span className="text-slate-600">/</span>
</>
) : null}
<span className="text-white truncate max-w-[200px]">{note.title}</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleTogglePin}
className={`px-3 py-1.5 text-sm rounded-lg border transition-colors ${
note.isPinned
? 'bg-amber-500/20 text-amber-400 border-amber-500/30'
: 'text-slate-400 border-slate-700 hover:text-white'
}`}
>
{note.isPinned ? 'Unpin' : 'Pin to Canvas'}
</button>
{editing ? (
<>
<button
onClick={handleSave}
disabled={saving}
className="px-3 py-1.5 text-sm bg-amber-500 hover:bg-amber-400 text-black font-medium rounded-lg transition-colors"
>
{saving ? 'Saving...' : 'Save'}
</button>
<button
onClick={() => {
setEditing(false);
setEditTitle(note.title);
setEditContent(note.content);
}}
className="px-3 py-1.5 text-sm text-slate-400 border border-slate-700 rounded-lg hover:text-white transition-colors"
>
Cancel
</button>
</>
) : (
<button
onClick={() => setEditing(true)}
className="px-3 py-1.5 text-sm text-slate-400 border border-slate-700 rounded-lg hover:text-white transition-colors"
>
Edit
</button>
)}
<button
onClick={handleDelete}
className="px-3 py-1.5 text-sm text-red-400 hover:text-red-300 border border-red-900/30 rounded-lg transition-colors"
>
Delete
</button>
</div>
</div>
</nav>
<main className="max-w-4xl mx-auto px-6 py-8">
{/* Metadata */}
<div className="flex items-center gap-3 mb-6">
<span className={`text-xs font-bold uppercase px-2 py-1 rounded ${TYPE_COLORS[note.type] || TYPE_COLORS.NOTE}`}>
{note.type}
</span>
{note.tags.map((nt) => (
<TagBadge key={nt.tag.id} name={nt.tag.name} color={nt.tag.color} />
))}
<span className="text-xs text-slate-500 ml-auto">
Created {new Date(note.createdAt).toLocaleDateString()} &middot; Updated {new Date(note.updatedAt).toLocaleDateString()}
</span>
</div>
{/* URL */}
{note.url && (
<a
href={note.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-400 hover:text-blue-300 mb-4 block truncate"
>
{note.url}
</a>
)}
{/* Content */}
{editing ? (
<div className="space-y-4">
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="w-full text-3xl font-bold bg-transparent text-white border-b border-slate-700 pb-2 focus:outline-none focus:border-amber-500/50"
/>
<NoteEditor
value={editContent}
onChange={setEditContent}
type={note.type}
/>
</div>
) : (
<div>
<h1 className="text-3xl font-bold text-white mb-6">{note.title}</h1>
{note.type === 'CODE' ? (
<pre className="bg-slate-800/50 border border-slate-700 rounded-lg p-4 overflow-x-auto">
<code className="text-sm text-slate-200 font-mono">
{note.content}
</code>
</pre>
) : (
<div className="prose prose-invert prose-sm max-w-none">
<div
className="whitespace-pre-wrap text-slate-300 leading-relaxed"
>
{note.content}
</div>
</div>
)}
</div>
)}
</main>
</div>
);
}

238
src/app/notes/new/page.tsx Normal file
View File

@ -0,0 +1,238 @@
'use client';
import { Suspense, useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { NoteEditor } from '@/components/NoteEditor';
const NOTE_TYPES = [
{ value: 'NOTE', label: 'Note', desc: 'Rich text note' },
{ value: 'CLIP', label: 'Clip', desc: 'Web clipping' },
{ value: 'BOOKMARK', label: 'Bookmark', desc: 'Save a URL' },
{ value: 'CODE', label: 'Code', desc: 'Code snippet' },
{ value: 'IMAGE', label: 'Image', desc: 'Image URL' },
{ value: 'FILE', label: 'File', desc: 'File reference' },
];
interface NotebookOption {
id: string;
title: string;
}
export default function NewNotePage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
<svg className="animate-spin h-8 w-8 text-amber-400" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
}>
<NewNoteForm />
</Suspense>
);
}
function NewNoteForm() {
const router = useRouter();
const searchParams = useSearchParams();
const preselectedNotebook = searchParams.get('notebookId');
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [type, setType] = useState('NOTE');
const [url, setUrl] = useState('');
const [language, setLanguage] = useState('');
const [tags, setTags] = useState('');
const [notebookId, setNotebookId] = useState(preselectedNotebook || '');
const [notebooks, setNotebooks] = useState<NotebookOption[]>([]);
const [saving, setSaving] = useState(false);
useEffect(() => {
fetch('/api/notebooks')
.then((res) => res.json())
.then((data) => setNotebooks(data.map((nb: NotebookOption) => ({ id: nb.id, title: nb.title }))))
.catch(console.error);
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || saving) return;
setSaving(true);
try {
const body: Record<string, unknown> = {
title,
content,
type,
tags: tags.split(',').map((t) => t.trim()).filter(Boolean),
};
if (notebookId) body.notebookId = notebookId;
if (url) body.url = url;
if (language) body.language = language;
const endpoint = notebookId
? `/api/notebooks/${notebookId}/notes`
: '/api/notes';
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const note = await res.json();
if (res.ok) {
router.push(`/notes/${note.id}`);
}
} catch (error) {
console.error('Failed to create note:', error);
} finally {
setSaving(false);
}
};
const showUrl = ['CLIP', 'BOOKMARK', 'IMAGE', 'FILE'].includes(type);
const showLanguage = type === 'CODE';
return (
<div className="min-h-screen bg-[#0a0a0a]">
<nav className="border-b border-slate-800 px-6 py-4">
<div className="max-w-6xl mx-auto flex items-center gap-3">
<Link href="/" className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-sm font-bold text-black">
rN
</div>
</Link>
<span className="text-slate-600">/</span>
<span className="text-white">New Note</span>
</div>
</nav>
<main className="max-w-3xl mx-auto px-6 py-12">
<h1 className="text-3xl font-bold text-white mb-8">Create Note</h1>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Type selector */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Type</label>
<div className="flex flex-wrap gap-2">
{NOTE_TYPES.map((t) => (
<button
key={t.value}
type="button"
onClick={() => setType(t.value)}
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
type === t.value
? 'bg-amber-500/20 text-amber-400 border-amber-500/30'
: 'bg-slate-800/50 text-slate-400 border-slate-700 hover:text-white hover:border-slate-600'
}`}
>
{t.label}
</button>
))}
</div>
</div>
{/* Title */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Note title"
className="w-full px-4 py-3 bg-slate-800/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/50"
autoFocus
/>
</div>
{/* URL field */}
{showUrl && (
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">URL</label>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://..."
className="w-full px-4 py-3 bg-slate-800/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/50"
/>
</div>
)}
{/* Language field */}
{showLanguage && (
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Language</label>
<input
type="text"
value={language}
onChange={(e) => setLanguage(e.target.value)}
placeholder="typescript, python, rust..."
className="w-full px-4 py-3 bg-slate-800/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/50"
/>
</div>
)}
{/* Content */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Content</label>
<NoteEditor
value={content}
onChange={setContent}
type={type}
placeholder={type === 'CODE' ? 'Paste your code here...' : 'Write in Markdown...'}
/>
</div>
{/* Notebook */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Notebook (optional)</label>
<select
value={notebookId}
onChange={(e) => setNotebookId(e.target.value)}
className="w-full px-4 py-3 bg-slate-800/50 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-amber-500/50"
>
<option value="">No notebook (standalone)</option>
{notebooks.map((nb) => (
<option key={nb.id} value={nb.id}>{nb.title}</option>
))}
</select>
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Tags (comma-separated)</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="research, web3, draft"
className="w-full px-4 py-3 bg-slate-800/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/50"
/>
</div>
{/* Submit */}
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={!title.trim() || saving}
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 disabled:bg-slate-700 disabled:text-slate-400 text-black font-semibold rounded-lg transition-colors"
>
{saving ? 'Creating...' : 'Create Note'}
</button>
<button
type="button"
onClick={() => router.back()}
className="px-6 py-3 border border-slate-700 hover:border-slate-600 text-white rounded-lg transition-colors"
>
Cancel
</button>
</div>
</form>
</main>
</div>
);
}

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

@ -0,0 +1,165 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { NotebookCard } from '@/components/NotebookCard';
import { SearchBar } from '@/components/SearchBar';
interface NotebookData {
id: string;
title: string;
description: string | null;
coverColor: string;
updatedAt: string;
_count: { notes: number };
}
export default function HomePage() {
const [notebooks, setNotebooks] = useState<NotebookData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/notebooks')
.then((res) => res.json())
.then(setNotebooks)
.catch(console.error)
.finally(() => setLoading(false));
}, []);
return (
<div className="min-h-screen bg-[#0a0a0a]">
{/* Nav */}
<nav className="border-b border-slate-800 px-6 py-4">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-sm font-bold text-black">
rN
</div>
<span className="text-lg font-semibold text-white">rNotes</span>
</div>
<div className="flex items-center gap-4">
<div className="w-64">
<SearchBar />
</div>
<Link
href="/notebooks"
className="text-sm text-slate-400 hover:text-white transition-colors"
>
Notebooks
</Link>
<Link
href="/notes/new"
className="px-4 py-2 bg-amber-500 hover:bg-amber-400 text-black text-sm font-medium rounded-lg transition-colors"
>
New Note
</Link>
</div>
</div>
</nav>
{/* Hero */}
<section className="py-20 px-6">
<div className="max-w-4xl mx-auto text-center">
<h1 className="text-5xl font-bold mb-4">
<span className="bg-gradient-to-r from-amber-400 to-orange-500 bg-clip-text text-transparent">
Capture Everything
</span>
<br />
<span className="text-white">Find Anything</span>
</h1>
<p className="text-lg text-slate-400 mb-8 max-w-2xl mx-auto">
Notes, clips, bookmarks, code, images, and files all in one place.
Organize in notebooks, tag freely, and collaborate on a visual canvas shared across r*Spaces.
</p>
<div className="flex items-center justify-center gap-4">
<Link
href="/notebooks/new"
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-lg transition-colors"
>
Create Notebook
</Link>
<Link
href="/notes/new"
className="px-6 py-3 border border-slate-700 hover:border-slate-600 text-white rounded-lg transition-colors"
>
Quick Note
</Link>
</div>
</div>
</section>
{/* How it works */}
<section className="py-16 px-6 border-t border-slate-800">
<div className="max-w-6xl mx-auto">
<h2 className="text-2xl font-bold text-white text-center mb-12">How It Works</h2>
<div className="grid md:grid-cols-3 gap-8">
<div className="text-center">
<div className="w-12 h-12 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center mx-auto mb-4">
<svg className="w-6 h-6 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<h3 className="text-lg font-semibold text-white mb-2">Capture</h3>
<p className="text-sm text-slate-400">Notes, web clips, bookmarks, code snippets, images, and files. Every type of content, one tool.</p>
</div>
<div className="text-center">
<div className="w-12 h-12 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center mx-auto mb-4">
<svg className="w-6 h-6 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-white mb-2">Organize</h3>
<p className="text-sm text-slate-400">Notebooks for structure, tags for cross-cutting themes. Full-text search finds anything instantly.</p>
</div>
<div className="text-center">
<div className="w-12 h-12 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center mx-auto mb-4">
<svg className="w-6 h-6 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-white mb-2">Collaborate</h3>
<p className="text-sm text-slate-400">Pin notes to a visual canvas. Share notebooks across r*Spaces for real-time collaboration.</p>
</div>
</div>
</div>
</section>
{/* Recent notebooks */}
{!loading && notebooks.length > 0 && (
<section className="py-16 px-6 border-t border-slate-800">
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-8">
<h2 className="text-2xl font-bold text-white">Recent Notebooks</h2>
<Link href="/notebooks" className="text-sm text-amber-400 hover:text-amber-300">
View all
</Link>
</div>
<div className="grid md:grid-cols-3 gap-4">
{notebooks.slice(0, 6).map((nb) => (
<NotebookCard
key={nb.id}
id={nb.id}
title={nb.title}
description={nb.description}
coverColor={nb.coverColor}
noteCount={nb._count.notes}
updatedAt={nb.updatedAt}
/>
))}
</div>
</div>
</section>
)}
{/* Footer */}
<footer className="border-t border-slate-800 px-6 py-8">
<div className="max-w-6xl mx-auto flex items-center justify-between text-sm text-slate-500">
<span>rNotes.online Part of the r* ecosystem</span>
<a href="https://rspace.online" className="hover:text-amber-400 transition-colors">
rSpace.online
</a>
</div>
</footer>
</div>
);
}

View File

@ -0,0 +1,49 @@
'use client';
import { useEffect, useCallback } from 'react';
import { isCanvasMessage, CanvasShapeMessage } from '@/lib/canvas-sync';
interface CanvasEmbedProps {
canvasSlug: string;
className?: string;
onShapeUpdate?: (message: CanvasShapeMessage) => void;
}
export function CanvasEmbed({ canvasSlug, className = '', onShapeUpdate }: CanvasEmbedProps) {
const canvasUrl = `https://${canvasSlug}.rspace.online`;
const handleMessage = useCallback(
(event: MessageEvent) => {
if (!isCanvasMessage(event)) return;
if (event.data.communitySlug !== canvasSlug) return;
onShapeUpdate?.(event.data);
},
[canvasSlug, onShapeUpdate]
);
useEffect(() => {
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [handleMessage]);
return (
<div className={`relative bg-slate-900 rounded-xl overflow-hidden border border-slate-700/50 ${className}`}>
<div className="absolute top-2 right-2 z-10 flex gap-2">
<a
href={canvasUrl}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 bg-slate-800/90 hover:bg-slate-700 border border-slate-600/50 rounded-lg text-xs text-slate-300 backdrop-blur-sm transition-colors"
>
Open in rSpace
</a>
</div>
<iframe
src={canvasUrl}
className="w-full h-full border-0"
allow="clipboard-write"
title="Notes Canvas"
/>
</div>
);
}

View File

@ -0,0 +1,72 @@
'use client';
import Link from 'next/link';
import { TagBadge } from './TagBadge';
const TYPE_COLORS: Record<string, string> = {
NOTE: 'bg-amber-500/20 text-amber-400',
CLIP: 'bg-purple-500/20 text-purple-400',
BOOKMARK: 'bg-blue-500/20 text-blue-400',
CODE: 'bg-green-500/20 text-green-400',
IMAGE: 'bg-pink-500/20 text-pink-400',
FILE: 'bg-slate-500/20 text-slate-400',
};
interface NoteCardProps {
id: string;
title: string;
type: string;
contentPlain?: string | null;
isPinned: boolean;
updatedAt: string;
tags: { id: string; name: string; color: string | null }[];
url?: string | null;
}
export function NoteCard({ id, title, type, contentPlain, isPinned, updatedAt, tags, url }: NoteCardProps) {
const snippet = (contentPlain || '').slice(0, 120);
return (
<Link
href={`/notes/${id}`}
className="block group bg-slate-800/50 hover:bg-slate-800 border border-slate-700/50 hover:border-slate-600 rounded-lg p-4 transition-all"
>
<div className="flex items-center gap-2 mb-2">
<span className={`text-[10px] font-bold uppercase px-1.5 py-0.5 rounded ${TYPE_COLORS[type] || TYPE_COLORS.NOTE}`}>
{type}
</span>
{isPinned && (
<span className="text-amber-400 text-xs" title="Pinned to canvas">
&#9733;
</span>
)}
<span className="text-[10px] text-slate-500 ml-auto">
{new Date(updatedAt).toLocaleDateString()}
</span>
</div>
<h4 className="text-sm font-medium text-white group-hover:text-amber-400 transition-colors line-clamp-1 mb-1">
{title}
</h4>
{url && (
<p className="text-xs text-slate-500 truncate mb-1">{url}</p>
)}
{snippet && (
<p className="text-xs text-slate-400 line-clamp-2 mb-2">{snippet}</p>
)}
{tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tags.slice(0, 4).map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
{tags.length > 4 && (
<span className="text-[10px] text-slate-500">+{tags.length - 4}</span>
)}
</div>
)}
</Link>
);
}

View File

@ -0,0 +1,57 @@
'use client';
import { useState } from 'react';
interface NoteEditorProps {
value: string;
onChange: (content: string) => void;
type?: string;
placeholder?: string;
}
export function NoteEditor({ value, onChange, type, placeholder }: NoteEditorProps) {
const [showPreview, setShowPreview] = useState(false);
const isCode = type === 'CODE';
return (
<div className="border border-slate-700 rounded-lg overflow-hidden">
<div className="flex border-b border-slate-700 bg-slate-800/50">
<button
type="button"
onClick={() => setShowPreview(false)}
className={`px-4 py-2 text-sm transition-colors ${
!showPreview ? 'text-amber-400 border-b-2 border-amber-400' : 'text-slate-400 hover:text-slate-300'
}`}
>
Edit
</button>
<button
type="button"
onClick={() => setShowPreview(true)}
className={`px-4 py-2 text-sm transition-colors ${
showPreview ? 'text-amber-400 border-b-2 border-amber-400' : 'text-slate-400 hover:text-slate-300'
}`}
>
Preview
</button>
</div>
{showPreview ? (
<div
className="p-4 min-h-[300px] prose prose-invert prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: value || '<p class="text-slate-500">Nothing to preview</p>' }}
/>
) : (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || 'Write your note in Markdown...'}
className={`w-full min-h-[300px] p-4 bg-transparent text-slate-200 placeholder-slate-600 resize-y focus:outline-none ${
isCode ? 'font-mono text-sm' : ''
}`}
/>
)}
</div>
);
}

View File

@ -0,0 +1,40 @@
'use client';
import Link from 'next/link';
interface NotebookCardProps {
id: string;
title: string;
description?: string | null;
coverColor: string;
noteCount: number;
updatedAt: string;
}
export function NotebookCard({ id, title, description, coverColor, noteCount, updatedAt }: NotebookCardProps) {
return (
<Link
href={`/notebooks/${id}`}
className="block group bg-slate-800/50 hover:bg-slate-800 border border-slate-700/50 hover:border-slate-600 rounded-xl p-5 transition-all"
>
<div className="flex items-start gap-3 mb-3">
<div
className="w-3 h-3 rounded-full mt-1.5 flex-shrink-0"
style={{ backgroundColor: coverColor }}
/>
<h3 className="text-lg font-semibold text-white group-hover:text-amber-400 transition-colors line-clamp-1">
{title}
</h3>
</div>
{description && (
<p className="text-sm text-slate-400 mb-3 line-clamp-2">{description}</p>
)}
<div className="flex items-center justify-between text-xs text-slate-500">
<span>{noteCount} {noteCount === 1 ? 'note' : 'notes'}</span>
<span>{new Date(updatedAt).toLocaleDateString()}</span>
</div>
</Link>
);
}

View File

@ -0,0 +1,101 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { SearchResult } from '@/lib/types';
interface SearchBarProps {
onResults?: (results: SearchResult[]) => void;
}
export function SearchBar({ onResults }: SearchBarProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const search = useCallback(async (q: string) => {
if (!q.trim()) {
setResults([]);
onResults?.([]);
return;
}
setLoading(true);
try {
const res = await fetch(`/api/notes/search?q=${encodeURIComponent(q)}`);
const data = await res.json();
setResults(data);
onResults?.(data);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, [onResults]);
// Debounced search
useEffect(() => {
const timer = setTimeout(() => search(query), 300);
return () => clearTimeout(timer);
}, [query, search]);
return (
<div className="relative">
<div className="relative">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
placeholder="Search notes..."
className="w-full pl-10 pr-4 py-2 bg-slate-800/50 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/50 transition-colors"
/>
{loading && (
<svg className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-amber-400 animate-spin" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
)}
</div>
{open && query && results.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-slate-800 border border-slate-700 rounded-lg shadow-xl z-50 max-h-80 overflow-y-auto">
{results.map((result) => (
<a
key={result.id}
href={`/notes/${result.id}`}
className="block px-4 py-3 hover:bg-slate-700/50 transition-colors border-b border-slate-700/50 last:border-0"
onClick={() => setOpen(false)}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-bold uppercase text-amber-400">{result.type}</span>
<span className="text-sm font-medium text-white truncate">{result.title}</span>
</div>
<p className="text-xs text-slate-400 line-clamp-1">{result.snippet}</p>
{result.notebookTitle && (
<p className="text-[10px] text-slate-500 mt-1">in {result.notebookTitle}</p>
)}
</a>
))}
</div>
)}
{open && query && !loading && results.length === 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-slate-800 border border-slate-700 rounded-lg shadow-xl z-50 p-4 text-center text-sm text-slate-400">
No results found
</div>
)}
</div>
);
}

View File

@ -0,0 +1,21 @@
interface TagBadgeProps {
name: string;
color?: string | null;
onClick?: () => void;
}
export function TagBadge({ name, color, onClick }: TagBadgeProps) {
const Component = onClick ? 'button' : 'span';
return (
<Component
onClick={onClick}
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-slate-700/50 text-slate-300 hover:bg-slate-700 transition-colors"
>
<span
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ backgroundColor: color || '#6b7280' }}
/>
{name}
</Component>
);
}

37
src/lib/canvas-sync.ts Normal file
View File

@ -0,0 +1,37 @@
/**
* Canvas sync utilities for rnotes.online <-> rspace.online integration
*/
export interface CanvasShapeMessage {
source: 'rspace-canvas';
type: 'shape-updated' | 'shape-deleted';
communitySlug: string;
shapeId: string;
data: Record<string, unknown>;
}
export function isCanvasMessage(event: MessageEvent): event is MessageEvent<CanvasShapeMessage> {
return event.data?.source === 'rspace-canvas';
}
/**
* Push shapes to an rspace canvas community via the shapes API
*/
export async function pushShapesToCanvas(
canvasSlug: string,
shapes: Record<string, unknown>[],
rspaceUrl?: string
): Promise<void> {
const baseUrl = rspaceUrl || process.env.RSPACE_INTERNAL_URL || 'http://rspace-online:3000';
const response = await fetch(`${baseUrl}/api/communities/${canvasSlug}/shapes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shapes }),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to push shapes: ${response.status} ${text}`);
}
}

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

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

9
src/lib/slug.ts Normal file
View File

@ -0,0 +1,9 @@
export function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 60);
}

10
src/lib/strip-html.ts Normal file
View File

@ -0,0 +1,10 @@
export function stripHtml(html: string): string {
return html
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/\s+/g, ' ')
.trim();
}

26
src/lib/types.ts Normal file
View File

@ -0,0 +1,26 @@
export interface NoteFormData {
title: string;
content: string;
type: 'NOTE' | 'CLIP' | 'BOOKMARK' | 'CODE' | 'IMAGE' | 'FILE';
notebookId?: string;
url?: string;
language?: string;
tags?: string[];
}
export interface NotebookFormData {
title: string;
description?: string;
coverColor?: string;
}
export interface SearchResult {
id: string;
title: string;
snippet: string;
type: string;
notebookId: string | null;
notebookTitle: string | null;
updatedAt: string;
tags: { id: string; name: string; color: string | null }[];
}