feat: rewrite demo page with live rSpace data via useDemoSync

Replace static mock data with real-time WebSocket connection to the
shared demo community. Notes are expandable, packing items toggleable,
all changes sync across the r* ecosystem in real-time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-15 09:39:07 -07:00
parent 2c329d7ca4
commit 30f3383d1b
3 changed files with 1111 additions and 565 deletions

View File

@ -0,0 +1,888 @@
'use client'
import Link from 'next/link'
import { useState, useMemo, useCallback } from 'react'
import { useDemoSync, type DemoShape } from '@/lib/demo-sync'
/* --- Types -------------------------------------------------------------- */
interface NotebookData {
notebookTitle: string
description: string
noteCount: number
collaborators: string[]
}
interface NoteData {
noteTitle: string
content: string
tags: string[]
editor: string
editedAt: string
}
interface PackingItem {
name: string
packed: boolean
category: string
}
interface PackingListData {
listTitle: string
items: PackingItem[]
}
/* --- Markdown Renderer -------------------------------------------------- */
function RenderMarkdown({ content }: { content: string }) {
const lines = content.split('\n')
const elements: React.ReactNode[] = []
let i = 0
while (i < lines.length) {
const line = lines[i]
// Heading 3
if (line.startsWith('### ')) {
elements.push(
<h5 key={i} className="text-sm font-semibold text-slate-300 mt-3 mb-1">
{renderInline(line.slice(4))}
</h5>
)
i++
continue
}
// Heading 2
if (line.startsWith('## ')) {
elements.push(
<h4 key={i} className="text-base font-semibold text-slate-200 mt-4 mb-2">
{renderInline(line.slice(3))}
</h4>
)
i++
continue
}
// Heading 1
if (line.startsWith('# ')) {
elements.push(
<h3 key={i} className="text-lg font-bold text-white mt-4 mb-2">
{renderInline(line.slice(2))}
</h3>
)
i++
continue
}
// Blockquote
if (line.startsWith('> ')) {
elements.push(
<div key={i} className="bg-amber-500/10 border-l-2 border-amber-500 px-4 py-2 rounded-r-lg my-2">
<p className="text-amber-200 text-sm">{renderInline(line.slice(2))}</p>
</div>
)
i++
continue
}
// Unordered list item
if (line.startsWith('- ') || line.startsWith('* ')) {
const listItems: React.ReactNode[] = []
while (i < lines.length && (lines[i].startsWith('- ') || lines[i].startsWith('* '))) {
listItems.push(
<li key={i} className="flex items-start gap-2">
<span className="text-amber-400 mt-0.5">&#8226;</span>
<span>{renderInline(lines[i].slice(2))}</span>
</li>
)
i++
}
elements.push(
<ul key={`ul-${i}`} className="space-y-1 text-slate-300 text-sm my-2">
{listItems}
</ul>
)
continue
}
// Ordered list item
if (/^\d+\.\s/.test(line)) {
const listItems: React.ReactNode[] = []
while (i < lines.length && /^\d+\.\s/.test(lines[i])) {
const text = lines[i].replace(/^\d+\.\s/, '')
listItems.push(
<li key={i} className="flex items-start gap-2">
<span className="text-amber-400 font-medium min-w-[1.2em] text-right">
{listItems.length + 1}.
</span>
<span>{renderInline(text)}</span>
</li>
)
i++
}
elements.push(
<ol key={`ol-${i}`} className="space-y-1 text-slate-300 text-sm my-2">
{listItems}
</ol>
)
continue
}
// Code block (fenced)
if (line.startsWith('```')) {
const lang = line.slice(3).trim()
const codeLines: string[] = []
i++
while (i < lines.length && !lines[i].startsWith('```')) {
codeLines.push(lines[i])
i++
}
i++ // skip closing ```
elements.push(
<div key={`code-${i}`} className="bg-slate-950 rounded-lg border border-slate-700/50 overflow-hidden my-2">
{lang && (
<div className="flex items-center justify-between px-4 py-2 bg-slate-800/50 border-b border-slate-700/50">
<span className="text-xs text-slate-400 font-mono">{lang}</span>
</div>
)}
<pre className="px-4 py-3 text-xs text-slate-300 font-mono overflow-x-auto leading-relaxed">
{codeLines.join('\n')}
</pre>
</div>
)
continue
}
// Empty line
if (line.trim() === '') {
i++
continue
}
// Regular paragraph
elements.push(
<p key={i} className="text-slate-300 text-sm my-1">
{renderInline(line)}
</p>
)
i++
}
return <div className="space-y-1">{elements}</div>
}
/** Render inline markdown: **bold**, *italic*, `code`, [links](url) */
function renderInline(text: string): React.ReactNode {
// Split on bold, italic, code, and links
const parts: React.ReactNode[] = []
let remaining = text
let key = 0
while (remaining.length > 0) {
// Bold **text**
const boldMatch = remaining.match(/^(.*?)\*\*(.+?)\*\*(.*)$/s)
if (boldMatch) {
if (boldMatch[1]) parts.push(<span key={key++}>{boldMatch[1]}</span>)
parts.push(<strong key={key++} className="text-white font-medium">{boldMatch[2]}</strong>)
remaining = boldMatch[3]
continue
}
// Italic *text*
const italicMatch = remaining.match(/^(.*?)\*(.+?)\*(.*)$/s)
if (italicMatch) {
if (italicMatch[1]) parts.push(<span key={key++}>{italicMatch[1]}</span>)
parts.push(<em key={key++} className="text-slate-300 italic">{italicMatch[2]}</em>)
remaining = italicMatch[3]
continue
}
// Inline code `text`
const codeMatch = remaining.match(/^(.*?)`(.+?)`(.*)$/s)
if (codeMatch) {
if (codeMatch[1]) parts.push(<span key={key++}>{codeMatch[1]}</span>)
parts.push(
<code key={key++} className="text-amber-300 bg-slate-800 px-1.5 py-0.5 rounded text-xs font-mono">
{codeMatch[2]}
</code>
)
remaining = codeMatch[3]
continue
}
// No more inline formatting
parts.push(<span key={key++}>{remaining}</span>)
break
}
return <>{parts}</>
}
/* --- Editor Colors ------------------------------------------------------ */
const EDITOR_COLORS: Record<string, string> = {
Maya: 'bg-teal-500',
Liam: 'bg-cyan-500',
Priya: 'bg-violet-500',
Omar: 'bg-rose-500',
Alex: 'bg-blue-500',
Sam: 'bg-green-500',
}
function editorColor(name: string): string {
return EDITOR_COLORS[name] || 'bg-slate-500'
}
/* --- Note Card Component ------------------------------------------------ */
function NoteCard({
note,
expanded,
onToggle,
}: {
note: NoteData & { id: string }
expanded: boolean
onToggle: () => void
}) {
return (
<div
className={`bg-slate-800/50 rounded-xl border border-slate-700/50 overflow-hidden ${
expanded ? 'ring-1 ring-amber-500/30' : 'hover:border-slate-600/50 cursor-pointer'
} transition-colors`}
onClick={!expanded ? onToggle : undefined}
>
<div className="p-4">
{/* Header row */}
<div className="flex items-start justify-between gap-3 mb-2">
<h3
className={`font-semibold ${expanded ? 'text-lg text-white' : 'text-sm text-slate-200'} ${!expanded ? 'cursor-pointer hover:text-white' : ''}`}
onClick={expanded ? onToggle : undefined}
>
{expanded && (
<button
onClick={(e) => { e.stopPropagation(); onToggle() }}
className="mr-2 text-slate-500 hover:text-slate-300 transition-colors"
aria-label="Collapse note"
>
<svg className="w-4 h-4 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
)}
{note.noteTitle}
</h3>
<span className="flex-shrink-0 flex items-center gap-1.5 text-xs px-2.5 py-1 bg-teal-500/10 border border-teal-500/20 text-teal-400 rounded-full">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.172 13.828a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.102 1.101" />
</svg>
Synced to rSpace
</span>
</div>
{/* Preview text (only for collapsed notes) */}
{!expanded && (
<p className="text-sm text-slate-400 mb-3 line-clamp-2">{note.content.slice(0, 150)}...</p>
)}
{/* Expanded content */}
{expanded && (
<div className="mt-4 space-y-2 text-sm">
<RenderMarkdown content={note.content} />
</div>
)}
{/* Tags */}
<div className="flex flex-wrap gap-1.5 mt-3 mb-3">
{note.tags.map((tag) => (
<span
key={tag}
className="text-xs px-2 py-0.5 bg-slate-700/50 text-slate-400 rounded-md border border-slate-600/30"
>
#{tag}
</span>
))}
</div>
{/* Footer: editor info */}
<div className="flex items-center justify-between text-xs text-slate-500">
<div className="flex items-center gap-2">
<div
className={`w-5 h-5 ${editorColor(note.editor)} rounded-full flex items-center justify-center text-[10px] font-bold text-white`}
>
{note.editor[0]}
</div>
<span className="text-slate-400">{note.editor}</span>
</div>
<span>{note.editedAt}</span>
</div>
</div>
</div>
)
}
/* --- Packing List Component --------------------------------------------- */
function PackingList({
packingList,
shapeId,
updateShape,
}: {
packingList: PackingListData
shapeId: string
updateShape: (id: string, data: Partial<DemoShape>) => void
}) {
const categories = useMemo(() => {
const cats: Record<string, PackingItem[]> = {}
for (const item of packingList.items) {
if (!cats[item.category]) cats[item.category] = []
cats[item.category].push(item)
}
return cats
}, [packingList.items])
const totalItems = packingList.items.length
const packedCount = packingList.items.filter((i) => i.packed).length
const toggleItem = useCallback(
(itemName: string) => {
const updatedItems = packingList.items.map((item) =>
item.name === itemName ? { ...item, packed: !item.packed } : item
)
updateShape(shapeId, { items: updatedItems } as Partial<DemoShape>)
},
[packingList.items, shapeId, updateShape]
)
return (
<div className="bg-slate-800/50 rounded-xl border border-slate-700/50 overflow-hidden">
{/* Header */}
<div className="px-5 py-4 border-b border-slate-700/50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-xl">🎒</span>
<div>
<h3 className="font-semibold text-white text-sm">{packingList.listTitle}</h3>
<p className="text-xs text-slate-500 mt-0.5">
{packedCount} of {totalItems} items packed
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="w-24 h-2 bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-amber-400 to-orange-500 rounded-full transition-all duration-300"
style={{ width: `${totalItems > 0 ? (packedCount / totalItems) * 100 : 0}%` }}
/>
</div>
<span className="text-xs text-slate-400">{totalItems > 0 ? Math.round((packedCount / totalItems) * 100) : 0}%</span>
</div>
</div>
</div>
{/* Categories */}
<div className="p-4 space-y-4">
{Object.entries(categories).map(([category, items]) => (
<div key={category}>
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">{category}</h4>
<div className="space-y-1">
{items.map((item) => (
<label
key={item.name}
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-slate-700/30 cursor-pointer transition-colors group"
>
<div
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
item.packed
? 'bg-amber-500 border-amber-500'
: 'border-slate-600 group-hover:border-slate-500'
}`}
onClick={(e) => {
e.preventDefault()
toggleItem(item.name)
}}
>
{item.packed && (
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<span
className={`text-sm transition-colors ${
item.packed ? 'text-slate-500 line-through' : 'text-slate-300'
}`}
>
{item.name}
</span>
</label>
))}
</div>
</div>
))}
</div>
</div>
)
}
/* --- Sidebar Component -------------------------------------------------- */
function Sidebar({
notebook,
noteCount,
}: {
notebook: NotebookData | null
noteCount: number
}) {
return (
<div className="bg-slate-800/30 rounded-xl border border-slate-700/50 overflow-hidden">
{/* Sidebar header */}
<div className="px-4 py-3 border-b border-slate-700/50">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Notebook</span>
<span className="text-xs text-slate-500">{noteCount} notes</span>
</div>
</div>
{/* Active notebook */}
<div className="p-2">
<div className="mb-1">
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm bg-amber-500/10 text-amber-300 transition-colors">
<span>📓</span>
<span className="font-medium">{notebook?.notebookTitle || 'Loading...'}</span>
</div>
<div className="ml-4 mt-0.5 space-y-0.5">
<div className="flex items-center justify-between px-3 py-1.5 rounded-md text-xs bg-slate-700/40 text-white transition-colors">
<span>Notes</span>
<span className="text-slate-600">{noteCount}</span>
</div>
<div className="flex items-center justify-between px-3 py-1.5 rounded-md text-xs text-slate-500 hover:text-slate-300 hover:bg-slate-700/20 transition-colors">
<span>Packing List</span>
<span className="text-slate-600">1</span>
</div>
</div>
</div>
</div>
{/* Quick info */}
<div className="px-4 py-3 border-t border-slate-700/50 space-y-2">
<div className="flex items-center gap-2 text-xs text-slate-500">
<svg className="w-3.5 h-3.5" 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>
<span>Search notes...</span>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
<svg className="w-3.5 h-3.5" 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>
<span>Browse tags</span>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Recent edits</span>
</div>
</div>
</div>
)
}
/* --- Loading Skeleton --------------------------------------------------- */
function LoadingSkeleton() {
return (
<div className="space-y-4 animate-pulse">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-slate-800/50 rounded-xl border border-slate-700/50 p-4">
<div className="h-4 bg-slate-700/50 rounded w-2/3 mb-3" />
<div className="h-3 bg-slate-700/30 rounded w-full mb-2" />
<div className="h-3 bg-slate-700/30 rounded w-4/5 mb-3" />
<div className="flex gap-2">
<div className="h-5 bg-slate-700/30 rounded w-16" />
<div className="h-5 bg-slate-700/30 rounded w-20" />
</div>
</div>
))}
</div>
)
}
/* --- Main Demo Content -------------------------------------------------- */
export default function DemoContent() {
const { shapes, updateShape, connected, resetDemo } = useDemoSync({
filter: ['folk-note', 'folk-notebook', 'folk-packing-list'],
})
const [expandedNotes, setExpandedNotes] = useState<Set<string>>(new Set(['demo-note-packing']))
const [resetting, setResetting] = useState(false)
// Extract data from shapes
const notebook = useMemo<NotebookData | null>(() => {
const shape = Object.values(shapes).find((s) => s.type === 'folk-notebook')
if (!shape) return null
return {
notebookTitle: (shape.notebookTitle as string) || 'Untitled Notebook',
description: (shape.description as string) || '',
noteCount: (shape.noteCount as number) || 0,
collaborators: (shape.collaborators as string[]) || [],
}
}, [shapes])
const notes = useMemo(() => {
return Object.entries(shapes)
.filter(([, s]) => s.type === 'folk-note')
.map(([id, s]) => ({
id,
noteTitle: (s.noteTitle as string) || 'Untitled Note',
content: (s.content as string) || '',
tags: (s.tags as string[]) || [],
editor: (s.editor as string) || 'Unknown',
editedAt: (s.editedAt as string) || '',
}))
}, [shapes])
const packingList = useMemo<{ data: PackingListData; shapeId: string } | null>(() => {
const entry = Object.entries(shapes).find(([, s]) => s.type === 'folk-packing-list')
if (!entry) return null
const [id, s] = entry
return {
shapeId: id,
data: {
listTitle: (s.listTitle as string) || 'Packing List',
items: (s.items as PackingItem[]) || [],
},
}
}, [shapes])
const toggleNote = useCallback((id: string) => {
setExpandedNotes((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
const handleReset = useCallback(async () => {
setResetting(true)
try {
await resetDemo()
} catch (err) {
console.error('Reset failed:', err)
} finally {
setTimeout(() => setResetting(false), 1000)
}
}, [resetDemo])
const hasData = Object.keys(shapes).length > 0
const collaborators = notebook?.collaborators || []
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
{/* Nav */}
<nav className="border-b border-slate-700/50 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-amber-400 to-orange-500 rounded-lg flex items-center justify-center font-bold text-slate-900 text-sm">
rN
</div>
<span className="font-semibold text-lg">rNotes</span>
</Link>
<span className="text-slate-600">/</span>
<span className="text-sm text-slate-400">Demo</span>
</div>
<div className="flex items-center gap-3">
{/* Connection indicator */}
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<span
className={`w-2 h-2 rounded-full ${
connected ? 'bg-green-400' : 'bg-red-400'
}`}
/>
<span className="hidden sm:inline">{connected ? 'Connected' : 'Disconnected'}</span>
</div>
<Link
href="/"
className="text-sm text-slate-400 hover:text-white transition-colors hidden sm:inline"
>
Home
</Link>
<Link
href="/demo"
className="text-sm text-amber-400 hover:text-amber-300 transition-colors hidden sm:inline"
>
Demo
</Link>
<Link
href="/notebooks/new"
className="text-sm px-4 py-2 bg-amber-500 hover:bg-amber-400 rounded-lg transition-colors font-medium text-slate-900"
>
Start Taking Notes
</Link>
</div>
</div>
</nav>
{/* Hero Section */}
<section className="max-w-7xl mx-auto px-6 pt-12 pb-8">
<div className="text-center max-w-3xl mx-auto">
{/* Badge */}
<div className="inline-flex items-center gap-2 px-4 py-1.5 bg-amber-500/10 border border-amber-500/20 rounded-full text-sm text-amber-300 mb-6">
<span
className={`w-2 h-2 rounded-full ${
connected ? 'bg-green-400 animate-pulse' : 'bg-amber-400 animate-pulse'
}`}
/>
{connected ? 'Live Demo' : 'Interactive Demo'}
</div>
<h1 className="text-4xl sm:text-5xl font-bold mb-4 bg-gradient-to-r from-amber-300 via-orange-300 to-rose-300 bg-clip-text text-transparent">
See how rNotes works
</h1>
<p className="text-lg text-slate-300 mb-2">
{notebook?.description || 'A collaborative knowledge base for your team'}
</p>
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-400 mb-6">
<span>Organized notebooks</span>
<span>Flexible tagging</span>
<span>Canvas sync</span>
<span>Real-time collaboration</span>
</div>
{/* Collaborator avatars */}
<div className="flex items-center justify-center gap-2">
{(collaborators.length > 0 ? collaborators : ['...']).map((name, i) => {
const colors = ['bg-teal-500', 'bg-cyan-500', 'bg-violet-500', 'bg-rose-500', 'bg-blue-500']
return (
<div
key={name}
className={`w-10 h-10 ${colors[i % colors.length]} rounded-full flex items-center justify-center text-sm font-bold text-white ring-2 ring-slate-800`}
title={name}
>
{name[0]}
</div>
)
})}
{collaborators.length > 0 && (
<span className="text-sm text-slate-400 ml-2">
{collaborators.length} collaborator{collaborators.length !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
</section>
{/* Context line + Reset button */}
<section className="max-w-7xl mx-auto px-6 pb-6">
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<p className="text-center text-sm text-slate-400 max-w-2xl">
This demo shows a <span className="text-slate-200 font-medium">Trip Planning Notebook</span> scenario
with notes, a packing list, tags, and canvas sync -- all powered by the{' '}
<span className="text-slate-200 font-medium">r* ecosystem</span> with live data from{' '}
<span className="text-slate-200 font-medium">rSpace</span>.
</p>
<button
onClick={handleReset}
disabled={resetting}
className="flex-shrink-0 text-xs px-4 py-2 bg-slate-700/60 hover:bg-slate-600/60 disabled:opacity-50 rounded-lg text-slate-300 hover:text-white transition-colors border border-slate-600/30"
>
{resetting ? 'Resetting...' : 'Reset Demo'}
</button>
</div>
</section>
{/* Demo Content: Sidebar + Notes + Packing List */}
<section className="max-w-7xl mx-auto px-6 pb-16">
{/* Notebook header card */}
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 overflow-hidden mb-6">
<div className="flex items-center justify-between px-5 py-3 border-b border-slate-700/50">
<div className="flex items-center gap-2">
<span className="text-xl">📓</span>
<span className="font-semibold text-sm">{notebook?.notebookTitle || 'Loading...'}</span>
<span className="text-xs text-slate-500 ml-2">
{notebook?.noteCount ?? notes.length} notes
</span>
</div>
<a
href="https://rnotes.online"
target="_blank"
rel="noopener noreferrer"
className="text-xs px-3 py-1.5 bg-slate-700/60 hover:bg-slate-600/60 rounded-lg text-slate-300 hover:text-white transition-colors"
>
Open in rNotes
</a>
</div>
<div className="px-5 py-3">
<p className="text-sm text-slate-400">{notebook?.description || 'Loading notebook data...'}</p>
</div>
</div>
{/* Main layout: sidebar + notes + packing list */}
<div className="grid lg:grid-cols-4 gap-6">
{/* Sidebar */}
<div className="lg:col-span-1">
<Sidebar notebook={notebook} noteCount={notes.length} />
</div>
{/* Notes + Packing list */}
<div className="lg:col-span-3 space-y-6">
{/* Notes section */}
<div>
{/* Section header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-slate-300">Notes</h2>
<span className="text-xs text-slate-500">{notes.length} notes</span>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
<span>Sort: Recently edited</span>
</div>
</div>
{/* Note cards or loading */}
{!hasData ? (
<LoadingSkeleton />
) : notes.length > 0 ? (
<div className="space-y-4">
{notes.map((note) => (
<NoteCard
key={note.id}
note={note}
expanded={expandedNotes.has(note.id)}
onToggle={() => toggleNote(note.id)}
/>
))}
</div>
) : (
<div className="bg-slate-800/50 rounded-xl border border-slate-700/50 p-8 text-center">
<p className="text-slate-400 text-sm">No notes found. Try resetting the demo.</p>
</div>
)}
</div>
{/* Packing List section */}
{packingList && (
<div>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-sm font-semibold text-slate-300">Packing List</h2>
</div>
<PackingList
packingList={packingList.data}
shapeId={packingList.shapeId}
updateShape={updateShape}
/>
</div>
)}
</div>
</div>
</section>
{/* Features showcase */}
<section className="max-w-7xl mx-auto px-6 pb-16">
<h2 className="text-2xl font-bold text-white text-center mb-8">Everything you need to capture knowledge</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[
{
icon: 'rich-edit',
title: 'Rich Editing',
desc: 'Headings, lists, code blocks, highlights, images, and file attachments in every note.',
},
{
icon: 'notebooks',
title: 'Notebooks',
desc: 'Organize notes into notebooks with sections. Nest as deep as you need.',
},
{
icon: 'tags',
title: 'Flexible Tags',
desc: 'Cross-cutting tags let you find notes across all notebooks instantly.',
},
{
icon: 'canvas',
title: 'Canvas Sync',
desc: 'Pin any note to your rSpace canvas for visual collaboration with your team.',
},
].map((feature) => (
<div
key={feature.title}
className="bg-slate-800/50 rounded-xl border border-slate-700/50 p-5"
>
<div className="w-10 h-10 bg-amber-500/10 rounded-lg flex items-center justify-center mb-3">
{feature.icon === 'rich-edit' && (
<svg className="w-5 h-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
)}
{feature.icon === 'notebooks' && (
<svg className="w-5 h-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
)}
{feature.icon === 'tags' && (
<svg className="w-5 h-5 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>
)}
{feature.icon === 'canvas' && (
<svg className="w-5 h-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.102 1.101" />
</svg>
)}
</div>
<h3 className="text-sm font-semibold text-white mb-1">{feature.title}</h3>
<p className="text-xs text-slate-400">{feature.desc}</p>
</div>
))}
</div>
</section>
{/* CTA Section */}
<section className="max-w-7xl mx-auto px-6 pb-20 text-center">
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 p-10">
<h2 className="text-3xl font-bold mb-3">Ready to capture everything?</h2>
<p className="text-slate-400 mb-6 max-w-lg mx-auto">
rNotes gives your team a shared knowledge base with rich editing, flexible organization,
and deep integration with the r* ecosystem -- all on a collaborative canvas.
</p>
<Link
href="/notebooks/new"
className="inline-block px-8 py-4 bg-amber-500 hover:bg-amber-400 rounded-xl text-lg font-medium transition-all shadow-lg shadow-amber-900/30 text-slate-900"
>
Start Taking Notes
</Link>
</div>
</section>
{/* Footer */}
<footer className="border-t border-slate-700/50 py-8">
<div className="max-w-7xl mx-auto px-6">
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-500 mb-4">
<span className="font-medium text-slate-400">r* Ecosystem</span>
<a href="https://rspace.online" className="hover:text-slate-300 transition-colors">rSpace</a>
<a href="https://rmaps.online" className="hover:text-slate-300 transition-colors">rMaps</a>
<a href="https://rnotes.online" className="hover:text-slate-300 transition-colors font-medium text-slate-300">rNotes</a>
<a href="https://rvote.online" className="hover:text-slate-300 transition-colors">rVote</a>
<a href="https://rfunds.online" className="hover:text-slate-300 transition-colors">rFunds</a>
<a href="https://rtrips.online" className="hover:text-slate-300 transition-colors">rTrips</a>
<a href="https://rcart.online" className="hover:text-slate-300 transition-colors">rCart</a>
<a href="https://rwallet.online" className="hover:text-slate-300 transition-colors">rWallet</a>
<a href="https://rfiles.online" className="hover:text-slate-300 transition-colors">rFiles</a>
<a href="https://rnetwork.online" className="hover:text-slate-300 transition-colors">rNetwork</a>
</div>
<p className="text-center text-xs text-slate-600">
Part of the r* ecosystem -- collaborative tools for communities.
</p>
</div>
</footer>
</div>
)
}

View File

@ -1,5 +1,5 @@
import Link from 'next/link'
import type { Metadata } from 'next'
import DemoContent from './demo-content'
export const metadata: Metadata = {
title: 'rNotes Demo - Team Knowledge Base',
@ -12,569 +12,6 @@ export const metadata: Metadata = {
},
}
/* --- Mock Data --------------------------------------------------------- */
const notebook = {
name: 'Project Alpha',
description: 'Engineering knowledge base for the Alpha product launch',
color: 'from-amber-400 to-orange-500',
noteCount: 12,
collaborators: ['Maya', 'Liam', 'Priya', 'Omar'],
}
const sidebarCategories = [
{
name: 'Project Alpha',
icon: '📓',
active: true,
children: [
{ name: 'Architecture', count: 4, active: true },
{ name: 'Meeting Notes', count: 3, active: false },
{ name: 'API Reference', count: 2, active: false },
{ name: 'Launch Checklist', count: 3, active: false },
],
},
{
name: 'Design System',
icon: '🎨',
active: false,
children: [
{ name: 'Components', count: 8, active: false },
{ name: 'Tokens', count: 2, active: false },
],
},
{
name: 'Personal',
icon: '📝',
active: false,
children: [
{ name: 'Ideas', count: 5, active: false },
{ name: 'Reading List', count: 7, active: false },
],
},
]
const notes = [
{
id: 1,
title: 'System Architecture Overview',
preview: 'High-level architecture for Project Alpha including service boundaries, data flow, and deployment topology...',
tags: ['architecture', 'backend', 'infrastructure'],
editedAt: '2 hours ago',
editor: 'Maya',
editorColor: 'bg-teal-500',
synced: true,
expanded: true,
},
{
id: 2,
title: 'API Rate Limiting Strategy',
preview: 'Token bucket algorithm with sliding window fallback. 100 req/min for free tier, 1000 req/min for pro...',
tags: ['api', 'backend', 'security'],
editedAt: '5 hours ago',
editor: 'Liam',
editorColor: 'bg-cyan-500',
synced: false,
expanded: false,
},
{
id: 3,
title: 'Sprint 14 Retro Notes',
preview: 'What went well: deployment pipeline improvements, faster CI. What to improve: test coverage on auth module...',
tags: ['meeting', 'retro', 'sprint-14'],
editedAt: 'Yesterday',
editor: 'Priya',
editorColor: 'bg-violet-500',
synced: false,
expanded: false,
},
{
id: 4,
title: 'Database Migration Plan',
preview: 'Step-by-step plan for migrating from PostgreSQL 14 to 16 with zero downtime. Includes rollback procedures...',
tags: ['database', 'migration', 'ops'],
editedAt: '2 days ago',
editor: 'Omar',
editorColor: 'bg-rose-500',
synced: false,
expanded: false,
},
{
id: 5,
title: 'Feature Flag Conventions',
preview: 'Naming: feature.<team>.<name>. Lifecycle: dev -> staging -> canary -> GA. Cleanup policy: remove after 30 days GA...',
tags: ['conventions', 'feature-flags', 'dx'],
editedAt: '3 days ago',
editor: 'Maya',
editorColor: 'bg-teal-500',
synced: false,
expanded: false,
},
]
/* --- Expanded Note Content ---------------------------------------------- */
function ExpandedNoteContent() {
return (
<div className="mt-4 space-y-4 text-sm">
{/* Heading 1 */}
<div>
<h3 className="text-lg font-bold text-white mb-2">System Architecture Overview</h3>
<p className="text-slate-400 text-xs">
Last updated by <span className="text-teal-400">Maya</span> on Feb 15, 2026
</p>
</div>
{/* Highlight block */}
<div className="bg-amber-500/10 border-l-2 border-amber-500 px-4 py-3 rounded-r-lg">
<p className="text-amber-200 text-sm">
Key Decision: We are adopting an event-driven microservices architecture with a shared message bus (NATS) for inter-service communication.
</p>
</div>
{/* Heading 2 */}
<div>
<h4 className="text-base font-semibold text-slate-200 mb-2">Service Boundaries</h4>
<ul className="space-y-1.5 text-slate-300">
<li className="flex items-start gap-2">
<span className="text-amber-400 mt-0.5">&#8226;</span>
<span><span className="text-white font-medium">Auth Service</span> -- JWT issuance, OAuth2 providers, session management</span>
</li>
<li className="flex items-start gap-2">
<span className="text-amber-400 mt-0.5">&#8226;</span>
<span><span className="text-white font-medium">Content Service</span> -- CRUD for notebooks, notes, attachments, and tags</span>
</li>
<li className="flex items-start gap-2">
<span className="text-amber-400 mt-0.5">&#8226;</span>
<span><span className="text-white font-medium">Search Service</span> -- Full-text indexing via Meilisearch, faceted filters</span>
</li>
<li className="flex items-start gap-2">
<span className="text-amber-400 mt-0.5">&#8226;</span>
<span><span className="text-white font-medium">Canvas Sync</span> -- WebSocket bridge to rSpace for real-time collaboration</span>
</li>
</ul>
</div>
{/* Heading 2 */}
<div>
<h4 className="text-base font-semibold text-slate-200 mb-2">Deployment Topology</h4>
<p className="text-slate-300 mb-3">
All services are containerized and orchestrated via Docker Compose in development, with Kubernetes for production.
</p>
</div>
{/* Code block */}
<div className="bg-slate-950 rounded-lg border border-slate-700/50 overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-slate-800/50 border-b border-slate-700/50">
<span className="text-xs text-slate-400 font-mono">docker-compose.yml</span>
<span className="text-xs text-slate-500">yaml</span>
</div>
<pre className="px-4 py-3 text-xs text-slate-300 font-mono overflow-x-auto leading-relaxed">
{`services:
api-gateway:
image: alpha/gateway:latest
ports: ["8080:8080"]
depends_on: [auth, content, search]
auth:
image: alpha/auth:latest
environment:
JWT_SECRET: \${JWT_SECRET}
OAUTH_GITHUB_ID: \${GITHUB_ID}
content:
image: alpha/content:latest
volumes: ["./data:/app/data"]
search:
image: meilisearch/meilisearch:v1.6
environment:
MEILI_MASTER_KEY: \${SEARCH_KEY}`}
</pre>
</div>
{/* Another heading */}
<div>
<h4 className="text-base font-semibold text-slate-200 mb-2">Data Flow</h4>
<p className="text-slate-300">
Client requests hit the API gateway, which routes to the appropriate service. All mutations publish events to NATS, which the search service consumes for real-time index updates.
</p>
</div>
</div>
)
}
/* --- Note Card Component ------------------------------------------------ */
function NoteCard({
note,
}: {
note: (typeof notes)[0]
}) {
return (
<div
className={`bg-slate-800/50 rounded-xl border border-slate-700/50 overflow-hidden ${
note.expanded ? 'ring-1 ring-amber-500/30' : 'hover:border-slate-600/50'
} transition-colors`}
>
<div className="p-4">
{/* Header row */}
<div className="flex items-start justify-between gap-3 mb-2">
<h3 className={`font-semibold ${note.expanded ? 'text-lg text-white' : 'text-sm text-slate-200'}`}>
{note.title}
</h3>
{note.synced && (
<span className="flex-shrink-0 flex items-center gap-1.5 text-xs px-2.5 py-1 bg-teal-500/10 border border-teal-500/20 text-teal-400 rounded-full">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.172 13.828a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.102 1.101" />
</svg>
Synced to rSpace canvas
</span>
)}
</div>
{/* Preview text (only for collapsed notes) */}
{!note.expanded && (
<p className="text-sm text-slate-400 mb-3 line-clamp-2">{note.preview}</p>
)}
{/* Expanded content */}
{note.expanded && <ExpandedNoteContent />}
{/* Tags */}
<div className="flex flex-wrap gap-1.5 mt-3 mb-3">
{note.tags.map((tag) => (
<span
key={tag}
className="text-xs px-2 py-0.5 bg-slate-700/50 text-slate-400 rounded-md border border-slate-600/30"
>
#{tag}
</span>
))}
</div>
{/* Footer: editor info */}
<div className="flex items-center justify-between text-xs text-slate-500">
<div className="flex items-center gap-2">
<div
className={`w-5 h-5 ${note.editorColor} rounded-full flex items-center justify-center text-[10px] font-bold text-white`}
>
{note.editor[0]}
</div>
<span className="text-slate-400">{note.editor}</span>
</div>
<span>{note.editedAt}</span>
</div>
</div>
</div>
)
}
/* --- Sidebar Component -------------------------------------------------- */
function Sidebar() {
return (
<div className="bg-slate-800/30 rounded-xl border border-slate-700/50 overflow-hidden">
{/* Sidebar header */}
<div className="px-4 py-3 border-b border-slate-700/50">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Notebooks</span>
<span className="text-xs text-slate-500">3 notebooks</span>
</div>
</div>
{/* Notebook tree */}
<div className="p-2">
{sidebarCategories.map((cat) => (
<div key={cat.name} className="mb-1">
{/* Notebook name */}
<div
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm ${
cat.active
? 'bg-amber-500/10 text-amber-300'
: 'text-slate-400 hover:text-slate-300 hover:bg-slate-700/30'
} transition-colors`}
>
<span>{cat.icon}</span>
<span className={cat.active ? 'font-medium' : ''}>{cat.name}</span>
</div>
{/* Children / categories */}
{cat.active && (
<div className="ml-4 mt-0.5 space-y-0.5">
{cat.children.map((child) => (
<div
key={child.name}
className={`flex items-center justify-between px-3 py-1.5 rounded-md text-xs ${
child.active
? 'bg-slate-700/40 text-white'
: 'text-slate-500 hover:text-slate-300 hover:bg-slate-700/20'
} transition-colors`}
>
<span>{child.name}</span>
<span className="text-slate-600">{child.count}</span>
</div>
))}
</div>
)}
{/* Collapsed children indicator */}
{!cat.active && (
<div className="ml-8 py-0.5">
<span className="text-xs text-slate-600">
{cat.children.length} sections
</span>
</div>
)}
</div>
))}
</div>
{/* Quick actions */}
<div className="px-4 py-3 border-t border-slate-700/50 space-y-2">
<div className="flex items-center gap-2 text-xs text-slate-500">
<svg className="w-3.5 h-3.5" 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>
<span>Search notes...</span>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
<svg className="w-3.5 h-3.5" 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>
<span>Browse tags</span>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Recent edits</span>
</div>
</div>
</div>
)
}
/* --- Page --------------------------------------------------------------- */
export default function DemoPage() {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
{/* Nav */}
<nav className="border-b border-slate-700/50 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-amber-400 to-orange-500 rounded-lg flex items-center justify-center font-bold text-slate-900 text-sm">
rN
</div>
<span className="font-semibold text-lg">rNotes</span>
</Link>
<span className="text-slate-600">/</span>
<span className="text-sm text-slate-400">Demo</span>
</div>
<div className="flex items-center gap-3">
<Link
href="/"
className="text-sm text-slate-400 hover:text-white transition-colors hidden sm:inline"
>
Home
</Link>
<Link
href="/demo"
className="text-sm text-amber-400 hover:text-amber-300 transition-colors hidden sm:inline"
>
Demo
</Link>
<Link
href="/notebooks/new"
className="text-sm px-4 py-2 bg-amber-500 hover:bg-amber-400 rounded-lg transition-colors font-medium text-slate-900"
>
Start Taking Notes
</Link>
</div>
</div>
</nav>
{/* Hero Section */}
<section className="max-w-7xl mx-auto px-6 pt-12 pb-8">
<div className="text-center max-w-3xl mx-auto">
{/* Badge */}
<div className="inline-flex items-center gap-2 px-4 py-1.5 bg-amber-500/10 border border-amber-500/20 rounded-full text-sm text-amber-300 mb-6">
<span className="w-2 h-2 bg-amber-400 rounded-full animate-pulse" />
Interactive Demo
</div>
<h1 className="text-4xl sm:text-5xl font-bold mb-4 bg-gradient-to-r from-amber-300 via-orange-300 to-rose-300 bg-clip-text text-transparent">
See how rNotes works
</h1>
<p className="text-lg text-slate-300 mb-2">
A collaborative knowledge base for your team
</p>
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-400 mb-6">
<span>📓 Organized notebooks</span>
<span>🏷 Flexible tagging</span>
<span>🔗 Canvas sync</span>
<span>👥 Real-time collaboration</span>
</div>
{/* Collaborator avatars */}
<div className="flex items-center justify-center gap-2">
{notebook.collaborators.map((name, i) => {
const colors = ['bg-teal-500', 'bg-cyan-500', 'bg-violet-500', 'bg-rose-500']
return (
<div
key={name}
className={`w-10 h-10 ${colors[i]} rounded-full flex items-center justify-center text-sm font-bold text-white ring-2 ring-slate-800`}
title={name}
>
{name[0]}
</div>
)
})}
<span className="text-sm text-slate-400 ml-2">4 collaborators</span>
</div>
</div>
</section>
{/* Context line */}
<section className="max-w-7xl mx-auto px-6 pb-6">
<p className="text-center text-sm text-slate-400 max-w-2xl mx-auto">
This demo shows a <span className="text-slate-200 font-medium">Team Knowledge Base</span> scenario
with notebooks, rich notes, tags, and canvas sync -- all powered by the{' '}
<span className="text-slate-200 font-medium">r* ecosystem</span>.
</p>
</section>
{/* Demo Content: Sidebar + Notes */}
<section className="max-w-7xl mx-auto px-6 pb-16">
{/* Notebook header card */}
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 overflow-hidden mb-6">
<div className="flex items-center justify-between px-5 py-3 border-b border-slate-700/50">
<div className="flex items-center gap-2">
<span className="text-xl">📓</span>
<span className="font-semibold text-sm">{notebook.name}</span>
<span className="text-xs text-slate-500 ml-2">{notebook.noteCount} notes</span>
</div>
<a
href="https://rnotes.online"
target="_blank"
rel="noopener noreferrer"
className="text-xs px-3 py-1.5 bg-slate-700/60 hover:bg-slate-600/60 rounded-lg text-slate-300 hover:text-white transition-colors"
>
Open in rNotes
</a>
</div>
<div className="px-5 py-3">
<p className="text-sm text-slate-400">{notebook.description}</p>
</div>
</div>
{/* Main layout: sidebar + notes grid */}
<div className="grid lg:grid-cols-4 gap-6">
{/* Sidebar */}
<div className="lg:col-span-1">
<Sidebar />
</div>
{/* Notes list */}
<div className="lg:col-span-3 space-y-4">
{/* Section header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-slate-300">Architecture</h2>
<span className="text-xs text-slate-500">4 notes</span>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
<span>Sort: Recently edited</span>
</div>
</div>
{/* Note cards */}
{notes.map((note) => (
<NoteCard key={note.id} note={note} />
))}
</div>
</div>
</section>
{/* Features showcase */}
<section className="max-w-7xl mx-auto px-6 pb-16">
<h2 className="text-2xl font-bold text-white text-center mb-8">Everything you need to capture knowledge</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[
{
icon: '📝',
title: 'Rich Editing',
desc: 'Headings, lists, code blocks, highlights, images, and file attachments in every note.',
},
{
icon: '📓',
title: 'Notebooks',
desc: 'Organize notes into notebooks with sections. Nest as deep as you need.',
},
{
icon: '🏷️',
title: 'Flexible Tags',
desc: 'Cross-cutting tags let you find notes across all notebooks instantly.',
},
{
icon: '🔗',
title: 'Canvas Sync',
desc: 'Pin any note to your rSpace canvas for visual collaboration with your team.',
},
].map((feature) => (
<div
key={feature.title}
className="bg-slate-800/50 rounded-xl border border-slate-700/50 p-5"
>
<span className="text-2xl mb-3 block">{feature.icon}</span>
<h3 className="text-sm font-semibold text-white mb-1">{feature.title}</h3>
<p className="text-xs text-slate-400">{feature.desc}</p>
</div>
))}
</div>
</section>
{/* CTA Section */}
<section className="max-w-7xl mx-auto px-6 pb-20 text-center">
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 p-10">
<h2 className="text-3xl font-bold mb-3">Ready to capture everything?</h2>
<p className="text-slate-400 mb-6 max-w-lg mx-auto">
rNotes gives your team a shared knowledge base with rich editing, flexible organization,
and deep integration with the r* ecosystem -- all on a collaborative canvas.
</p>
<Link
href="/notebooks/new"
className="inline-block px-8 py-4 bg-amber-500 hover:bg-amber-400 rounded-xl text-lg font-medium transition-all shadow-lg shadow-amber-900/30 text-slate-900"
>
Start Taking Notes
</Link>
</div>
</section>
{/* Footer */}
<footer className="border-t border-slate-700/50 py-8">
<div className="max-w-7xl mx-auto px-6">
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-500 mb-4">
<span className="font-medium text-slate-400">r* Ecosystem</span>
<a href="https://rspace.online" className="hover:text-slate-300 transition-colors">🌌 rSpace</a>
<a href="https://rmaps.online" className="hover:text-slate-300 transition-colors">🗺 rMaps</a>
<a href="https://rnotes.online" className="hover:text-slate-300 transition-colors font-medium text-slate-300">📝 rNotes</a>
<a href="https://rvote.online" className="hover:text-slate-300 transition-colors">🗳 rVote</a>
<a href="https://rfunds.online" className="hover:text-slate-300 transition-colors">💰 rFunds</a>
<a href="https://rtrips.online" className="hover:text-slate-300 transition-colors"> rTrips</a>
<a href="https://rcart.online" className="hover:text-slate-300 transition-colors">🛒 rCart</a>
<a href="https://rwallet.online" className="hover:text-slate-300 transition-colors">💼 rWallet</a>
<a href="https://rfiles.online" className="hover:text-slate-300 transition-colors">📁 rFiles</a>
<a href="https://rnetwork.online" className="hover:text-slate-300 transition-colors">🌐 rNetwork</a>
</div>
<p className="text-center text-xs text-slate-600">
Part of the r* ecosystem -- collaborative tools for communities.
</p>
</div>
</footer>
</div>
)
return <DemoContent />
}

