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:
parent
2c329d7ca4
commit
30f3383d1b
|
|
@ -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">•</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">•</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">•</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">•</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">•</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 />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Reference in New Issue