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:
Hiren Kangad
2026-03-10 21:16:08 +05:30
parent 303124f518
commit f7a39a1726
9 changed files with 176 additions and 133 deletions
+62 -36
View File
@@ -9,6 +9,7 @@ interface FeedbackPanelProps {
token: string;
canvasObjects: Map<string, { id: string; name?: string; type: string }>;
onJumpToObject?: (objectId: string) => void;
onError?: (msg: string) => void;
}
export default function FeedbackPanel({
@@ -19,6 +20,7 @@ export default function FeedbackPanel({
token,
canvasObjects,
onJumpToObject,
onError,
}: FeedbackPanelProps) {
const [collapsed, setCollapsed] = useState(false);
const [expandedThreadId, setExpandedThreadId] = useState<string | null>(null);
@@ -30,7 +32,7 @@ export default function FeedbackPanel({
// Subscribe to store changes
const _version = useSyncExternalStore(
(cb) => annotationStore.subscribe(cb),
() => annotationStore.threads.size + annotationStore.votes.size,
() => annotationStore.version,
);
const allThreads = Array.from(annotationStore.threads.values());
@@ -54,52 +56,76 @@ export default function FeedbackPanel({
// ── 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) => {
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]);
try {
await apiFetch(`/api/boards/${boardId}/threads/${threadId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ content: replyText.trim() }),
});
setReplyText('');
} catch {}
}, [boardId, token, replyText, apiFetch]);
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]);
try {
await apiFetch(`/api/boards/${boardId}/threads/${threadId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ status }),
});
} catch {}
}, [boardId, token, apiFetch]);
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]);
try {
await apiFetch(`/api/boards/${boardId}/threads/${threadId}/comments/${commentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
} catch {}
}, [boardId, token, apiFetch]);
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]);
try {
await apiFetch(`/api/boards/${boardId}/votes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ object_id: objectId }),
});
} catch {}
}, [boardId, token, apiFetch]);
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]);
try {
await apiFetch(`/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('');
} catch {}
}, [boardId, token, newCommentText, selectedObjectId, apiFetch]);
// ── Collapsed state ──