221
src/lib/demo-sync.ts Normal file
View File

@ -0,0 +1,221 @@
/**
* useDemoSync lightweight React hook for real-time demo data via rSpace
*
* Connects to rSpace WebSocket in JSON mode (no Automerge bundle needed).
* All demo pages share the "demo" community, so changes in one app
* propagate to every other app viewing the same shapes.
*
* Usage:
* const { shapes, updateShape, deleteShape, connected, resetDemo } = useDemoSync({
* filter: ['folk-note', 'folk-notebook'], // optional: only these shape types
* });
*/
import { useEffect, useRef, useState, useCallback } from 'react';
export interface DemoShape {
type: string;
id: string;
x: number;
y: number;
width: number;
height: number;
rotation: number;
[key: string]: unknown;
}
interface UseDemoSyncOptions {
/** Community slug (default: 'demo') */
slug?: string;
/** Only subscribe to these shape types */
filter?: string[];
/** rSpace server URL (default: auto-detect based on environment) */
serverUrl?: string;
}
interface UseDemoSyncReturn {
/** Current shapes (filtered if filter option set) */
shapes: Record<string, DemoShape>;
/** Update a shape by ID (partial update merged with existing) */
updateShape: (id: string, data: Partial<DemoShape>) => void;
/** Delete a shape by ID */
deleteShape: (id: string) => void;
/** Whether WebSocket is connected */
connected: boolean;
/** Reset demo to seed state */
resetDemo: () => Promise<void>;
}
const DEFAULT_SLUG = 'demo';
const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30000;
const PING_INTERVAL_MS = 30000;
function getDefaultServerUrl(): string {
if (typeof window === 'undefined') return 'https://rspace.online';
// In development, use localhost
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
return `http://${window.location.hostname}:3000`;
}
return 'https://rspace.online';
}
export function useDemoSync(options?: UseDemoSyncOptions): UseDemoSyncReturn {
const slug = options?.slug ?? DEFAULT_SLUG;
const filter = options?.filter;
const serverUrl = options?.serverUrl ?? getDefaultServerUrl();
const [shapes, setShapes] = useState<Record<string, DemoShape>>({});
const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const reconnectAttemptRef = useRef(0);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const mountedRef = useRef(true);
// Stable filter reference for use in callbacks
const filterRef = useRef(filter);
filterRef.current = filter;
const applyFilter = useCallback((allShapes: Record<string, DemoShape>): Record<string, DemoShape> => {
const f = filterRef.current;
if (!f || f.length === 0) return allShapes;
const filtered: Record<string, DemoShape> = {};
for (const [id, shape] of Object.entries(allShapes)) {
if (f.includes(shape.type)) {
filtered[id] = shape;
}
}
return filtered;
}, []);
const connect = useCallback(() => {
if (!mountedRef.current) return;
// Build WebSocket URL
const wsProtocol = serverUrl.startsWith('https') ? 'wss' : 'ws';
const host = serverUrl.replace(/^https?:\/\//, '');
const wsUrl = `${wsProtocol}://${host}/ws/${slug}?mode=json`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
if (!mountedRef.current) return;
setConnected(true);
reconnectAttemptRef.current = 0;
// Start ping keepalive
pingTimerRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
}
}, PING_INTERVAL_MS);
};
ws.onmessage = (event) => {
if (!mountedRef.current) return;
try {
const msg = JSON.parse(event.data);
if (msg.type === 'snapshot' && msg.shapes) {
setShapes(applyFilter(msg.shapes));
}
// pong and error messages are silently handled
} catch {
// ignore parse errors
}
};
ws.onclose = () => {
if (!mountedRef.current) return;
setConnected(false);
cleanup();
scheduleReconnect();
};
ws.onerror = () => {
// onclose will fire after onerror, so reconnect is handled there
};
}, [slug, serverUrl, applyFilter]);
const cleanup = useCallback(() => {
if (pingTimerRef.current) {
clearInterval(pingTimerRef.current);
pingTimerRef.current = null;
}
}, []);
const scheduleReconnect = useCallback(() => {
if (!mountedRef.current) return;
const attempt = reconnectAttemptRef.current;
const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, attempt), RECONNECT_MAX_MS);
reconnectAttemptRef.current = attempt + 1;
reconnectTimerRef.current = setTimeout(() => {
if (mountedRef.current) connect();
}, delay);
}, [connect]);
// Connect on mount
useEffect(() => {
mountedRef.current = true;
connect();
return () => {
mountedRef.current = false;
cleanup();
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
if (wsRef.current) {
wsRef.current.onclose = null; // prevent reconnect on unmount
wsRef.current.close();
wsRef.current = null;
}
};
}, [connect, cleanup]);
const updateShape = useCallback((id: string, data: Partial<DemoShape>) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
// Optimistic local update
setShapes((prev) => {
const existing = prev[id];
if (!existing) return prev;
const updated = { ...existing, ...data, id };
const f = filterRef.current;
if (f && f.length > 0 && !f.includes(updated.type)) return prev;
return { ...prev, [id]: updated };
});
// Send to server
ws.send(JSON.stringify({ type: 'update', id, data: { ...data, id } }));
}, []);
const deleteShape = useCallback((id: string) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
// Optimistic local delete
setShapes((prev) => {
const { [id]: _, ...rest } = prev;
return rest;
});
ws.send(JSON.stringify({ type: 'delete', id }));
}, []);
const resetDemo = useCallback(async () => {
const res = await fetch(`${serverUrl}/api/communities/demo/reset`, { method: 'POST' });
if (!res.ok) {
const body = await res.text();
throw new Error(`Reset failed: ${res.status} ${body}`);
}
// The server will broadcast new snapshot via WebSocket
}, [serverUrl]);
return { shapes, updateShape, deleteShape, connected, resetDemo };
}