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
+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;
}
}