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:
parent
445a282ef1
commit
118710999b
File diff suppressed because it is too large
Load Diff
|
|
@ -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])
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ status: 'ok', service: 'rnotes-online' });
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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()} · 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
★
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
export function stripHtml(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/<[^>]*>/g, ' ')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
@ -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 }[];
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue