'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; import { LayoutGrid, Plus, Trash2, GripVertical, X, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; interface CanvasCard { id: string; canvas_id: string; type: 'note' | 'task' | 'image' | 'entity'; entity_id?: string; title?: string; content?: string; x: number; y: number; width: number; height: number; rotation: number; color?: string; z_index: number; created: string; updated: string; } interface CanvasConnection { id: string; source_card_id: string; target_card_id: string; label?: string; style: 'solid' | 'dashed' | 'dotted'; } interface Canvas { id: string; name: string; description?: string; mode: 'freeform' | 'graph'; domain: string; tags: string[]; cards: CanvasCard[]; connections: CanvasConnection[]; viewport?: { x: number; y: number; zoom: number }; background?: string; created: string; updated: string; } function CanvasBoard({ canvas, onBack }: { canvas: Canvas; onBack: () => void }) { const [cards, setCards] = useState(canvas.cards || []); const [connections] = useState(canvas.connections || []); const [dragging, setDragging] = useState(null); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [viewport, setViewport] = useState(canvas.viewport || { x: 0, y: 0, zoom: 1 }); const [editingCard, setEditingCard] = useState(null); const [editTitle, setEditTitle] = useState(''); const [editContent, setEditContent] = useState(''); const boardRef = useRef(null); const handlePointerDown = useCallback( (e: React.PointerEvent, cardId: string) => { e.preventDefault(); const card = cards.find((c) => c.id === cardId); if (!card) return; setDragging(cardId); setDragOffset({ x: e.clientX - card.x * viewport.zoom, y: e.clientY - card.y * viewport.zoom, }); (e.target as HTMLElement).setPointerCapture(e.pointerId); }, [cards, viewport.zoom] ); const handlePointerMove = useCallback( (e: React.PointerEvent) => { if (!dragging) return; const newX = (e.clientX - dragOffset.x) / viewport.zoom; const newY = (e.clientY - dragOffset.y) / viewport.zoom; setCards((prev) => prev.map((c) => (c.id === dragging ? { ...c, x: Math.max(0, newX), y: Math.max(0, newY) } : c)) ); }, [dragging, dragOffset, viewport.zoom] ); const handlePointerUp = useCallback(() => { setDragging(null); }, []); async function saveCardPosition(card: CanvasCard) { try { await fetch(`/api/canvases/${canvas.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cards: cards.map((c) => c.id === card.id ? { ...c, x: card.x, y: card.y } : c ), }), }); } catch (err) { console.error('Failed to save card position:', err); } } async function addCard() { const newCard: CanvasCard = { id: crypto.randomUUID(), canvas_id: canvas.id, type: 'note', title: 'New note', content: '', x: 50 + Math.random() * 200, y: 50 + Math.random() * 200, width: 200, height: 150, rotation: 0, z_index: cards.length, created: new Date().toISOString(), updated: new Date().toISOString(), }; const updatedCards = [...cards, newCard]; setCards(updatedCards); try { await fetch(`/api/canvases/${canvas.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cards: updatedCards }), }); toast.success('Card added'); } catch (err) { console.error('Failed to add card:', err); toast.error('Failed to add card'); } } async function deleteCard(cardId: string) { const updatedCards = cards.filter((c) => c.id !== cardId); setCards(updatedCards); try { await fetch(`/api/canvases/${canvas.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cards: updatedCards }), }); toast.success('Card removed'); } catch (err) { console.error('Failed to delete card:', err); toast.error('Failed to delete card'); } } async function saveCardEdit() { if (!editingCard) return; const updatedCards = cards.map((c) => c.id === editingCard.id ? { ...c, title: editTitle, content: editContent } : c ); setCards(updatedCards); setEditingCard(null); try { await fetch(`/api/canvases/${canvas.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cards: updatedCards }), }); } catch (err) { console.error('Failed to save card:', err); } } function openEdit(card: CanvasCard) { setEditingCard(card); setEditTitle(card.title || ''); setEditContent(card.content || ''); } return (
{/* Toolbar */}

{canvas.name}

{Math.round(viewport.zoom * 100)}%
{/* Board */}
{/* Connections */} {connections.map((conn) => { const source = cards.find((c) => c.id === conn.source_card_id); const target = cards.find((c) => c.id === conn.target_card_id); if (!source || !target) return null; return ( ); })} {/* Cards */} {cards.map((card) => (
{/* Drag handle */}
handlePointerDown(e, card.id)} style={{ touchAction: 'none' }} > {card.title || 'Untitled'}
{/* Content */}
{card.content || 'No content'}
))} {cards.length === 0 && (

No cards yet

)}
{/* Edit dialog */} !open && setEditingCard(null)}> Edit card
setEditTitle(e.target.value)} placeholder="Card title" />