diff --git a/src/app/demo/demo-content.tsx b/src/app/demo/demo-content.tsx new file mode 100644 index 0000000..8442b95 --- /dev/null +++ b/src/app/demo/demo-content.tsx @@ -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( +
+ {renderInline(line.slice(4))} +
+ ) + i++ + continue + } + + // Heading 2 + if (line.startsWith('## ')) { + elements.push( +

+ {renderInline(line.slice(3))} +

+ ) + i++ + continue + } + + // Heading 1 + if (line.startsWith('# ')) { + elements.push( +

+ {renderInline(line.slice(2))} +

+ ) + i++ + continue + } + + // Blockquote + if (line.startsWith('> ')) { + elements.push( +
+

{renderInline(line.slice(2))}

+
+ ) + 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( +
  • + + {renderInline(lines[i].slice(2))} +
  • + ) + i++ + } + elements.push( + + ) + 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( +
  • + + {listItems.length + 1}. + + {renderInline(text)} +
  • + ) + i++ + } + elements.push( +
      + {listItems} +
    + ) + 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( +
    + {lang && ( +
    + {lang} +
    + )} +
    +            {codeLines.join('\n')}
    +          
    +
    + ) + continue + } + + // Empty line + if (line.trim() === '') { + i++ + continue + } + + // Regular paragraph + elements.push( +

    + {renderInline(line)} +

    + ) + i++ + } + + return
    {elements}
    +} + +/** 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({boldMatch[1]}) + parts.push({boldMatch[2]}) + remaining = boldMatch[3] + continue + } + + // Italic *text* + const italicMatch = remaining.match(/^(.*?)\*(.+?)\*(.*)$/s) + if (italicMatch) { + if (italicMatch[1]) parts.push({italicMatch[1]}) + parts.push({italicMatch[2]}) + remaining = italicMatch[3] + continue + } + + // Inline code `text` + const codeMatch = remaining.match(/^(.*?)`(.+?)`(.*)$/s) + if (codeMatch) { + if (codeMatch[1]) parts.push({codeMatch[1]}) + parts.push( + + {codeMatch[2]} + + ) + remaining = codeMatch[3] + continue + } + + // No more inline formatting + parts.push({remaining}) + break + } + + return <>{parts} +} + +/* --- Editor Colors ------------------------------------------------------ */ + +const EDITOR_COLORS: Record = { + 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 ( +
    +
    + {/* Header row */} +
    +

    + {expanded && ( + + )} + {note.noteTitle} +

    + + + + + + Synced to rSpace + +
    + + {/* Preview text (only for collapsed notes) */} + {!expanded && ( +

    {note.content.slice(0, 150)}...

    + )} + + {/* Expanded content */} + {expanded && ( +
    + +
    + )} + + {/* Tags */} +
    + {note.tags.map((tag) => ( + + #{tag} + + ))} +
    + + {/* Footer: editor info */} +
    +
    +
    + {note.editor[0]} +
    + {note.editor} +
    + {note.editedAt} +
    +
    +
    + ) +} + +/* --- Packing List Component --------------------------------------------- */ + +function PackingList({ + packingList, + shapeId, + updateShape, +}: { + packingList: PackingListData + shapeId: string + updateShape: (id: string, data: Partial) => void +}) { + const categories = useMemo(() => { + const cats: Record = {} + 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) + }, + [packingList.items, shapeId, updateShape] + ) + + return ( +
    + {/* Header */} +
    +
    +
    + πŸŽ’ +
    +

    {packingList.listTitle}

    +

    + {packedCount} of {totalItems} items packed +

    +
    +
    +
    +
    +
    0 ? (packedCount / totalItems) * 100 : 0}%` }} + /> +
    + {totalItems > 0 ? Math.round((packedCount / totalItems) * 100) : 0}% +
    +
    +
    + + {/* Categories */} +
    + {Object.entries(categories).map(([category, items]) => ( +
    +

    {category}

    +
    + {items.map((item) => ( + + ))} +
    +
    + ))} +
    +
    + ) +} + +/* --- Sidebar Component -------------------------------------------------- */ + +function Sidebar({ + notebook, + noteCount, +}: { + notebook: NotebookData | null + noteCount: number +}) { + return ( +
    + {/* Sidebar header */} +
    +
    + Notebook + {noteCount} notes +
    +
    + + {/* Active notebook */} +
    +
    +
    + πŸ““ + {notebook?.notebookTitle || 'Loading...'} +
    +
    +
    + Notes + {noteCount} +
    +
    + Packing List + 1 +
    +
    +
    +
    + + {/* Quick info */} +
    +
    + + + + Search notes... +
    +
    + + + + Browse tags +
    +
    + + + + Recent edits +
    +
    +
    + ) +} + +/* --- Loading Skeleton --------------------------------------------------- */ + +function LoadingSkeleton() { + return ( +
    + {[1, 2, 3].map((i) => ( +
    +
    +
    +
    +
    +
    +
    +
    +
    + ))} +
    + ) +} + +/* --- 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>(new Set(['demo-note-packing'])) + const [resetting, setResetting] = useState(false) + + // Extract data from shapes + const notebook = useMemo(() => { + 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 ( +
    + {/* Nav */} + + + {/* Hero Section */} +
    +
    + {/* Badge */} +
    + + {connected ? 'Live Demo' : 'Interactive Demo'} +
    + +

    + See how rNotes works +

    +

    + {notebook?.description || 'A collaborative knowledge base for your team'} +

    +
    + Organized notebooks + Flexible tagging + Canvas sync + Real-time collaboration +
    + + {/* Collaborator avatars */} +
    + {(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 ( +
    + {name[0]} +
    + ) + })} + {collaborators.length > 0 && ( + + {collaborators.length} collaborator{collaborators.length !== 1 ? 's' : ''} + + )} +
    +
    +
    + + {/* Context line + Reset button */} +
    +
    +

    + This demo shows a Trip Planning Notebook scenario + with notes, a packing list, tags, and canvas sync -- all powered by the{' '} + r* ecosystem with live data from{' '} + rSpace. +

    + +
    +
    + + {/* Demo Content: Sidebar + Notes + Packing List */} +
    + {/* Notebook header card */} +
    +
    +
    + πŸ““ + {notebook?.notebookTitle || 'Loading...'} + + {notebook?.noteCount ?? notes.length} notes + +
    + + Open in rNotes + +
    +
    +

    {notebook?.description || 'Loading notebook data...'}

    +
    +
    + + {/* Main layout: sidebar + notes + packing list */} +
    + {/* Sidebar */} +
    + +
    + + {/* Notes + Packing list */} +
    + {/* Notes section */} +
    + {/* Section header */} +
    +
    +

    Notes

    + {notes.length} notes +
    +
    + Sort: Recently edited +
    +
    + + {/* Note cards or loading */} + {!hasData ? ( + + ) : notes.length > 0 ? ( +
    + {notes.map((note) => ( + toggleNote(note.id)} + /> + ))} +
    + ) : ( +
    +

    No notes found. Try resetting the demo.

    +
    + )} +
    + + {/* Packing List section */} + {packingList && ( +
    +
    +

    Packing List

    +
    + +
    + )} +
    +
    +
    + + {/* Features showcase */} +
    +

    Everything you need to capture knowledge

    +
    + {[ + { + 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) => ( +
    +
    + {feature.icon === 'rich-edit' && ( + + + + )} + {feature.icon === 'notebooks' && ( + + + + )} + {feature.icon === 'tags' && ( + + + + )} + {feature.icon === 'canvas' && ( + + + + )} +
    +

    {feature.title}

    +

    {feature.desc}

    +
    + ))} +
    +
    + + {/* CTA Section */} +
    +
    +

    Ready to capture everything?

    +

    + 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. +

    + + Start Taking Notes + +
    +
    + + {/* Footer */} + +
    + ) +} diff --git a/src/app/demo/page.tsx b/src/app/demo/page.tsx index 30b3aa0..ed62de9 100644 --- a/src/app/demo/page.tsx +++ b/src/app/demo/page.tsx @@ -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... 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 ( -
    - {/* Heading 1 */} -
    -

    System Architecture Overview

    -

    - Last updated by Maya on Feb 15, 2026 -

    -
    - - {/* Highlight block */} -
    -

    - Key Decision: We are adopting an event-driven microservices architecture with a shared message bus (NATS) for inter-service communication. -

    -
    - - {/* Heading 2 */} -
    -

    Service Boundaries

    -
      -
    • - - Auth Service -- JWT issuance, OAuth2 providers, session management -
    • -
    • - - Content Service -- CRUD for notebooks, notes, attachments, and tags -
    • -
    • - - Search Service -- Full-text indexing via Meilisearch, faceted filters -
    • -
    • - - Canvas Sync -- WebSocket bridge to rSpace for real-time collaboration -
    • -
    -
    - - {/* Heading 2 */} -
    -

    Deployment Topology

    -

    - All services are containerized and orchestrated via Docker Compose in development, with Kubernetes for production. -

    -
    - - {/* Code block */} -
    -
    - docker-compose.yml - yaml -
    -
    -{`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}`}
    -        
    -
    - - {/* Another heading */} -
    -

    Data Flow

    -

    - 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. -

    -
    -
    - ) -} - -/* --- Note Card Component ------------------------------------------------ */ - -function NoteCard({ - note, -}: { - note: (typeof notes)[0] -}) { - return ( -
    -
    - {/* Header row */} -
    -

    - {note.title} -

    - {note.synced && ( - - - - - - Synced to rSpace canvas - - )} -
    - - {/* Preview text (only for collapsed notes) */} - {!note.expanded && ( -

    {note.preview}

    - )} - - {/* Expanded content */} - {note.expanded && } - - {/* Tags */} -
    - {note.tags.map((tag) => ( - - #{tag} - - ))} -
    - - {/* Footer: editor info */} -
    -
    -
    - {note.editor[0]} -
    - {note.editor} -
    - {note.editedAt} -
    -
    -
    - ) -} - -/* --- Sidebar Component -------------------------------------------------- */ - -function Sidebar() { - return ( -
    - {/* Sidebar header */} -
    -
    - Notebooks - 3 notebooks -
    -
    - - {/* Notebook tree */} -
    - {sidebarCategories.map((cat) => ( -
    - {/* Notebook name */} -
    - {cat.icon} - {cat.name} -
    - - {/* Children / categories */} - {cat.active && ( -
    - {cat.children.map((child) => ( -
    - {child.name} - {child.count} -
    - ))} -
    - )} - - {/* Collapsed children indicator */} - {!cat.active && ( -
    - - {cat.children.length} sections - -
    - )} -
    - ))} -
    - - {/* Quick actions */} -
    -
    - - - - Search notes... -
    -
    - - - - Browse tags -
    -
    - - - - Recent edits -
    -
    -
    - ) -} - -/* --- Page --------------------------------------------------------------- */ - export default function DemoPage() { - return ( -
    - {/* Nav */} - - - {/* Hero Section */} -
    -
    - {/* Badge */} -
    - - Interactive Demo -
    - -

    - See how rNotes works -

    -

    - A collaborative knowledge base for your team -

    -
    - πŸ““ Organized notebooks - 🏷️ Flexible tagging - πŸ”— Canvas sync - πŸ‘₯ Real-time collaboration -
    - - {/* Collaborator avatars */} -
    - {notebook.collaborators.map((name, i) => { - const colors = ['bg-teal-500', 'bg-cyan-500', 'bg-violet-500', 'bg-rose-500'] - return ( -
    - {name[0]} -
    - ) - })} - 4 collaborators -
    -
    -
    - - {/* Context line */} -
    -

    - This demo shows a Team Knowledge Base scenario - with notebooks, rich notes, tags, and canvas sync -- all powered by the{' '} - r* ecosystem. -

    -
    - - {/* Demo Content: Sidebar + Notes */} -
    - {/* Notebook header card */} -
    -
    -
    - πŸ““ - {notebook.name} - {notebook.noteCount} notes -
    - - Open in rNotes β†— - -
    -
    -

    {notebook.description}

    -
    -
    - - {/* Main layout: sidebar + notes grid */} -
    - {/* Sidebar */} -
    - -
    - - {/* Notes list */} -
    - {/* Section header */} -
    -
    -

    Architecture

    - 4 notes -
    -
    - Sort: Recently edited -
    -
    - - {/* Note cards */} - {notes.map((note) => ( - - ))} -
    -
    -
    - - {/* Features showcase */} -
    -

    Everything you need to capture knowledge

    -
    - {[ - { - 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) => ( -
    - {feature.icon} -

    {feature.title}

    -

    {feature.desc}

    -
    - ))} -
    -
    - - {/* CTA Section */} -
    -
    -

    Ready to capture everything?

    -

    - 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. -

    - - Start Taking Notes - -
    -
    - - {/* Footer */} - -
    - ) + return } diff --git a/src/lib/demo-sync.ts b/src/lib/demo-sync.ts new file mode 100644 index 0000000..6487586 --- /dev/null +++ b/src/lib/demo-sync.ts @@ -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; + /** Update a shape by ID (partial update merged with existing) */ + updateShape: (id: string, data: Partial) => void; + /** Delete a shape by ID */ + deleteShape: (id: string) => void; + /** Whether WebSocket is connected */ + connected: boolean; + /** Reset demo to seed state */ + resetDemo: () => Promise; +} + +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>({}); + const [connected, setConnected] = useState(false); + + const wsRef = useRef(null); + const reconnectAttemptRef = useRef(0); + const reconnectTimerRef = useRef | null>(null); + const pingTimerRef = useRef | 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): Record => { + const f = filterRef.current; + if (!f || f.length === 0) return allShapes; + const filtered: Record = {}; + 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) => { + 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 }; +}