# Annotations & Voting Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add thread-centric comments (object-level and region-pinned) and per-object upvoting to refboard, with real-time sync and a Review Mode overlay. **Architecture:** Backend uses SQLite tables (comment_threads, comments, object_votes) with REST endpoints and socket event relay. Frontend loads all threads/votes on board open, updates via socket events (server-authoritative, sender included), renders pin markers in a PixiJS overlay container, and shows a collapsible Feedback Panel alongside the existing LayerPanel. **Tech Stack:** Node.js/Express backend, better-sqlite3, Socket.IO, React + PixiJS frontend, TypeScript **Design Doc:** `docs/plans/2026-03-10-annotations-voting-design.md` --- ## Phase 1: Comments Backend ### Task 1: Database Schema — comment_threads + comments tables **Files:** - Modify: `backend/db.js` **Step 1: Add tables after existing schema** Add this block after the existing `CREATE TABLE` statements (after the media_jobs table, around line 116): ```javascript // ── Comment Threads & Comments ── db.exec(` CREATE TABLE IF NOT EXISTS comment_threads ( id TEXT PRIMARY KEY, board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE, object_id TEXT NOT NULL, anchor_type TEXT NOT NULL DEFAULT 'object', pin_x REAL, pin_y REAL, status TEXT NOT NULL DEFAULT 'open', resolved_by TEXT REFERENCES users(id), resolved_at TEXT, comment_count INTEGER NOT NULL DEFAULT 1, last_commented_at TEXT, last_commented_by TEXT, created_by TEXT NOT NULL REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_threads_board ON comment_threads(board_id); CREATE INDEX IF NOT EXISTS idx_threads_object ON comment_threads(board_id, object_id); CREATE TABLE IF NOT EXISTS comments ( id TEXT PRIMARY KEY, thread_id TEXT NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id), author_name TEXT NOT NULL, author_color TEXT, content TEXT NOT NULL, edited_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_comments_thread ON comments(thread_id); `); ``` **Step 2: Add prepared statement helpers** Add these functions before the `module.exports` block: ```javascript // ── Thread queries ── function getThreadsByBoard(boardId) { return db.prepare(` SELECT t.*, u.display_name AS author_name FROM comment_threads t LEFT JOIN users u ON u.id = t.created_by WHERE t.board_id = ? ORDER BY t.created_at ASC `).all(boardId); } function getThread(threadId) { return db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(threadId); } function createThread({ id, boardId, objectId, anchorType, pinX, pinY, createdBy }) { db.prepare(` INSERT INTO comment_threads (id, board_id, object_id, anchor_type, pin_x, pin_y, created_by, last_commented_at, last_commented_by) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), ?) `).run(id, boardId, objectId, anchorType, pinX ?? null, pinY ?? null, createdBy, createdBy); return getThread(id); } function updateThreadStatus(threadId, status, resolvedBy) { if (status === 'resolved') { db.prepare(` UPDATE comment_threads SET status = ?, resolved_by = ?, resolved_at = datetime('now'), updated_at = datetime('now') WHERE id = ? `).run(status, resolvedBy, threadId); } else { db.prepare(` UPDATE comment_threads SET status = ?, resolved_by = NULL, resolved_at = NULL, updated_at = datetime('now') WHERE id = ? `).run(status, threadId); } return getThread(threadId); } function deleteThread(threadId) { db.prepare('DELETE FROM comment_threads WHERE id = ?').run(threadId); } function incrementThreadCommentCount(threadId, userId) { db.prepare(` UPDATE comment_threads SET comment_count = comment_count + 1, last_commented_at = datetime('now'), last_commented_by = ?, updated_at = datetime('now') WHERE id = ? `).run(userId, threadId); } function decrementThreadCommentCount(threadId) { db.prepare(` UPDATE comment_threads SET comment_count = MAX(0, comment_count - 1), updated_at = datetime('now') WHERE id = ? `).run(threadId); } // ── Comment queries ── function getCommentsByThread(threadId) { return db.prepare('SELECT * FROM comments WHERE thread_id = ? ORDER BY created_at ASC').all(threadId); } function getCommentsByBoard(boardId) { return db.prepare(` SELECT c.* FROM comments c JOIN comment_threads t ON t.id = c.thread_id WHERE t.board_id = ? ORDER BY c.created_at ASC `).all(boardId); } function getComment(commentId) { return db.prepare('SELECT * FROM comments WHERE id = ?').get(commentId); } function createComment({ id, threadId, userId, authorName, authorColor, content }) { db.prepare(` INSERT INTO comments (id, thread_id, user_id, author_name, author_color, content) VALUES (?, ?, ?, ?, ?, ?) `).run(id, threadId, userId, authorName, authorColor ?? null, content); return getComment(id); } function updateComment(commentId, content) { db.prepare(` UPDATE comments SET content = ?, edited_at = datetime('now') WHERE id = ? `).run(content, commentId); return getComment(commentId); } function deleteComment(commentId) { db.prepare('DELETE FROM comments WHERE id = ?').run(commentId); } ``` **Step 3: Export the new functions** Add to the `module.exports` block: ```javascript // Threads getThreadsByBoard, getThread, createThread, updateThreadStatus, deleteThread, incrementThreadCommentCount, decrementThreadCommentCount, // Comments getCommentsByThread, getCommentsByBoard, getComment, createComment, updateComment, deleteComment, ``` **Step 4: Verify the server starts** Run: `cd backend && node -e "require('./db'); console.log('DB OK')"` Expected: `DB OK` (tables created, no errors) **Step 5: Commit** ```bash git add backend/db.js git commit -m "feat(annotations): add comment_threads and comments tables + queries" ``` --- ### Task 2: Threads & Comments REST Endpoints **Files:** - Create: `backend/routes/threads.js` - Modify: `backend/server.js` (mount route) **Step 1: Create the threads route file** Create `backend/routes/threads.js`: ```javascript const { Router } = require('express'); const { v4: uuidv4 } = require('uuid'); const { authMiddleware } = require('../auth'); const { getBoard, getCollection, getCollectionMember, getThreadsByBoard, getThread, createThread, updateThreadStatus, deleteThread, getCommentsByBoard, getComment, createComment, updateComment, deleteComment, incrementThreadCommentCount, decrementThreadCommentCount, } = require('../db'); const router = Router(); router.use(authMiddleware); function hasCollectionRole(member, minRole) { if (!member) return false; const hierarchy = { owner: 3, editor: 2, viewer: 1 }; return (hierarchy[member.role] || 0) >= (hierarchy[minRole] || 0); } function resolveBoard(req, res, minRole = 'viewer') { const board = getBoard(req.params.boardId); if (!board) { res.status(404).json({ error: 'Board not found' }); return null; } const collection = getCollection(board.collection_id); if (!collection) { res.status(404).json({ error: 'Collection not found' }); return null; } const member = getCollectionMember(board.collection_id, req.user.id); if (minRole === 'viewer' && collection.is_public) { return { board, collection, member: member || { role: 'viewer' } }; } if (!hasCollectionRole(member, minRole)) { res.status(403).json({ error: `${minRole} access required` }); return null; } return { board, collection, member }; } // GET /api/boards/:boardId/threads — all threads + comments for board router.get('/:boardId/threads', (req, res) => { try { const result = resolveBoard(req, res, 'viewer'); if (!result) return; const threads = getThreadsByBoard(req.params.boardId); const comments = getCommentsByBoard(req.params.boardId); // Group comments by thread const commentsByThread = {}; for (const c of comments) { if (!commentsByThread[c.thread_id]) commentsByThread[c.thread_id] = []; commentsByThread[c.thread_id].push(c); } const data = threads.map((t) => ({ ...t, comments: commentsByThread[t.id] || [], })); return res.json({ threads: data }); } catch (err) { console.error('[threads] list error:', err); return res.status(500).json({ error: 'Internal server error' }); } }); // POST /api/boards/:boardId/threads — create thread + first comment router.post('/:boardId/threads', (req, res) => { try { const result = resolveBoard(req, res, 'viewer'); if (!result) return; const { object_id, anchor_type, pin_x, pin_y, content } = req.body; if (!object_id || !content || !content.trim()) { return res.status(400).json({ error: 'object_id and content are required' }); } const threadId = uuidv4(); const commentId = uuidv4(); const userId = req.user.id; const thread = createThread({ id: threadId, boardId: req.params.boardId, objectId: object_id, anchorType: anchor_type || 'object', pinX: pin_x, pinY: pin_y, createdBy: userId, }); const comment = createComment({ id: commentId, threadId, userId, authorName: req.user.display_name || req.user.username, authorColor: null, content: content.trim(), }); // Broadcast via socket const io = req.app.get('io'); if (io) { io.to(`board:${req.params.boardId}`).emit('thread:add', { boardId: req.params.boardId, thread, comment, }); } return res.status(201).json({ thread, comment }); } catch (err) { console.error('[threads] create error:', err); return res.status(500).json({ error: 'Internal server error' }); } }); // PATCH /api/boards/:boardId/threads/:threadId — update thread status router.patch('/:boardId/threads/:threadId', (req, res) => { try { const result = resolveBoard(req, res, 'editor'); if (!result) return; const { status } = req.body; if (!['open', 'resolved', 'archived'].includes(status)) { return res.status(400).json({ error: 'Invalid status' }); } const thread = getThread(req.params.threadId); if (!thread || thread.board_id !== req.params.boardId) { return res.status(404).json({ error: 'Thread not found' }); } const updated = updateThreadStatus(req.params.threadId, status, req.user.id); const io = req.app.get('io'); if (io) { io.to(`board:${req.params.boardId}`).emit('thread:status', { boardId: req.params.boardId, threadId: req.params.threadId, status: updated.status, resolvedBy: updated.resolved_by, resolvedAt: updated.resolved_at, }); } return res.json({ thread: updated }); } catch (err) { console.error('[threads] status error:', err); return res.status(500).json({ error: 'Internal server error' }); } }); // DELETE /api/boards/:boardId/threads/:threadId — delete thread + all comments router.delete('/:boardId/threads/:threadId', (req, res) => { try { const result = resolveBoard(req, res, 'owner'); if (!result) return; const thread = getThread(req.params.threadId); if (!thread || thread.board_id !== req.params.boardId) { return res.status(404).json({ error: 'Thread not found' }); } deleteThread(req.params.threadId); const io = req.app.get('io'); if (io) { io.to(`board:${req.params.boardId}`).emit('thread:delete', { boardId: req.params.boardId, threadId: req.params.threadId, }); } return res.json({ message: 'Thread deleted' }); } catch (err) { console.error('[threads] delete error:', err); return res.status(500).json({ error: 'Internal server error' }); } }); // POST /api/boards/:boardId/threads/:threadId/comments — add reply router.post('/:boardId/threads/:threadId/comments', (req, res) => { try { const result = resolveBoard(req, res, 'viewer'); if (!result) return; const { content } = req.body; if (!content || !content.trim()) { return res.status(400).json({ error: 'content is required' }); } const thread = getThread(req.params.threadId); if (!thread || thread.board_id !== req.params.boardId) { return res.status(404).json({ error: 'Thread not found' }); } const commentId = uuidv4(); const userId = req.user.id; const comment = createComment({ id: commentId, threadId: req.params.threadId, userId, authorName: req.user.display_name || req.user.username, authorColor: null, content: content.trim(), }); incrementThreadCommentCount(req.params.threadId, userId); const io = req.app.get('io'); if (io) { io.to(`board:${req.params.boardId}`).emit('comment:add', { boardId: req.params.boardId, threadId: req.params.threadId, comment, }); } return res.status(201).json({ comment }); } catch (err) { console.error('[threads] add comment error:', err); return res.status(500).json({ error: 'Internal server error' }); } }); // PUT /api/boards/:boardId/threads/:threadId/comments/:commentId — edit own comment router.put('/:boardId/threads/:threadId/comments/:commentId', (req, res) => { try { const result = resolveBoard(req, res, 'viewer'); if (!result) return; const { content } = req.body; if (!content || !content.trim()) { return res.status(400).json({ error: 'content is required' }); } const comment = getComment(req.params.commentId); if (!comment || comment.thread_id !== req.params.threadId) { return res.status(404).json({ error: 'Comment not found' }); } if (comment.user_id !== req.user.id) { return res.status(403).json({ error: 'Can only edit own comments' }); } const updated = updateComment(req.params.commentId, content.trim()); const io = req.app.get('io'); if (io) { io.to(`board:${req.params.boardId}`).emit('comment:update', { boardId: req.params.boardId, threadId: req.params.threadId, commentId: req.params.commentId, content: updated.content, editedAt: updated.edited_at, }); } return res.json({ comment: updated }); } catch (err) { console.error('[threads] edit comment error:', err); return res.status(500).json({ error: 'Internal server error' }); } }); // DELETE /api/boards/:boardId/threads/:threadId/comments/:commentId — delete own comment router.delete('/:boardId/threads/:threadId/comments/:commentId', (req, res) => { try { // Owner can delete any comment, others only their own const result = resolveBoard(req, res, 'viewer'); if (!result) return; const comment = getComment(req.params.commentId); if (!comment || comment.thread_id !== req.params.threadId) { return res.status(404).json({ error: 'Comment not found' }); } const isOwner = hasCollectionRole(result.member, 'owner'); if (comment.user_id !== req.user.id && !isOwner) { return res.status(403).json({ error: 'Can only delete own comments' }); } deleteComment(req.params.commentId); decrementThreadCommentCount(req.params.threadId); const io = req.app.get('io'); if (io) { io.to(`board:${req.params.boardId}`).emit('comment:delete', { boardId: req.params.boardId, threadId: req.params.threadId, commentId: req.params.commentId, }); } return res.json({ message: 'Comment deleted' }); } catch (err) { console.error('[threads] delete comment error:', err); return res.status(500).json({ error: 'Internal server error' }); } }); module.exports = router; ``` **Step 2: Mount the route in server.js** In `backend/server.js`, add after the existing route imports (around line 101): ```javascript const threadRoutes = require('./routes/threads'); ``` And after the existing `app.use('/api/boards', ...)` lines (around line 109): ```javascript app.use('/api/boards', threadRoutes); ``` **Step 3: Store io reference on app for routes to access** In `backend/server.js`, after `const io = setupSocket(server);` (around line 153), add: ```javascript app.set('io', io); ``` **Step 4: Verify server starts and route is accessible** Run: `cd backend && node -e "require('./server');"` (should start without errors) **Step 5: Commit** ```bash git add backend/routes/threads.js backend/server.js git commit -m "feat(annotations): add threads & comments REST endpoints with socket broadcast" ``` --- ### Task 3: Votes Backend — Table + REST + Socket **Files:** - Modify: `backend/db.js` - Create: `backend/routes/votes.js` - Modify: `backend/server.js` **Step 1: Add object_votes table to db.js** Add after the comments table creation: ```javascript // ── Object Votes ── db.exec(` CREATE TABLE IF NOT EXISTS object_votes ( board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE, object_id TEXT NOT NULL, user_id TEXT NOT NULL REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (board_id, object_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_votes_object ON object_votes(board_id, object_id); `); ``` **Step 2: Add vote query helpers to db.js** ```javascript // ── Vote queries ── function getVotesByBoard(boardId) { return db.prepare('SELECT * FROM object_votes WHERE board_id = ?').all(boardId); } function toggleVote(boardId, objectId, userId) { const existing = db.prepare( 'SELECT 1 FROM object_votes WHERE board_id = ? AND object_id = ? AND user_id = ?' ).get(boardId, objectId, userId); if (existing) { db.prepare('DELETE FROM object_votes WHERE board_id = ? AND object_id = ? AND user_id = ?') .run(boardId, objectId, userId); return false; // vote removed } else { db.prepare('INSERT INTO object_votes (board_id, object_id, user_id) VALUES (?, ?, ?)') .run(boardId, objectId, userId); return true; // vote added } } ``` Export them: ```javascript // Votes getVotesByBoard, toggleVote, ``` **Step 3: Create votes route file** Create `backend/routes/votes.js`: ```javascript const { Router } = require('express'); const { authMiddleware } = require('../auth'); const { getBoard, getCollection, getCollectionMember, getVotesByBoard, toggleVote, } = require('../db'); const router = Router(); router.use(authMiddleware); function hasCollectionRole(member, minRole) { if (!member) return false; const hierarchy = { owner: 3, editor: 2, viewer: 1 }; return (hierarchy[member.role] || 0) >= (hierarchy[minRole] || 0); } function resolveBoard(req, res, minRole = 'viewer') { const board = getBoard(req.params.boardId); if (!board) { res.status(404).json({ error: 'Board not found' }); return null; } const collection = getCollection(board.collection_id); if (!collection) { res.status(404).json({ error: 'Collection not found' }); return null; } const member = getCollectionMember(board.collection_id, req.user.id); if (minRole === 'viewer' && collection.is_public) { return { board, collection, member: member || { role: 'viewer' } }; } if (!hasCollectionRole(member, minRole)) { res.status(403).json({ error: `${minRole} access required` }); return null; } return { board, collection, member }; } // GET /api/boards/:boardId/votes — all votes for board router.get('/:boardId/votes', (req, res) => { try { const result = resolveBoard(req, res, 'viewer'); if (!result) return; const votes = getVotesByBoard(req.params.boardId); return res.json({ votes }); } catch (err) { console.error('[votes] list error:', err); return res.status(500).json({ error: 'Internal server error' }); } }); // POST /api/boards/:boardId/votes — toggle vote router.post('/:boardId/votes', (req, res) => { try { const result = resolveBoard(req, res, 'viewer'); if (!result) return; const { object_id } = req.body; if (!object_id) { return res.status(400).json({ error: 'object_id is required' }); } const active = toggleVote(req.params.boardId, object_id, req.user.id); const io = req.app.get('io'); if (io) { io.to(`board:${req.params.boardId}`).emit('vote:toggle', { boardId: req.params.boardId, objectId: object_id, userId: req.user.id, active, }); } return res.json({ active }); } catch (err) { console.error('[votes] toggle error:', err); return res.status(500).json({ error: 'Internal server error' }); } }); module.exports = router; ``` **Step 4: Mount in server.js** ```javascript const voteRoutes = require('./routes/votes'); app.use('/api/boards', voteRoutes); ``` **Step 5: Commit** ```bash git add backend/db.js backend/routes/votes.js backend/server.js git commit -m "feat(annotations): add object_votes table, toggle endpoint, socket broadcast" ``` --- ## Phase 2: Frontend State & Data Layer ### Task 4: Annotation Store — Thread & Vote State Management **Files:** - Create: `frontend/src/stores/annotationStore.ts` **Step 1: Create the store** This store holds all threads, comments, and votes for the current board. It's a plain class with methods that the socket handlers and panel components call. ```typescript export interface Thread { id: string; board_id: string; object_id: string; anchor_type: 'object' | 'point'; pin_x: number | null; pin_y: number | null; status: 'open' | 'resolved' | 'archived'; resolved_by: string | null; resolved_at: string | null; comment_count: number; last_commented_at: string | null; last_commented_by: string | null; created_by: string; created_at: string; updated_at: string; comments: Comment[]; } export interface Comment { id: string; thread_id: string; user_id: string; author_name: string; author_color: string | null; content: string; edited_at: string | null; created_at: string; } export interface Vote { board_id: string; object_id: string; user_id: string; } type Listener = () => void; export class AnnotationStore { threads = new Map(); /** objectId → Set */ votes = new Map>(); private _listeners = new Set(); subscribe(fn: Listener): () => void { this._listeners.add(fn); return () => this._listeners.delete(fn); } private _notify() { for (const fn of this._listeners) fn(); } // ── Bulk load ── loadThreads(threads: Thread[]) { this.threads.clear(); for (const t of threads) { this.threads.set(t.id, t); } this._notify(); } loadVotes(votes: Vote[]) { this.votes.clear(); for (const v of votes) { if (!this.votes.has(v.object_id)) this.votes.set(v.object_id, new Set()); this.votes.get(v.object_id)!.add(v.user_id); } this._notify(); } clear() { this.threads.clear(); this.votes.clear(); this._notify(); } // ── Socket event handlers ── onThreadAdd(thread: Thread, comment: Comment) { thread.comments = [comment]; this.threads.set(thread.id, thread); this._notify(); } onThreadStatus(threadId: string, status: string, resolvedBy: string | null, resolvedAt: string | null) { const t = this.threads.get(threadId); if (!t) return; t.status = status as Thread['status']; t.resolved_by = resolvedBy; t.resolved_at = resolvedAt; this._notify(); } onThreadDelete(threadId: string) { this.threads.delete(threadId); this._notify(); } onCommentAdd(threadId: string, comment: Comment) { const t = this.threads.get(threadId); if (!t) return; t.comments.push(comment); t.comment_count = t.comments.length; t.last_commented_at = comment.created_at; t.last_commented_by = comment.user_id; this._notify(); } onCommentUpdate(threadId: string, commentId: string, content: string, editedAt: string) { const t = this.threads.get(threadId); if (!t) return; const c = t.comments.find((c) => c.id === commentId); if (c) { c.content = content; c.edited_at = editedAt; } this._notify(); } onCommentDelete(threadId: string, commentId: string) { const t = this.threads.get(threadId); if (!t) return; t.comments = t.comments.filter((c) => c.id !== commentId); t.comment_count = t.comments.length; this._notify(); } onVoteToggle(objectId: string, userId: string, active: boolean) { if (!this.votes.has(objectId)) this.votes.set(objectId, new Set()); const set = this.votes.get(objectId)!; if (active) set.add(userId); else set.delete(userId); if (set.size === 0) this.votes.delete(objectId); this._notify(); } // ── Convenience getters ── getThreadsForObject(objectId: string): Thread[] { const result: Thread[] = []; for (const t of this.threads.values()) { if (t.object_id === objectId) result.push(t); } return result; } getVoteCount(objectId: string): number { return this.votes.get(objectId)?.size ?? 0; } hasVoted(objectId: string, userId: string): boolean { return this.votes.get(objectId)?.has(userId) ?? false; } } ``` **Step 2: Commit** ```bash git add frontend/src/stores/annotationStore.ts git commit -m "feat(annotations): add AnnotationStore for threads, comments, and votes" ``` --- ### Task 5: Wire Annotation Store to Board Lifecycle **Files:** - Modify: `frontend/src/hooks/useCanvasSetup.ts` - Modify: `frontend/src/pages/Editor.tsx` **Step 1: Create and load the store in useCanvasSetup.ts** At the top of useCanvasSetup.ts, import: ```typescript import { AnnotationStore } from '../stores/annotationStore'; ``` Inside the hook, create a ref for the store: ```typescript const annotationStoreRef = useRef(null); if (!annotationStoreRef.current) annotationStoreRef.current = new AnnotationStore(); ``` **Step 2: Load threads and votes on board open** After the board data is loaded (where socket connects and `board:join` fires), add two fetch calls: ```typescript // Load annotations fetch(`/api/boards/${resolvedBoardId}/threads`, { headers: { Authorization: `Bearer ${token}` }, }) .then((r) => r.json()) .then((data) => { if (data.threads) annotationStoreRef.current?.loadThreads(data.threads); }) .catch((err) => console.error('[annotations] load threads error:', err)); fetch(`/api/boards/${resolvedBoardId}/votes`, { headers: { Authorization: `Bearer ${token}` }, }) .then((r) => r.json()) .then((data) => { if (data.votes) annotationStoreRef.current?.loadVotes(data.votes); }) .catch((err) => console.error('[annotations] load votes error:', err)); ``` **Step 3: Wire socket event handlers** After the existing socket event listeners (after `socket.on('media:job:update', ...)`), add: ```typescript socket.on('thread:add', (data: any) => { annotationStoreRef.current?.onThreadAdd(data.thread, data.comment); }); socket.on('thread:status', (data: any) => { annotationStoreRef.current?.onThreadStatus(data.threadId, data.status, data.resolvedBy, data.resolvedAt); }); socket.on('thread:delete', (data: any) => { annotationStoreRef.current?.onThreadDelete(data.threadId); }); socket.on('comment:add', (data: any) => { annotationStoreRef.current?.onCommentAdd(data.threadId, data.comment); }); socket.on('comment:update', (data: any) => { annotationStoreRef.current?.onCommentUpdate(data.threadId, data.commentId, data.content, data.editedAt); }); socket.on('comment:delete', (data: any) => { annotationStoreRef.current?.onCommentDelete(data.threadId, data.commentId); }); socket.on('vote:toggle', (data: any) => { annotationStoreRef.current?.onVoteToggle(data.objectId, data.userId, data.active); }); ``` **Step 4: Return annotationStore from hook and expose in Editor** Add `annotationStore: annotationStoreRef.current` to the hook's return value. In Editor.tsx, destructure it and pass it down to the FeedbackPanel (created in Task 7). **Step 5: Clean up socket listeners on unmount** In the cleanup function of the useEffect, add `socket.off(...)` for each of the 7 annotation events. **Step 6: Commit** ```bash git add frontend/src/hooks/useCanvasSetup.ts frontend/src/pages/Editor.tsx git commit -m "feat(annotations): wire AnnotationStore to board lifecycle + socket events" ``` --- ## Phase 3: Feedback Panel ### Task 6: FeedbackPanel Component — Overview & Thread List **Files:** - Create: `frontend/src/components/FeedbackPanel.tsx` **Step 1: Create the panel component** Follow the same pattern as LayerPanel.tsx: absolute positioned, right side, 280px wide, collapsible. ```typescript import React, { useState, useCallback, useSyncExternalStore } from 'react'; import { AnnotationStore, Thread } from '../stores/annotationStore'; interface FeedbackPanelProps { annotationStore: AnnotationStore; selectedObjectId: string | null; userId: string; boardId: string; token: string; canvasObjects: Map; } export default function FeedbackPanel({ annotationStore, selectedObjectId, userId, boardId, token, canvasObjects, }: FeedbackPanelProps) { const [collapsed, setCollapsed] = useState(false); const [expandedThreadId, setExpandedThreadId] = useState(null); const [replyText, setReplyText] = useState(''); const [filter, setFilter] = useState<'all' | 'unresolved' | 'mine'>('unresolved'); // Subscribe to store changes const _version = useSyncExternalStore( (cb) => annotationStore.subscribe(cb), () => annotationStore.threads.size + annotationStore.votes.size, ); const allThreads = Array.from(annotationStore.threads.values()); // Apply filter let threads = allThreads; if (filter === 'unresolved') threads = threads.filter((t) => t.status === 'open'); if (filter === 'mine') threads = threads.filter((t) => t.created_by === userId); // If an object is selected, show only its threads if (selectedObjectId) { threads = threads.filter((t) => t.object_id === selectedObjectId); } // Sort: newest activity first threads.sort((a, b) => (b.last_commented_at || b.created_at).localeCompare(a.last_commented_at || a.created_at)); // ── API helpers ── const postReply = useCallback(async (threadId: string) => { if (!replyText.trim()) return; await fetch(`/api/boards/${boardId}/threads/${threadId}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ content: replyText.trim() }), }); setReplyText(''); }, [boardId, token, replyText]); const resolveThread = useCallback(async (threadId: string, status: string) => { await fetch(`/api/boards/${boardId}/threads/${threadId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ status }), }); }, [boardId, token]); const deleteComment = useCallback(async (threadId: string, commentId: string) => { await fetch(`/api/boards/${boardId}/threads/${threadId}/comments/${commentId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); }, [boardId, token]); // ── Collapsed state ── if (collapsed) { return (
); } // ── Expanded thread view ── const expandedThread = expandedThreadId ? annotationStore.threads.get(expandedThreadId) : null; if (expandedThread) { return (
{/* Header */}
{expandedThread.status === 'resolved' ? '✓ Resolved' : 'Open'} {expandedThread.status === 'open' ? ( ) : ( )}
{/* Comments */}
{expandedThread.comments.map((c) => (
{c.author_name} {new Date(c.created_at).toLocaleString()} {c.edited_at && (edited)}
{c.content}
{c.user_id === userId && ( )}
))}
{/* Reply input */}