diff --git a/docs/plans/2026-03-10-annotations-voting-plan.md b/docs/plans/2026-03-10-annotations-voting-plan.md new file mode 100644 index 0000000..da65162 --- /dev/null +++ b/docs/plans/2026-03-10-annotations-voting-plan.md @@ -0,0 +1,1743 @@ +# 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 */} +
+