fix(annotations): address code review findings (1-8, 10)
1. toggleVote wrapped in transaction (race condition fix) 2. FeedbackPanel fetch calls now surface errors via onError/toast 3. Extracted resolveBoard/hasCollectionRole to shared board-access.js 4. AnnotationStore uses monotonic version counter for snapshots 5. PinOverlay uses object pool instead of destroy/recreate on refresh 6. canvasObjects prop memoized with useMemo 7. PinOverlay store subscription cleaned up on unmount 8. Comment content capped at 5000 chars (backend validation) 10. anchor_type validated to 'object' or 'point'
This commit is contained in:
+5
-1
@@ -534,7 +534,7 @@ function getVotesByBoard(boardId) {
|
|||||||
return db.prepare('SELECT * FROM object_votes WHERE board_id = ?').all(boardId);
|
return db.prepare('SELECT * FROM object_votes WHERE board_id = ?').all(boardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleVote(boardId, objectId, userId) {
|
const _toggleVoteTx = db.transaction((boardId, objectId, userId) => {
|
||||||
const existing = db.prepare(
|
const existing = db.prepare(
|
||||||
'SELECT 1 FROM object_votes WHERE board_id = ? AND object_id = ? AND user_id = ?'
|
'SELECT 1 FROM object_votes WHERE board_id = ? AND object_id = ? AND user_id = ?'
|
||||||
).get(boardId, objectId, userId);
|
).get(boardId, objectId, userId);
|
||||||
@@ -548,6 +548,10 @@ function toggleVote(boardId, objectId, userId) {
|
|||||||
.run(boardId, objectId, userId);
|
.run(boardId, objectId, userId);
|
||||||
return true; // vote added
|
return true; // vote added
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleVote(boardId, objectId, userId) {
|
||||||
|
return _toggleVoteTx(boardId, objectId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------
|
// ---------------------
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const { getBoard, getCollection, getCollectionMember } = require('../db');
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { hasCollectionRole, resolveBoard };
|
||||||
+14
-26
@@ -2,9 +2,6 @@ const { Router } = require('express');
|
|||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const { authMiddleware } = require('../auth');
|
const { authMiddleware } = require('../auth');
|
||||||
const {
|
const {
|
||||||
getBoard,
|
|
||||||
getCollection,
|
|
||||||
getCollectionMember,
|
|
||||||
getThreadsByBoard,
|
getThreadsByBoard,
|
||||||
getThread,
|
getThread,
|
||||||
createThread,
|
createThread,
|
||||||
@@ -18,34 +15,13 @@ const {
|
|||||||
incrementThreadCommentCount,
|
incrementThreadCommentCount,
|
||||||
decrementThreadCommentCount,
|
decrementThreadCommentCount,
|
||||||
} = require('../db');
|
} = require('../db');
|
||||||
|
const { hasCollectionRole, resolveBoard } = require('./board-access');
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
|
|
||||||
function hasCollectionRole(member, minRole) {
|
const MAX_COMMENT_LENGTH = 5000;
|
||||||
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
|
// GET /api/boards/:boardId/threads — all threads + comments for board
|
||||||
router.get('/:boardId/threads', (req, res) => {
|
router.get('/:boardId/threads', (req, res) => {
|
||||||
@@ -85,6 +61,12 @@ router.post('/:boardId/threads', (req, res) => {
|
|||||||
if (!object_id || !content || !content.trim()) {
|
if (!object_id || !content || !content.trim()) {
|
||||||
return res.status(400).json({ error: 'object_id and content are required' });
|
return res.status(400).json({ error: 'object_id and content are required' });
|
||||||
}
|
}
|
||||||
|
if (content.length > MAX_COMMENT_LENGTH) {
|
||||||
|
return res.status(400).json({ error: `Content too long (max ${MAX_COMMENT_LENGTH} chars)` });
|
||||||
|
}
|
||||||
|
if (anchor_type && !['object', 'point'].includes(anchor_type)) {
|
||||||
|
return res.status(400).json({ error: 'anchor_type must be "object" or "point"' });
|
||||||
|
}
|
||||||
|
|
||||||
const threadId = uuidv4();
|
const threadId = uuidv4();
|
||||||
const commentId = uuidv4();
|
const commentId = uuidv4();
|
||||||
@@ -200,6 +182,9 @@ router.post('/:boardId/threads/:threadId/comments', (req, res) => {
|
|||||||
if (!content || !content.trim()) {
|
if (!content || !content.trim()) {
|
||||||
return res.status(400).json({ error: 'content is required' });
|
return res.status(400).json({ error: 'content is required' });
|
||||||
}
|
}
|
||||||
|
if (content.length > MAX_COMMENT_LENGTH) {
|
||||||
|
return res.status(400).json({ error: `Content too long (max ${MAX_COMMENT_LENGTH} chars)` });
|
||||||
|
}
|
||||||
|
|
||||||
const thread = getThread(req.params.threadId);
|
const thread = getThread(req.params.threadId);
|
||||||
if (!thread || thread.board_id !== req.params.boardId) {
|
if (!thread || thread.board_id !== req.params.boardId) {
|
||||||
@@ -246,6 +231,9 @@ router.put('/:boardId/threads/:threadId/comments/:commentId', (req, res) => {
|
|||||||
if (!content || !content.trim()) {
|
if (!content || !content.trim()) {
|
||||||
return res.status(400).json({ error: 'content is required' });
|
return res.status(400).json({ error: 'content is required' });
|
||||||
}
|
}
|
||||||
|
if (content.length > MAX_COMMENT_LENGTH) {
|
||||||
|
return res.status(400).json({ error: `Content too long (max ${MAX_COMMENT_LENGTH} chars)` });
|
||||||
|
}
|
||||||
|
|
||||||
const comment = getComment(req.params.commentId);
|
const comment = getComment(req.params.commentId);
|
||||||
if (!comment || comment.thread_id !== req.params.threadId) {
|
if (!comment || comment.thread_id !== req.params.threadId) {
|
||||||
|
|||||||
+2
-31
@@ -1,41 +1,12 @@
|
|||||||
const { Router } = require('express');
|
const { Router } = require('express');
|
||||||
const { authMiddleware } = require('../auth');
|
const { authMiddleware } = require('../auth');
|
||||||
const {
|
const { getVotesByBoard, toggleVote } = require('../db');
|
||||||
getBoard,
|
const { resolveBoard } = require('./board-access');
|
||||||
getCollection,
|
|
||||||
getCollectionMember,
|
|
||||||
getVotesByBoard,
|
|
||||||
toggleVote,
|
|
||||||
} = require('../db');
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(authMiddleware);
|
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
|
// GET /api/boards/:boardId/votes — all votes for board
|
||||||
router.get('/:boardId/votes', (req, res) => {
|
router.get('/:boardId/votes', (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ const PIN_COLOR_RESOLVED = 0x666666;
|
|||||||
|
|
||||||
export class PinOverlay extends Container {
|
export class PinOverlay extends Container {
|
||||||
private _pins = new Map<string, Graphics>();
|
private _pins = new Map<string, Graphics>();
|
||||||
private _voteBadges: Graphics[] = [];
|
private _voteBadges = new Map<string, Graphics>();
|
||||||
|
private _pool: Graphics[] = [];
|
||||||
private _viewport: Viewport;
|
private _viewport: Viewport;
|
||||||
private _scene: SceneManager;
|
private _scene: SceneManager;
|
||||||
private _store: AnnotationStore;
|
private _store: AnnotationStore;
|
||||||
@@ -21,16 +22,30 @@ export class PinOverlay extends Container {
|
|||||||
this._store = store;
|
this._store = store;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _acquire(): Graphics {
|
||||||
|
const gfx = this._pool.pop() || new Graphics();
|
||||||
|
gfx.clear();
|
||||||
|
gfx.removeChildren();
|
||||||
|
gfx.visible = true;
|
||||||
|
gfx.eventMode = 'none';
|
||||||
|
gfx.cursor = 'default';
|
||||||
|
return gfx;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _release(gfx: Graphics) {
|
||||||
|
gfx.visible = false;
|
||||||
|
this._pool.push(gfx);
|
||||||
|
}
|
||||||
|
|
||||||
/** Call on every viewport moved/zoomed event and on store change */
|
/** Call on every viewport moved/zoomed event and on store change */
|
||||||
refresh(showResolved = false) {
|
refresh(showResolved = false) {
|
||||||
const scale = 1 / this._viewport.scale.x; // scale-independent size
|
const scale = 1 / this._viewport.scale.x;
|
||||||
|
|
||||||
// Remove old pins + badges
|
// Return current pins and badges to pool
|
||||||
for (const gfx of this._pins.values()) gfx.destroy();
|
for (const gfx of this._pins.values()) this._release(gfx);
|
||||||
for (const gfx of this._voteBadges) gfx.destroy();
|
for (const gfx of this._voteBadges.values()) this._release(gfx);
|
||||||
this._pins.clear();
|
this._pins.clear();
|
||||||
this._voteBadges = [];
|
this._voteBadges.clear();
|
||||||
this.removeChildren();
|
|
||||||
|
|
||||||
// ── Thread pins ──
|
// ── Thread pins ──
|
||||||
for (const thread of this._store.threads.values()) {
|
for (const thread of this._store.threads.values()) {
|
||||||
@@ -46,12 +61,11 @@ export class PinOverlay extends Container {
|
|||||||
wx = bounds.x + thread.pin_x * bounds.width;
|
wx = bounds.x + thread.pin_x * bounds.width;
|
||||||
wy = bounds.y + thread.pin_y * bounds.height;
|
wy = bounds.y + thread.pin_y * bounds.height;
|
||||||
} else {
|
} else {
|
||||||
// Object-level: top-right corner
|
|
||||||
wx = bounds.x + bounds.width;
|
wx = bounds.x + bounds.width;
|
||||||
wy = bounds.y;
|
wy = bounds.y;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gfx = new Graphics();
|
const gfx = this._acquire();
|
||||||
const r = PIN_RADIUS * scale;
|
const r = PIN_RADIUS * scale;
|
||||||
const color = thread.status === 'open' ? PIN_COLOR_OPEN : PIN_COLOR_RESOLVED;
|
const color = thread.status === 'open' ? PIN_COLOR_OPEN : PIN_COLOR_RESOLVED;
|
||||||
|
|
||||||
@@ -59,27 +73,21 @@ export class PinOverlay extends Container {
|
|||||||
gfx.fill({ color, alpha: 0.9 });
|
gfx.fill({ color, alpha: 0.9 });
|
||||||
gfx.stroke({ color: 0xffffff, width: 1.5 * scale, alpha: 0.8 });
|
gfx.stroke({ color: 0xffffff, width: 1.5 * scale, alpha: 0.8 });
|
||||||
gfx.position.set(wx, wy);
|
gfx.position.set(wx, wy);
|
||||||
|
gfx.eventMode = 'static';
|
||||||
|
gfx.cursor = 'pointer';
|
||||||
|
(gfx as any)._threadId = thread.id;
|
||||||
|
|
||||||
// Count label
|
|
||||||
if (thread.comment_count > 1) {
|
if (thread.comment_count > 1) {
|
||||||
const label = new Text({
|
const label = new Text({
|
||||||
text: String(thread.comment_count),
|
text: String(thread.comment_count),
|
||||||
style: new TextStyle({
|
style: new TextStyle({ fontSize: 9 * scale, fill: '#ffffff', fontWeight: 'bold' }),
|
||||||
fontSize: 9 * scale,
|
|
||||||
fill: '#ffffff',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
label.anchor.set(0.5);
|
label.anchor.set(0.5);
|
||||||
gfx.addChild(label);
|
gfx.addChild(label);
|
||||||
}
|
}
|
||||||
|
|
||||||
gfx.eventMode = 'static';
|
if (!gfx.parent) this.addChild(gfx);
|
||||||
gfx.cursor = 'pointer';
|
|
||||||
(gfx as any)._threadId = thread.id;
|
|
||||||
|
|
||||||
this._pins.set(thread.id, gfx);
|
this._pins.set(thread.id, gfx);
|
||||||
this.addChild(gfx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Vote badges ──
|
// ── Vote badges ──
|
||||||
@@ -92,7 +100,7 @@ export class PinOverlay extends Container {
|
|||||||
const wx = bounds.x + bounds.width;
|
const wx = bounds.x + bounds.width;
|
||||||
const wy = bounds.y + bounds.height;
|
const wy = bounds.y + bounds.height;
|
||||||
|
|
||||||
const gfx = new Graphics();
|
const gfx = this._acquire();
|
||||||
const pw = 24 * scale;
|
const pw = 24 * scale;
|
||||||
const ph = 16 * scale;
|
const ph = 16 * scale;
|
||||||
const pr = 4 * scale;
|
const pr = 4 * scale;
|
||||||
@@ -106,8 +114,9 @@ export class PinOverlay extends Container {
|
|||||||
});
|
});
|
||||||
label.anchor.set(0.5);
|
label.anchor.set(0.5);
|
||||||
gfx.addChild(label);
|
gfx.addChild(label);
|
||||||
this.addChild(gfx);
|
|
||||||
this._voteBadges.push(gfx);
|
if (!gfx.parent) this.addChild(gfx);
|
||||||
|
this._voteBadges.set(objectId, gfx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,9 +134,11 @@ export class PinOverlay extends Container {
|
|||||||
|
|
||||||
override destroy(options?: any) {
|
override destroy(options?: any) {
|
||||||
for (const gfx of this._pins.values()) gfx.destroy();
|
for (const gfx of this._pins.values()) gfx.destroy();
|
||||||
for (const gfx of this._voteBadges) gfx.destroy();
|
for (const gfx of this._voteBadges.values()) gfx.destroy();
|
||||||
|
for (const gfx of this._pool) gfx.destroy();
|
||||||
this._pins.clear();
|
this._pins.clear();
|
||||||
this._voteBadges = [];
|
this._voteBadges.clear();
|
||||||
|
this._pool = [];
|
||||||
super.destroy(options);
|
super.destroy(options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface FeedbackPanelProps {
|
|||||||
token: string;
|
token: string;
|
||||||
canvasObjects: Map<string, { id: string; name?: string; type: string }>;
|
canvasObjects: Map<string, { id: string; name?: string; type: string }>;
|
||||||
onJumpToObject?: (objectId: string) => void;
|
onJumpToObject?: (objectId: string) => void;
|
||||||
|
onError?: (msg: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FeedbackPanel({
|
export default function FeedbackPanel({
|
||||||
@@ -19,6 +20,7 @@ export default function FeedbackPanel({
|
|||||||
token,
|
token,
|
||||||
canvasObjects,
|
canvasObjects,
|
||||||
onJumpToObject,
|
onJumpToObject,
|
||||||
|
onError,
|
||||||
}: FeedbackPanelProps) {
|
}: FeedbackPanelProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [expandedThreadId, setExpandedThreadId] = useState<string | null>(null);
|
const [expandedThreadId, setExpandedThreadId] = useState<string | null>(null);
|
||||||
@@ -30,7 +32,7 @@ export default function FeedbackPanel({
|
|||||||
// Subscribe to store changes
|
// Subscribe to store changes
|
||||||
const _version = useSyncExternalStore(
|
const _version = useSyncExternalStore(
|
||||||
(cb) => annotationStore.subscribe(cb),
|
(cb) => annotationStore.subscribe(cb),
|
||||||
() => annotationStore.threads.size + annotationStore.votes.size,
|
() => annotationStore.version,
|
||||||
);
|
);
|
||||||
|
|
||||||
const allThreads = Array.from(annotationStore.threads.values());
|
const allThreads = Array.from(annotationStore.threads.values());
|
||||||
@@ -54,42 +56,65 @@ export default function FeedbackPanel({
|
|||||||
|
|
||||||
// ── API helpers ──
|
// ── API helpers ──
|
||||||
|
|
||||||
|
const apiFetch = useCallback(async (url: string, init?: RequestInit) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Request failed (${res.status})`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} catch (err: any) {
|
||||||
|
onError?.(err.message || 'Request failed');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [onError]);
|
||||||
|
|
||||||
const postReply = useCallback(async (threadId: string) => {
|
const postReply = useCallback(async (threadId: string) => {
|
||||||
if (!replyText.trim()) return;
|
if (!replyText.trim()) return;
|
||||||
await fetch(`/api/boards/${boardId}/threads/${threadId}/comments`, {
|
try {
|
||||||
|
await apiFetch(`/api/boards/${boardId}/threads/${threadId}/comments`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify({ content: replyText.trim() }),
|
body: JSON.stringify({ content: replyText.trim() }),
|
||||||
});
|
});
|
||||||
setReplyText('');
|
setReplyText('');
|
||||||
}, [boardId, token, replyText]);
|
} catch {}
|
||||||
|
}, [boardId, token, replyText, apiFetch]);
|
||||||
|
|
||||||
const resolveThread = useCallback(async (threadId: string, status: string) => {
|
const resolveThread = useCallback(async (threadId: string, status: string) => {
|
||||||
await fetch(`/api/boards/${boardId}/threads/${threadId}`, {
|
try {
|
||||||
|
await apiFetch(`/api/boards/${boardId}/threads/${threadId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify({ status }),
|
body: JSON.stringify({ status }),
|
||||||
});
|
});
|
||||||
}, [boardId, token]);
|
} catch {}
|
||||||
|
}, [boardId, token, apiFetch]);
|
||||||
|
|
||||||
const deleteComment = useCallback(async (threadId: string, commentId: string) => {
|
const deleteComment = useCallback(async (threadId: string, commentId: string) => {
|
||||||
await fetch(`/api/boards/${boardId}/threads/${threadId}/comments/${commentId}`, {
|
try {
|
||||||
|
await apiFetch(`/api/boards/${boardId}/threads/${threadId}/comments/${commentId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
}, [boardId, token]);
|
} catch {}
|
||||||
|
}, [boardId, token, apiFetch]);
|
||||||
|
|
||||||
const toggleVote = useCallback(async (objectId: string) => {
|
const toggleVote = useCallback(async (objectId: string) => {
|
||||||
await fetch(`/api/boards/${boardId}/votes`, {
|
try {
|
||||||
|
await apiFetch(`/api/boards/${boardId}/votes`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify({ object_id: objectId }),
|
body: JSON.stringify({ object_id: objectId }),
|
||||||
});
|
});
|
||||||
}, [boardId, token]);
|
} catch {}
|
||||||
|
}, [boardId, token, apiFetch]);
|
||||||
|
|
||||||
const createThread = useCallback(async () => {
|
const createThread = useCallback(async () => {
|
||||||
if (!newCommentText.trim() || !selectedObjectId) return;
|
if (!newCommentText.trim() || !selectedObjectId) return;
|
||||||
await fetch(`/api/boards/${boardId}/threads`, {
|
try {
|
||||||
|
await apiFetch(`/api/boards/${boardId}/threads`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -99,7 +124,8 @@ export default function FeedbackPanel({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
setNewCommentText('');
|
setNewCommentText('');
|
||||||
}, [boardId, token, newCommentText, selectedObjectId]);
|
} catch {}
|
||||||
|
}, [boardId, token, newCommentText, selectedObjectId, apiFetch]);
|
||||||
|
|
||||||
// ── Collapsed state ──
|
// ── Collapsed state ──
|
||||||
|
|
||||||
|
|||||||
@@ -355,13 +355,18 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
pinOverlayRef.current = pinOverlay;
|
pinOverlayRef.current = pinOverlay;
|
||||||
|
|
||||||
// Refresh overlay on viewport move
|
// Refresh overlay on viewport move
|
||||||
viewport.on('moved', () => {
|
const onViewportMoved = () => {
|
||||||
if (pinOverlay.visible) pinOverlay.refresh();
|
if (pinOverlay.visible) pinOverlay.refresh();
|
||||||
});
|
};
|
||||||
// Refresh on store change
|
viewport.on('moved', onViewportMoved);
|
||||||
annotationStoreRef.current!.subscribe(() => {
|
// Refresh on store change (save unsub for cleanup)
|
||||||
|
const unsubPinOverlay = annotationStoreRef.current!.subscribe(() => {
|
||||||
if (pinOverlay.visible) pinOverlay.refresh();
|
if (pinOverlay.visible) pinOverlay.refresh();
|
||||||
});
|
});
|
||||||
|
(pinOverlay as any)._cleanup = () => {
|
||||||
|
viewport.off('moved', onViewportMoved);
|
||||||
|
unsubPinOverlay();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup drag/drop and paste
|
// Setup drag/drop and paste
|
||||||
@@ -394,6 +399,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
dropCleanupRef.current?.();
|
dropCleanupRef.current?.();
|
||||||
pasteCleanupRef.current?.();
|
pasteCleanupRef.current?.();
|
||||||
if (pinOverlayRef.current) {
|
if (pinOverlayRef.current) {
|
||||||
|
(pinOverlayRef.current as any)._cleanup?.();
|
||||||
pinOverlayRef.current.destroy();
|
pinOverlayRef.current.destroy();
|
||||||
pinOverlayRef.current = null;
|
pinOverlayRef.current = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,19 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
|
uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build canvas objects map for FeedbackPanel (memoized on object count changes)
|
||||||
|
const canvasObjectMap = React.useMemo(() => {
|
||||||
|
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;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [objectCount]);
|
||||||
|
|
||||||
// Toggle pin overlay visibility with review mode
|
// Toggle pin overlay visibility with review mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pinOverlay) {
|
if (pinOverlay) {
|
||||||
@@ -663,16 +676,8 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
userId={user.id}
|
userId={user.id}
|
||||||
boardId={resolvedBoardId}
|
boardId={resolvedBoardId}
|
||||||
token={localStorage.getItem('token') || ''}
|
token={localStorage.getItem('token') || ''}
|
||||||
canvasObjects={(() => {
|
canvasObjects={canvasObjectMap}
|
||||||
const scene = canvasRef.current?.getScene();
|
onError={(msg) => showToast(msg)}
|
||||||
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) => {
|
onJumpToObject={(objectId) => {
|
||||||
const scene = canvasRef.current?.getScene();
|
const scene = canvasRef.current?.getScene();
|
||||||
const vp = canvasRef.current?.getViewport();
|
const vp = canvasRef.current?.getViewport();
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ export class AnnotationStore {
|
|||||||
votes = new Map<string, Set<string>>();
|
votes = new Map<string, Set<string>>();
|
||||||
|
|
||||||
private _listeners = new Set<Listener>();
|
private _listeners = new Set<Listener>();
|
||||||
|
private _version = 0;
|
||||||
|
|
||||||
|
/** Monotonic version counter for useSyncExternalStore snapshots */
|
||||||
|
get version(): number { return this._version; }
|
||||||
|
|
||||||
subscribe(fn: Listener): () => void {
|
subscribe(fn: Listener): () => void {
|
||||||
this._listeners.add(fn);
|
this._listeners.add(fn);
|
||||||
@@ -49,6 +53,7 @@ export class AnnotationStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _notify() {
|
private _notify() {
|
||||||
|
this._version++;
|
||||||
for (const fn of this._listeners) fn();
|
for (const fn of this._listeners) fn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user