From 303124f5184d7bc273e8e28242b8e453f0979d09 Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Tue, 10 Mar 2026 21:04:38 +0530 Subject: [PATCH] feat(annotations): comments, threads, voting system with review mode Backend: - comment_threads + comments + object_votes tables with indexes - Thread/comment CRUD endpoints with socket broadcast - Vote toggle endpoint with socket broadcast Frontend: - AnnotationStore for reactive thread/comment/vote state - FeedbackPanel with thread list, expanded view, replies, filtering - Vote toggle buttons in panel - PinOverlay for canvas pin markers + vote badges - Review Mode toggle in toolbar (comment bubble icon) - Jump-to-object from thread view - Orphaned thread detection for deleted objects - New comment creation from panel when object selected - Socket event wiring for real-time sync --- backend/db.js | 180 +++++++++++ backend/routes/threads.js | 314 +++++++++++++++++++ backend/routes/votes.js | 83 +++++ backend/server.js | 5 + frontend/src/canvas/PinOverlay.ts | 133 ++++++++ frontend/src/components/FeedbackPanel.tsx | 365 ++++++++++++++++++++++ frontend/src/components/Toolbar.tsx | 13 + frontend/src/hooks/useCanvasSetup.ts | 71 +++++ frontend/src/pages/Editor.tsx | 47 ++- frontend/src/stores/annotationStore.ts | 156 +++++++++ 10 files changed, 1365 insertions(+), 2 deletions(-) create mode 100644 backend/routes/threads.js create mode 100644 backend/routes/votes.js create mode 100644 frontend/src/canvas/PinOverlay.ts create mode 100644 frontend/src/components/FeedbackPanel.tsx create mode 100644 frontend/src/stores/annotationStore.ts diff --git a/backend/db.js b/backend/db.js index 41b5cb2..926aad0 100644 --- a/backend/db.js +++ b/backend/db.js @@ -115,6 +115,53 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_media_jobs_image ON media_jobs(image_id); `); +// ── 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); +`); + +// ── 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); +`); + // Migrations — add columns to existing tables try { db.prepare("SELECT thumbnail FROM boards LIMIT 0").get(); @@ -480,6 +527,132 @@ function updateImageMedia(imageId, { posterAssetKey, duration, nativeWidth, nati `).run(posterAssetKey || null, duration || null, nativeWidth || null, nativeHeight || null, imageId); } +// --------------------- +// Vote helpers +// --------------------- +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 + } +} + +// --------------------- +// Thread helpers +// --------------------- +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 helpers +// --------------------- +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); +} + module.exports = { db, // Users @@ -499,4 +672,11 @@ module.exports = { getAllBoardChannelLinks, getImageByMmFileId, // Media Jobs createMediaJob, updateMediaJob, getMediaJob, getPendingMediaJobs, updateImageMedia, + // Threads + getThreadsByBoard, getThread, createThread, updateThreadStatus, deleteThread, + incrementThreadCommentCount, decrementThreadCommentCount, + // Comments + getCommentsByThread, getCommentsByBoard, getComment, createComment, updateComment, deleteComment, + // Votes + getVotesByBoard, toggleVote, }; diff --git a/backend/routes/threads.js b/backend/routes/threads.js new file mode 100644 index 0000000..1907a17 --- /dev/null +++ b/backend/routes/threads.js @@ -0,0 +1,314 @@ +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; diff --git a/backend/routes/votes.js b/backend/routes/votes.js new file mode 100644 index 0000000..e807db9 --- /dev/null +++ b/backend/routes/votes.js @@ -0,0 +1,83 @@ +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; diff --git a/backend/server.js b/backend/server.js index 6ca04c6..b36ca71 100644 --- a/backend/server.js +++ b/backend/server.js @@ -100,6 +100,8 @@ const boardRoutes = require('./routes/boards'); const uploadRoutes = require('./routes/upload'); const adminRoutes = require('./routes/admin'); const mmBridgeRoutes = require('./routes/mattermost-bridge'); +const threadRoutes = require('./routes/threads'); +const voteRoutes = require('./routes/votes'); app.use('/api/auth', authRoutes); app.use('/api/collections', collectionRoutes); @@ -107,6 +109,8 @@ app.use('/api/boards', boardRoutes); app.use('/api/upload', uploadRoutes); app.use('/api/admin', adminRoutes); app.use('/api/boards', mmBridgeRoutes); +app.use('/api/boards', threadRoutes); +app.use('/api/boards', voteRoutes); // Public shared collection route (no auth required) app.get('/api/c/:shareToken', (req, res) => { @@ -151,6 +155,7 @@ const server = http.createServer(app); const { setupSocket } = require('./socket'); const io = setupSocket(server); +app.set('io', io); // ---- Initialize services and start ---- async function start() { diff --git a/frontend/src/canvas/PinOverlay.ts b/frontend/src/canvas/PinOverlay.ts new file mode 100644 index 0000000..789ff1f --- /dev/null +++ b/frontend/src/canvas/PinOverlay.ts @@ -0,0 +1,133 @@ +import { Container, Graphics, Text, TextStyle } from 'pixi.js'; +import type { Viewport } from 'pixi-viewport'; +import type { AnnotationStore } from '../stores/annotationStore'; +import type { SceneManager } from './SceneManager'; + +const PIN_RADIUS = 10; +const PIN_COLOR_OPEN = 0xee4444; +const PIN_COLOR_RESOLVED = 0x666666; + +export class PinOverlay extends Container { + private _pins = new Map(); + private _voteBadges: Graphics[] = []; + private _viewport: Viewport; + private _scene: SceneManager; + private _store: AnnotationStore; + + constructor(viewport: Viewport, scene: SceneManager, store: AnnotationStore) { + super(); + this._viewport = viewport; + this._scene = scene; + this._store = store; + } + + /** Call on every viewport moved/zoomed event and on store change */ + refresh(showResolved = false) { + const scale = 1 / this._viewport.scale.x; // scale-independent size + + // Remove old pins + badges + for (const gfx of this._pins.values()) gfx.destroy(); + for (const gfx of this._voteBadges) gfx.destroy(); + this._pins.clear(); + this._voteBadges = []; + this.removeChildren(); + + // ── Thread pins ── + for (const thread of this._store.threads.values()) { + if (thread.status === 'resolved' && !showResolved) continue; + + const item = this._scene.items.get(thread.object_id); + if (!item || !item.displayObject) continue; + + const bounds = item.displayObject.getBounds(); + let wx: number, wy: number; + + if (thread.anchor_type === 'point' && thread.pin_x != null && thread.pin_y != null) { + wx = bounds.x + thread.pin_x * bounds.width; + wy = bounds.y + thread.pin_y * bounds.height; + } else { + // Object-level: top-right corner + wx = bounds.x + bounds.width; + wy = bounds.y; + } + + const gfx = new Graphics(); + const r = PIN_RADIUS * scale; + const color = thread.status === 'open' ? PIN_COLOR_OPEN : PIN_COLOR_RESOLVED; + + gfx.circle(0, 0, r); + gfx.fill({ color, alpha: 0.9 }); + gfx.stroke({ color: 0xffffff, width: 1.5 * scale, alpha: 0.8 }); + gfx.position.set(wx, wy); + + // Count label + if (thread.comment_count > 1) { + const label = new Text({ + text: String(thread.comment_count), + style: new TextStyle({ + fontSize: 9 * scale, + fill: '#ffffff', + fontWeight: 'bold', + }), + }); + label.anchor.set(0.5); + gfx.addChild(label); + } + + gfx.eventMode = 'static'; + gfx.cursor = 'pointer'; + (gfx as any)._threadId = thread.id; + + this._pins.set(thread.id, gfx); + this.addChild(gfx); + } + + // ── Vote badges ── + for (const [objectId, voters] of this._store.votes) { + if (voters.size === 0) continue; + const item = this._scene.items.get(objectId); + if (!item || !item.displayObject) continue; + + const bounds = item.displayObject.getBounds(); + const wx = bounds.x + bounds.width; + const wy = bounds.y + bounds.height; + + const gfx = new Graphics(); + const pw = 24 * scale; + const ph = 16 * scale; + const pr = 4 * scale; + gfx.roundRect(-pw / 2, -ph / 2, pw, ph, pr); + gfx.fill({ color: 0x2a3a50, alpha: 0.9 }); + gfx.position.set(wx - 16 * scale, wy - 4 * scale); + + const label = new Text({ + text: `${voters.size}`, + style: new TextStyle({ fontSize: 9 * scale, fill: '#4a9eff', fontWeight: 'bold' }), + }); + label.anchor.set(0.5); + gfx.addChild(label); + this.addChild(gfx); + this._voteBadges.push(gfx); + } + } + + getThreadIdAtPoint(worldX: number, worldY: number): string | null { + for (const [threadId, gfx] of this._pins) { + const dx = gfx.position.x - worldX; + const dy = gfx.position.y - worldY; + const hitRadius = PIN_RADIUS / this._viewport.scale.x; + if (dx * dx + dy * dy <= hitRadius * hitRadius) { + return threadId; + } + } + return null; + } + + override destroy(options?: any) { + for (const gfx of this._pins.values()) gfx.destroy(); + for (const gfx of this._voteBadges) gfx.destroy(); + this._pins.clear(); + this._voteBadges = []; + super.destroy(options); + } +} diff --git a/frontend/src/components/FeedbackPanel.tsx b/frontend/src/components/FeedbackPanel.tsx new file mode 100644 index 0000000..74097d7 --- /dev/null +++ b/frontend/src/components/FeedbackPanel.tsx @@ -0,0 +1,365 @@ +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; + onJumpToObject?: (objectId: string) => void; +} + +export default function FeedbackPanel({ + annotationStore, + selectedObjectId, + userId, + boardId, + token, + canvasObjects, + onJumpToObject, +}: FeedbackPanelProps) { + const [collapsed, setCollapsed] = useState(false); + const [expandedThreadId, setExpandedThreadId] = useState(null); + const [replyText, setReplyText] = useState(''); + const [filter, setFilter] = useState<'all' | 'unresolved' | 'mine'>('unresolved'); + const [newCommentText, setNewCommentText] = useState(''); + const [showOrphans, setShowOrphans] = useState(false); + + // 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); + } + + // Separate orphaned threads (object deleted from canvas) + const orphanedThreads = threads.filter((t) => !canvasObjects.has(t.object_id)); + threads = threads.filter((t) => canvasObjects.has(t.object_id)); + + // 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]); + + const toggleVote = useCallback(async (objectId: string) => { + await fetch(`/api/boards/${boardId}/votes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ object_id: objectId }), + }); + }, [boardId, token]); + + const createThread = useCallback(async () => { + if (!newCommentText.trim() || !selectedObjectId) return; + await fetch(`/api/boards/${boardId}/threads`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + object_id: selectedObjectId, + anchor_type: 'object', + content: newCommentText.trim(), + }), + }); + setNewCommentText(''); + }, [boardId, token, newCommentText, selectedObjectId]); + + // ── 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'} + + + {onJumpToObject && ( + + )} + {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 */} +
+