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
This commit is contained in:
Hiren Kangad
2026-03-10 21:04:38 +05:30
parent b6b615957b
commit 303124f518
10 changed files with 1365 additions and 2 deletions
+180
View File
@@ -115,6 +115,53 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_media_jobs_image ON media_jobs(image_id); 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 // Migrations — add columns to existing tables
try { try {
db.prepare("SELECT thumbnail FROM boards LIMIT 0").get(); 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); `).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 = { module.exports = {
db, db,
// Users // Users
@@ -499,4 +672,11 @@ module.exports = {
getAllBoardChannelLinks, getImageByMmFileId, getAllBoardChannelLinks, getImageByMmFileId,
// Media Jobs // Media Jobs
createMediaJob, updateMediaJob, getMediaJob, getPendingMediaJobs, updateImageMedia, createMediaJob, updateMediaJob, getMediaJob, getPendingMediaJobs, updateImageMedia,
// Threads
getThreadsByBoard, getThread, createThread, updateThreadStatus, deleteThread,
incrementThreadCommentCount, decrementThreadCommentCount,
// Comments
getCommentsByThread, getCommentsByBoard, getComment, createComment, updateComment, deleteComment,
// Votes
getVotesByBoard, toggleVote,
}; };
+314
View File
@@ -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;
+83
View File
@@ -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;
+5
View File
@@ -100,6 +100,8 @@ const boardRoutes = require('./routes/boards');
const uploadRoutes = require('./routes/upload'); const uploadRoutes = require('./routes/upload');
const adminRoutes = require('./routes/admin'); const adminRoutes = require('./routes/admin');
const mmBridgeRoutes = require('./routes/mattermost-bridge'); const mmBridgeRoutes = require('./routes/mattermost-bridge');
const threadRoutes = require('./routes/threads');
const voteRoutes = require('./routes/votes');
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/collections', collectionRoutes); app.use('/api/collections', collectionRoutes);
@@ -107,6 +109,8 @@ app.use('/api/boards', boardRoutes);
app.use('/api/upload', uploadRoutes); app.use('/api/upload', uploadRoutes);
app.use('/api/admin', adminRoutes); app.use('/api/admin', adminRoutes);
app.use('/api/boards', mmBridgeRoutes); app.use('/api/boards', mmBridgeRoutes);
app.use('/api/boards', threadRoutes);
app.use('/api/boards', voteRoutes);
// Public shared collection route (no auth required) // Public shared collection route (no auth required)
app.get('/api/c/:shareToken', (req, res) => { app.get('/api/c/:shareToken', (req, res) => {
@@ -151,6 +155,7 @@ const server = http.createServer(app);
const { setupSocket } = require('./socket'); const { setupSocket } = require('./socket');
const io = setupSocket(server); const io = setupSocket(server);
app.set('io', io);
// ---- Initialize services and start ---- // ---- Initialize services and start ----
async function start() { async function start() {
+133
View File
@@ -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<string, Graphics>();
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);
}
}
+365
View File
@@ -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<string, { id: string; name?: string; type: string }>;
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<string | null>(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 (
<div style={{
position: 'absolute', right: 0, top: 0, bottom: 0, width: '28px',
background: '#111', borderLeft: '1px solid #1a1a1a', zIndex: 100,
display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: '8px',
}}>
<button onClick={() => setCollapsed(false)} style={{
background: 'none', border: 'none', color: '#777', cursor: 'pointer', padding: '2px',
}} title="Open feedback panel">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M2 2a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2v3l3-3h7a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H2z"/>
</svg>
</button>
</div>
);
}
// ── Expanded thread view ──
const expandedThread = expandedThreadId ? annotationStore.threads.get(expandedThreadId) : null;
if (expandedThread) {
return (
<div style={{
position: 'absolute', right: 0, top: 0, bottom: 0, width: '280px',
background: '#111', borderLeft: '1px solid #1a1a1a', zIndex: 100,
display: 'flex', flexDirection: 'column', userSelect: 'none',
}}>
{/* Header */}
<div style={{ padding: '8px', borderBottom: '1px solid #1a1a1a', display: 'flex', alignItems: 'center', gap: '8px' }}>
<button onClick={() => setExpandedThreadId(null)} style={{
background: 'none', border: 'none', color: '#777', cursor: 'pointer', fontSize: '12px',
}}>Back</button>
<span style={{ color: '#aaa', fontSize: '12px', flex: 1 }}>
{expandedThread.status === 'resolved' ? 'Resolved' : 'Open'}
</span>
<button onClick={() => toggleVote(expandedThread.object_id)} style={{
background: 'none', border: 'none', cursor: 'pointer',
color: annotationStore.hasVoted(expandedThread.object_id, userId) ? '#4a9eff' : '#444',
fontSize: '11px',
}}>
{annotationStore.getVoteCount(expandedThread.object_id) > 0
? `+${annotationStore.getVoteCount(expandedThread.object_id)}`
: '+'}
</button>
{onJumpToObject && (
<button onClick={() => onJumpToObject(expandedThread.object_id)} style={{
background: 'none', border: 'none', color: '#4a9eff', cursor: 'pointer', fontSize: '10px',
}}>Jump</button>
)}
{expandedThread.status === 'open' ? (
<button onClick={() => resolveThread(expandedThread.id, 'resolved')} style={{
background: '#1a3a1a', border: 'none', borderRadius: '4px', color: '#4ade80',
padding: '2px 8px', cursor: 'pointer', fontSize: '11px',
}}>Resolve</button>
) : (
<button onClick={() => resolveThread(expandedThread.id, 'open')} style={{
background: '#1a1a3a', border: 'none', borderRadius: '4px', color: '#77a',
padding: '2px 8px', cursor: 'pointer', fontSize: '11px',
}}>Reopen</button>
)}
</div>
{/* Comments */}
<div style={{ flex: 1, overflowY: 'auto', padding: '8px' }}>
{expandedThread.comments.map((c) => (
<div key={c.id} style={{ marginBottom: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px' }}>
<span style={{ color: '#ddd', fontSize: '12px', fontWeight: 600 }}>{c.author_name}</span>
<span style={{ color: '#555', fontSize: '10px' }}>
{new Date(c.created_at).toLocaleString()}
</span>
{c.edited_at && <span style={{ color: '#555', fontSize: '10px' }}>(edited)</span>}
</div>
<div style={{ color: '#bbb', fontSize: '13px', lineHeight: '1.4' }}>{c.content}</div>
{c.user_id === userId && (
<button onClick={() => deleteComment(expandedThread.id, c.id)} style={{
background: 'none', border: 'none', color: '#644', cursor: 'pointer', fontSize: '10px', marginTop: '2px',
}}>Delete</button>
)}
</div>
))}
</div>
{/* Reply input */}
<div style={{ padding: '8px', borderTop: '1px solid #1a1a1a' }}>
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Reply..."
rows={2}
style={{
width: '100%', background: '#1a1a1a', border: '1px solid #333', borderRadius: '4px',
color: '#ddd', padding: '6px', fontSize: '12px', resize: 'none', boxSizing: 'border-box',
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
postReply(expandedThread.id);
}
}}
/>
<button onClick={() => postReply(expandedThread.id)} style={{
marginTop: '4px', background: '#2a3a50', border: 'none', borderRadius: '4px',
color: '#4a9eff', padding: '4px 12px', cursor: 'pointer', fontSize: '12px',
}}>Reply</button>
</div>
</div>
);
}
// ── Thread list view ──
return (
<div style={{
position: 'absolute', right: 0, top: 0, bottom: 0, width: '280px',
background: '#111', borderLeft: '1px solid #1a1a1a', zIndex: 100,
display: 'flex', flexDirection: 'column', userSelect: 'none',
}}>
{/* Header */}
<div style={{ padding: '8px', borderBottom: '1px solid #1a1a1a', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ color: '#ddd', fontSize: '13px', fontWeight: 600 }}>
Feedback
{allThreads.filter((t) => t.status === 'open').length > 0 && (
<span style={{ color: '#e44', fontSize: '11px', fontWeight: 400, marginLeft: '6px' }}>
{allThreads.filter((t) => t.status === 'open').length} open
</span>
)}
</span>
<button onClick={() => setCollapsed(true)} style={{
background: 'none', border: 'none', color: '#555', cursor: 'pointer', fontSize: '14px',
}}>x</button>
</div>
{/* Filter bar */}
<div style={{ padding: '6px 8px', borderBottom: '1px solid #1a1a1a', display: 'flex', gap: '4px' }}>
{(['unresolved', 'all', 'mine'] as const).map((f) => (
<button key={f} onClick={() => setFilter(f)} style={{
background: filter === f ? '#2a3a50' : 'transparent',
border: 'none', borderRadius: '4px', color: filter === f ? '#4a9eff' : '#555',
padding: '2px 8px', cursor: 'pointer', fontSize: '11px', textTransform: 'capitalize',
}}>{f}</button>
))}
</div>
{/* New comment input (when object selected) */}
{selectedObjectId && (
<div style={{ padding: '8px', borderBottom: '1px solid #1a1a1a' }}>
<div style={{ color: '#666', fontSize: '10px', marginBottom: '4px' }}>
Comment on: {canvasObjects.get(selectedObjectId)?.name || canvasObjects.get(selectedObjectId)?.type || selectedObjectId.slice(0, 8)}
</div>
<textarea
value={newCommentText}
onChange={(e) => setNewCommentText(e.target.value)}
placeholder="Add a comment..."
rows={2}
style={{
width: '100%', background: '#1a1a1a', border: '1px solid #333', borderRadius: '4px',
color: '#ddd', padding: '6px', fontSize: '12px', resize: 'none', boxSizing: 'border-box',
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) createThread();
}}
/>
<button onClick={createThread} disabled={!newCommentText.trim()} style={{
marginTop: '4px', background: newCommentText.trim() ? '#2a3a50' : '#1a1a1a',
border: 'none', borderRadius: '4px',
color: newCommentText.trim() ? '#4a9eff' : '#444',
padding: '4px 12px', cursor: newCommentText.trim() ? 'pointer' : 'default', fontSize: '12px',
}}>Comment</button>
</div>
)}
{/* Thread list */}
<div style={{ flex: 1, overflowY: 'auto' }}>
{threads.length === 0 && !selectedObjectId && (
<div style={{ padding: '16px', color: '#444', fontSize: '12px', textAlign: 'center' }}>
No threads yet
</div>
)}
{threads.length === 0 && selectedObjectId && (
<div style={{ padding: '16px', color: '#444', fontSize: '12px', textAlign: 'center' }}>
No comments on this object
</div>
)}
{threads.map((t) => {
const obj = canvasObjects.get(t.object_id);
const firstComment = t.comments[0];
return (
<div
key={t.id}
onClick={() => setExpandedThreadId(t.id)}
style={{
padding: '8px', borderBottom: '1px solid #1a1a1a', cursor: 'pointer',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.background = '#1a1a1a'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px' }}>
<span style={{
width: '8px', height: '8px', borderRadius: '50%',
background: t.status === 'open' ? '#e44' : '#666', flexShrink: 0,
}} />
<span style={{ color: '#aaa', fontSize: '11px', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{obj?.name || obj?.type || t.object_id.slice(0, 8)}
</span>
<span style={{ color: '#555', fontSize: '10px' }}>{t.comment_count}</span>
<button onClick={(e) => { e.stopPropagation(); toggleVote(t.object_id); }} style={{
background: 'none', border: 'none', cursor: 'pointer', padding: '0 2px',
color: annotationStore.hasVoted(t.object_id, userId) ? '#4a9eff' : '#444',
fontSize: '11px', flexShrink: 0,
}}>
{annotationStore.getVoteCount(t.object_id) > 0
? `+${annotationStore.getVoteCount(t.object_id)}`
: '+'}
</button>
</div>
{firstComment && (
<div style={{ color: '#777', fontSize: '12px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{firstComment.author_name}: {firstComment.content}
</div>
)}
</div>
);
})}
{/* Orphaned threads (deleted objects) */}
{orphanedThreads.length > 0 && (
<div>
<div onClick={() => setShowOrphans(!showOrphans)} style={{
padding: '8px', cursor: 'pointer', color: '#555', fontSize: '11px',
borderTop: '1px solid #1a1a1a',
}}>
{showOrphans ? 'v' : '>'} Deleted items ({orphanedThreads.length})
</div>
{showOrphans && orphanedThreads.map((t) => {
const firstComment = t.comments[0];
return (
<div key={t.id} onClick={() => setExpandedThreadId(t.id)} style={{
padding: '8px', borderBottom: '1px solid #1a1a1a', cursor: 'pointer', opacity: 0.5,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px' }}>
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#444', flexShrink: 0 }} />
<span style={{ color: '#666', fontSize: '11px', flex: 1 }}>{t.object_id.slice(0, 8)}...</span>
<span style={{ color: '#555', fontSize: '10px' }}>{t.comment_count}</span>
</div>
{firstComment && (
<div style={{ color: '#555', fontSize: '12px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{firstComment.author_name}: {firstComment.content}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
+13
View File
@@ -34,6 +34,8 @@ interface ToolbarProps {
onToggleHelp?: () => void; onToggleHelp?: () => void;
onMmImport?: () => void; onMmImport?: () => void;
onExport?: () => void; onExport?: () => void;
onToggleReview?: () => void;
reviewMode?: boolean;
boardName?: string; boardName?: string;
} }
@@ -150,6 +152,8 @@ export default function Toolbar({
onToggleHelp, onToggleHelp,
onMmImport, onMmImport,
onExport, onExport,
onToggleReview,
reviewMode,
}: ToolbarProps) { }: ToolbarProps) {
const showStroke = activeTool === ToolType.PEN; const showStroke = activeTool === ToolType.PEN;
const showFontSize = activeTool === ToolType.TEXT; const showFontSize = activeTool === ToolType.TEXT;
@@ -309,6 +313,15 @@ export default function Toolbar({
</ActionBtn> </ActionBtn>
)} )}
{/* Review / Feedback */}
{onToggleReview && (
<ActionBtn onClick={onToggleReview} title="Review Mode (R)" active={reviewMode}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
<path d="M2 2.5A1.5 1.5 0 013.5 1h7A1.5 1.5 0 0112 2.5v6A1.5 1.5 0 0110.5 10H6l-3 3v-3H3.5A1.5 1.5 0 012 8.5v-6z" />
</svg>
</ActionBtn>
)}
{/* Spacer */} {/* Spacer */}
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
+71
View File
@@ -9,6 +9,8 @@ import { InboxZone } from '../canvas/InboxZone';
import { LaserPointer } from '../canvas/LaserPointer'; import { LaserPointer } from '../canvas/LaserPointer';
import { VideoSprite } from '../canvas/sprites/VideoSprite'; import { VideoSprite } from '../canvas/sprites/VideoSprite';
import { UploadManager } from '../stores/uploadManager'; import { UploadManager } from '../stores/uploadManager';
import { AnnotationStore } from '../stores/annotationStore';
import { PinOverlay } from '../canvas/PinOverlay';
// PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit // PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit
import { connectSocket, disconnectSocket } from '../socket'; import { connectSocket, disconnectSocket } from '../socket';
@@ -64,6 +66,9 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
const dropCleanupRef = useRef<(() => void) | null>(null); const dropCleanupRef = useRef<(() => void) | null>(null);
const pasteCleanupRef = useRef<(() => void) | null>(null); const pasteCleanupRef = useRef<(() => void) | null>(null);
const laserCleanupRef = useRef<(() => void) | null>(null); const laserCleanupRef = useRef<(() => void) | null>(null);
const annotationStoreRef = useRef<AnnotationStore | null>(null);
if (!annotationStoreRef.current) annotationStoreRef.current = new AnnotationStore();
const pinOverlayRef = useRef<PinOverlay | null>(null);
useEffect(() => { useEffect(() => {
if (!boardData || !resolvedBoardId) return; if (!boardData || !resolvedBoardId) return;
@@ -298,6 +303,65 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
laser.removeRemote(uid); laser.removeRemote(uid);
} }
}); });
// ── Annotations: load threads + votes, wire socket events ──
const token = localStorage.getItem('token');
if (token) {
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));
}
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);
});
// ── Pin overlay (hidden by default, shown in Review Mode) ──
const pinOverlay = new PinOverlay(viewport, scene, annotationStoreRef.current!);
pinOverlay.visible = false;
viewport.addChild(pinOverlay);
pinOverlayRef.current = pinOverlay;
// Refresh overlay on viewport move
viewport.on('moved', () => {
if (pinOverlay.visible) pinOverlay.refresh();
});
// Refresh on store change
annotationStoreRef.current!.subscribe(() => {
if (pinOverlay.visible) pinOverlay.refresh();
});
} }
// Setup drag/drop and paste // Setup drag/drop and paste
@@ -329,7 +393,14 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
laserCleanupRef.current = null; laserCleanupRef.current = null;
dropCleanupRef.current?.(); dropCleanupRef.current?.();
pasteCleanupRef.current?.(); pasteCleanupRef.current?.();
if (pinOverlayRef.current) {
pinOverlayRef.current.destroy();
pinOverlayRef.current = null;
}
annotationStoreRef.current?.clear();
disconnectSocket(); disconnectSocket();
}; };
}, [boardData, resolvedBoardId, user, isPublicView, onCanvasChange, showToast, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, uploadManager, setOnlineUsers, setSelectedLayerIds]); }, [boardData, resolvedBoardId, user, isPublicView, onCanvasChange, showToast, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, uploadManager, setOnlineUsers, setSelectedLayerIds]);
return { annotationStore: annotationStoreRef.current, pinOverlay: pinOverlayRef.current };
} }
+45 -2
View File
@@ -26,6 +26,7 @@ import MattermostImport from '../components/MattermostImport';
import Minimap from '../components/Minimap'; import Minimap from '../components/Minimap';
import UploadPanel from '../components/UploadPanel'; import UploadPanel from '../components/UploadPanel';
import ExportDialog from '../components/ExportDialog'; import ExportDialog from '../components/ExportDialog';
import FeedbackPanel from '../components/FeedbackPanel';
import { UploadManager } from '../stores/uploadManager'; import { UploadManager } from '../stores/uploadManager';
import { InboxZone } from '../canvas/InboxZone'; import { InboxZone } from '../canvas/InboxZone';
import { getItemWorldBounds } from '../canvas/SceneManager'; import { getItemWorldBounds } from '../canvas/SceneManager';
@@ -90,6 +91,7 @@ export default function Editor({ isPublicView }: EditorProps) {
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const [showMmImport, setShowMmImport] = useState(false); const [showMmImport, setShowMmImport] = useState(false);
const [showExport, setShowExport] = useState(false); const [showExport, setShowExport] = useState(false);
const [reviewMode, setReviewMode] = useState(false);
const [focusMode, setFocusMode] = useState(false); const [focusMode, setFocusMode] = useState(false);
const [layerList, setLayerList] = useState<any[]>([]); const [layerList, setLayerList] = useState<any[]>([]);
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]); const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
@@ -153,13 +155,21 @@ export default function Editor({ isPublicView }: EditorProps) {
getViewport, getViewport,
}); });
// Canvas setup (selection, undo, sync, socket, drag/drop, paste, inbox) // Canvas setup (selection, undo, sync, socket, drag/drop, paste, inbox, annotations)
useCanvasSetup({ const { annotationStore, pinOverlay } = useCanvasSetup({
boardData, resolvedBoardId, user, isPublicView, boardData, resolvedBoardId, user, isPublicView,
canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef,
uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds, uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
}); });
// Toggle pin overlay visibility with review mode
useEffect(() => {
if (pinOverlay) {
pinOverlay.visible = reviewMode;
if (reviewMode) pinOverlay.refresh();
}
}, [reviewMode, pinOverlay]);
// Tool activation // Tool activation
useEffect(() => { useEffect(() => {
const viewport = canvasRef.current?.getViewport(); const viewport = canvasRef.current?.getViewport();
@@ -490,6 +500,8 @@ export default function Editor({ isPublicView }: EditorProps) {
onToggleHelp={() => setShowHelp((v) => !v)} onToggleHelp={() => setShowHelp((v) => !v)}
onMmImport={() => setShowMmImport(true)} onMmImport={() => setShowMmImport(true)}
onExport={() => setShowExport(true)} onExport={() => setShowExport(true)}
onToggleReview={() => setReviewMode((v) => !v)}
reviewMode={reviewMode}
boardName={board?.name} boardName={board?.name}
/>} />}
@@ -643,6 +655,37 @@ export default function Editor({ isPublicView }: EditorProps) {
/> />
)} )}
{/* Feedback / Review panel */}
{reviewMode && annotationStore && user && resolvedBoardId && (
<FeedbackPanel
annotationStore={annotationStore}
selectedObjectId={selectedLayerIds.length === 1 ? selectedLayerIds[0] : null}
userId={user.id}
boardId={resolvedBoardId}
token={localStorage.getItem('token') || ''}
canvasObjects={(() => {
const scene = canvasRef.current?.getScene();
const map = new Map<string, { id: string; name?: string; type: string }>();
if (scene) {
for (const item of scene.items.values()) {
map.set(item.id, { id: item.id, name: item.data.name, type: item.data.type });
}
}
return map;
})()}
onJumpToObject={(objectId) => {
const scene = canvasRef.current?.getScene();
const vp = canvasRef.current?.getViewport();
if (!scene || !vp) return;
const item = scene.items.get(objectId);
if (!item) return;
const b = getItemWorldBounds(item);
vp.animate({ time: 300, position: { x: b.x + b.w / 2, y: b.y + b.h / 2 }, ease: 'easeOutQuad' });
selectionRef.current?.selectOnly(objectId);
}}
/>
)}
{/* Upload progress panel */} {/* Upload progress panel */}
<UploadPanel uploadManager={uploadManager} /> <UploadPanel uploadManager={uploadManager} />
+156
View File
@@ -0,0 +1,156 @@
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<string, Thread>();
/** objectId → Set<userId> */
votes = new Map<string, Set<string>>();
private _listeners = new Set<Listener>();
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;
}
}