From d951348372cc65f1bbdfc8badc04e827ab3bcc2d Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Wed, 29 Jul 2026 19:25:37 +0000 Subject: [PATCH] feat: add canvas freeform board page with drag-and-drop cards and sidebar link --- apps/web/app/(dashboard)/canvas/page.tsx | 523 +++++++++++++++++++++++ apps/web/components/sidebar.tsx | 2 + 2 files changed, 525 insertions(+) create mode 100644 apps/web/app/(dashboard)/canvas/page.tsx diff --git a/apps/web/app/(dashboard)/canvas/page.tsx b/apps/web/app/(dashboard)/canvas/page.tsx new file mode 100644 index 0000000..7207141 --- /dev/null +++ b/apps/web/app/(dashboard)/canvas/page.tsx @@ -0,0 +1,523 @@ +'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 [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 */} + + {canvas.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" + /> +
+
+ +