feat(annotations): Figma-style UI overhaul
- Redesign PinOverlay: teardrop pins with author initials, colors, numbers - Split FeedbackPanel into modular components: - feedback/FeedbackPanel.tsx (orchestrator) - feedback/ThreadList.tsx (list view with filters) - feedback/ThreadListItem.tsx (individual thread row) - feedback/ThreadDetail.tsx (expanded thread with comments) - feedback/CommentItem.tsx (single comment) - feedback/CommentInput.tsx (reusable input with Enter/Shift+Enter) - feedback/feedbackStyles.ts (shared design tokens) - Add status colors: red (open), green (resolved) - Add relative timestamps (2h ago, 3d, etc.) - Wire pin clicks to expand threads in panel - Pins follow media transforms in real-time - Add delete thread with inline confirmation - Register review mode shortcut (. key) - Better empty states and filter bar (Open/Resolved/All/Mine)
This commit is contained in:
@@ -2,14 +2,60 @@ import { Container, Graphics, Text, TextStyle } from 'pixi.js';
|
|||||||
import type { Viewport } from 'pixi-viewport';
|
import type { Viewport } from 'pixi-viewport';
|
||||||
import type { AnnotationStore } from '../stores/annotationStore';
|
import type { AnnotationStore } from '../stores/annotationStore';
|
||||||
import type { SceneManager } from './SceneManager';
|
import type { SceneManager } from './SceneManager';
|
||||||
|
import { getAuthorColorHex, getAuthorInitial } from '../utils/authorColors';
|
||||||
|
|
||||||
const PIN_RADIUS = 10;
|
const PIN_RADIUS = 12;
|
||||||
const PIN_COLOR_OPEN = 0xee4444;
|
const PIN_COLOR_RESOLVED = 0x555555;
|
||||||
const PIN_COLOR_RESOLVED = 0x666666;
|
|
||||||
|
// Tail direction: 225° (down-left) in standard math coords.
|
||||||
|
// In PixiJS screen coords (+y down), 225° maps to:
|
||||||
|
// cos(225°) = -√2/2 (left)
|
||||||
|
// sin(225°) = +√2/2 (down, because +y is down in screen space)
|
||||||
|
// So the circle center is offset UP-RIGHT from the anchor tip:
|
||||||
|
// cx_offset = +r * √2/2, cy_offset = -r * √2/2
|
||||||
|
const SIN45 = Math.SQRT1_2; // √2/2 ≈ 0.7071
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a Figma-style teardrop pin whose tip lands at (0, 0).
|
||||||
|
* The circle body is offset up-right; the tail points down-left to (0,0).
|
||||||
|
*
|
||||||
|
* @param gfx Graphics object (drawn in its own local space, tip = origin)
|
||||||
|
* @param r Pin radius (already scaled to world space)
|
||||||
|
* @param color Fill color (0xRRGGBB)
|
||||||
|
* @param alpha Fill alpha
|
||||||
|
*/
|
||||||
|
function drawTeardrop(gfx: Graphics, r: number, color: number, alpha: number): void {
|
||||||
|
// Circle center relative to tip
|
||||||
|
const cx = r * SIN45;
|
||||||
|
const cy = -r * SIN45;
|
||||||
|
|
||||||
|
// Half-angle of the tail aperture from the tip (in radians).
|
||||||
|
// Controls how wide/sharp the tail looks. ~20° gives a nice taper.
|
||||||
|
const tailHalfAngle = (20 * Math.PI) / 180;
|
||||||
|
|
||||||
|
// Angle from circle center toward tip = 225° in screen coords
|
||||||
|
const tipAngle = (225 * Math.PI) / 180;
|
||||||
|
|
||||||
|
// The two "wing" angles on the circle where the tail edges start
|
||||||
|
const wingAngle1 = tipAngle - tailHalfAngle;
|
||||||
|
const wingAngle2 = tipAngle + tailHalfAngle;
|
||||||
|
|
||||||
|
// Build the teardrop as a single filled path:
|
||||||
|
// start at wing1 on circle → arc around (the "long way" CCW past top) → wing2 → line to tip → close
|
||||||
|
gfx.moveTo(cx + r * Math.cos(wingAngle1), cy + r * Math.sin(wingAngle1));
|
||||||
|
// Arc from wingAngle1 to wingAngle2 going CCW (counterclockwise = increasing angle in screen space)
|
||||||
|
// "long way" means going through the top of the circle (not through 225°).
|
||||||
|
// In PixiJS, arc() takes (cx, cy, r, startAngle, endAngle, anticlockwise).
|
||||||
|
// We want the arc that does NOT pass through 225°, so go anticlockwise from wingAngle1 to wingAngle2.
|
||||||
|
gfx.arc(cx, cy, r, wingAngle1, wingAngle2, true);
|
||||||
|
gfx.lineTo(0, 0); // tip at anchor
|
||||||
|
gfx.closePath();
|
||||||
|
gfx.fill({ color, alpha });
|
||||||
|
}
|
||||||
|
|
||||||
export class PinOverlay extends Container {
|
export class PinOverlay extends Container {
|
||||||
private _pins = new Map<string, Graphics>();
|
private _pins = new Map<string, Container>();
|
||||||
private _pool: Graphics[] = [];
|
private _pool: Container[] = [];
|
||||||
private _viewport: Viewport;
|
private _viewport: Viewport;
|
||||||
private _scene: SceneManager;
|
private _scene: SceneManager;
|
||||||
private _store: AnnotationStore;
|
private _store: AnnotationStore;
|
||||||
@@ -21,19 +67,18 @@ export class PinOverlay extends Container {
|
|||||||
this._store = store;
|
this._store = store;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _acquire(): Graphics {
|
private _acquire(): Container {
|
||||||
const gfx = this._pool.pop() || new Graphics();
|
const c = this._pool.pop() || new Container();
|
||||||
gfx.clear();
|
c.removeChildren();
|
||||||
gfx.removeChildren();
|
c.visible = true;
|
||||||
gfx.visible = true;
|
c.eventMode = 'none';
|
||||||
gfx.eventMode = 'none';
|
c.cursor = 'default';
|
||||||
gfx.cursor = 'default';
|
return c;
|
||||||
return gfx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _release(gfx: Graphics) {
|
private _release(c: Container) {
|
||||||
gfx.visible = false;
|
c.visible = false;
|
||||||
this._pool.push(gfx);
|
this._pool.push(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Call on every viewport moved/zoomed event and on store change */
|
/** Call on every viewport moved/zoomed event and on store change */
|
||||||
@@ -41,10 +86,11 @@ export class PinOverlay extends Container {
|
|||||||
const scale = 1 / this._viewport.scale.x;
|
const scale = 1 / this._viewport.scale.x;
|
||||||
|
|
||||||
// Return current pins to pool
|
// Return current pins to pool
|
||||||
for (const gfx of this._pins.values()) this._release(gfx);
|
for (const c of this._pins.values()) this._release(c);
|
||||||
this._pins.clear();
|
this._pins.clear();
|
||||||
|
|
||||||
// ── Thread pins ──
|
const r = PIN_RADIUS * scale;
|
||||||
|
|
||||||
for (const thread of this._store.threads.values()) {
|
for (const thread of this._store.threads.values()) {
|
||||||
if (thread.status === 'resolved' && !showResolved) continue;
|
if (thread.status === 'resolved' && !showResolved) continue;
|
||||||
|
|
||||||
@@ -58,42 +104,87 @@ 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 {
|
||||||
|
// Default: top-right corner of object bounds
|
||||||
wx = bounds.x + bounds.width;
|
wx = bounds.x + bounds.width;
|
||||||
wy = bounds.y;
|
wy = bounds.y;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gfx = this._acquire();
|
const isResolved = thread.status === 'resolved';
|
||||||
const r = PIN_RADIUS * scale;
|
const fillColor = isResolved ? PIN_COLOR_RESOLVED : getAuthorColorHex(thread.created_by);
|
||||||
const color = thread.status === 'open' ? PIN_COLOR_OPEN : PIN_COLOR_RESOLVED;
|
const fillAlpha = isResolved ? 0.5 : 1.0;
|
||||||
|
|
||||||
gfx.circle(0, 0, r);
|
// Container whose origin = anchor tip
|
||||||
gfx.fill({ color, alpha: 0.9 });
|
const pin = this._acquire();
|
||||||
gfx.stroke({ color: 0xffffff, width: 1.5 * scale, alpha: 0.8 });
|
pin.position.set(wx, wy);
|
||||||
gfx.position.set(wx, wy);
|
pin.eventMode = 'static';
|
||||||
gfx.eventMode = 'static';
|
pin.cursor = 'pointer';
|
||||||
gfx.cursor = 'pointer';
|
(pin as any)._threadId = thread.id;
|
||||||
(gfx as any)._threadId = thread.id;
|
|
||||||
|
|
||||||
if (thread.comment_count > 1) {
|
// ── Teardrop body ──
|
||||||
const label = new Text({
|
const gfx = new Graphics();
|
||||||
text: String(thread.comment_count),
|
drawTeardrop(gfx, r, fillColor, fillAlpha);
|
||||||
style: new TextStyle({ fontSize: 9 * scale, fill: '#ffffff', fontWeight: 'bold' }),
|
// White stroke around the circle part only (drawn as a separate circle stroke)
|
||||||
});
|
gfx.circle(r * SIN45, -r * SIN45, r);
|
||||||
label.anchor.set(0.5);
|
gfx.stroke({ color: 0xffffff, width: 1.5 * scale, alpha: isResolved ? 0.4 : 0.85 });
|
||||||
gfx.addChild(label);
|
pin.addChild(gfx);
|
||||||
}
|
|
||||||
|
|
||||||
if (!gfx.parent) this.addChild(gfx);
|
// ── Author initial ──
|
||||||
this._pins.set(thread.id, gfx);
|
const authorName = thread.comments[0]?.author_name ?? '';
|
||||||
|
const initial = getAuthorInitial(authorName);
|
||||||
|
const initialText = new Text({
|
||||||
|
text: initial,
|
||||||
|
style: new TextStyle({
|
||||||
|
fontSize: r * 1.1,
|
||||||
|
fill: '#ffffff',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
initialText.anchor.set(0.5);
|
||||||
|
// Center on the circle body
|
||||||
|
initialText.position.set(r * SIN45, -r * SIN45);
|
||||||
|
pin.addChild(initialText);
|
||||||
|
|
||||||
|
// ── Pin number label (#N) ──
|
||||||
|
const pinNum = this._store.getPinNumber(thread.id);
|
||||||
|
const numLabel = new Text({
|
||||||
|
text: `#${pinNum}`,
|
||||||
|
style: new TextStyle({
|
||||||
|
fontSize: r * 0.75,
|
||||||
|
fill: '#ffffff',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
|
dropShadow: {
|
||||||
|
color: '#000000',
|
||||||
|
blur: 2 * scale,
|
||||||
|
distance: 0,
|
||||||
|
alpha: 0.6,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
numLabel.anchor.set(0, 1);
|
||||||
|
// Place just to the right of the circle top
|
||||||
|
numLabel.position.set(r * SIN45 + r * 1.1, -r * SIN45 - r * 0.9);
|
||||||
|
pin.addChild(numLabel);
|
||||||
|
|
||||||
|
if (!pin.parent) this.addChild(pin);
|
||||||
|
this._pins.set(thread.id, pin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getThreadIdAtPoint(worldX: number, worldY: number): string | null {
|
getThreadIdAtPoint(worldX: number, worldY: number): string | null {
|
||||||
for (const [threadId, gfx] of this._pins) {
|
const scale = 1 / this._viewport.scale.x;
|
||||||
const dx = gfx.position.x - worldX;
|
const r = PIN_RADIUS * scale;
|
||||||
const dy = gfx.position.y - worldY;
|
// Hit-test against the circle body of each pin (not the tail tip)
|
||||||
const hitRadius = PIN_RADIUS / this._viewport.scale.x;
|
const cx_off = r * SIN45;
|
||||||
if (dx * dx + dy * dy <= hitRadius * hitRadius) {
|
const cy_off = -r * SIN45;
|
||||||
|
|
||||||
|
for (const [threadId, pin] of this._pins) {
|
||||||
|
const circleCx = pin.position.x + cx_off;
|
||||||
|
const circleCy = pin.position.y + cy_off;
|
||||||
|
const dx = circleCx - worldX;
|
||||||
|
const dy = circleCy - worldY;
|
||||||
|
if (dx * dx + dy * dy <= r * r) {
|
||||||
return threadId;
|
return threadId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,8 +192,8 @@ export class PinOverlay extends Container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override destroy(options?: any) {
|
override destroy(options?: any) {
|
||||||
for (const gfx of this._pins.values()) gfx.destroy();
|
for (const c of this._pins.values()) c.destroy({ children: true });
|
||||||
for (const gfx of this._pool) gfx.destroy();
|
for (const c of this._pool) c.destroy({ children: true });
|
||||||
this._pins.clear();
|
this._pins.clear();
|
||||||
this._pool = [];
|
this._pool = [];
|
||||||
super.destroy(options);
|
super.destroy(options);
|
||||||
|
|||||||
@@ -688,4 +688,14 @@ export const shortcuts: ShortcutDef[] = [
|
|||||||
category: 'view', description: 'Show shortcuts help',
|
category: 'view', description: 'Show shortcuts help',
|
||||||
handler: (ctx) => ctx.toggleShowHelp(),
|
handler: (ctx) => ctx.toggleShowHelp(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'toggle-review',
|
||||||
|
keys: { key: '.' },
|
||||||
|
category: 'view',
|
||||||
|
description: 'Toggle review mode',
|
||||||
|
needsSelection: false,
|
||||||
|
handler: (ctx: ShortcutContext) => {
|
||||||
|
ctx.toggleReviewMode?.();
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export interface ShortcutContext {
|
|||||||
fitSelection: () => void;
|
fitSelection: () => void;
|
||||||
writeCanvasToClipboard: (items?: SceneItem[]) => Promise<void>;
|
writeCanvasToClipboard: (items?: SceneItem[]) => Promise<void>;
|
||||||
pasteFromSystemClipboard: () => Promise<string>;
|
pasteFromSystemClipboard: () => Promise<string>;
|
||||||
|
toggleReviewMode?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,363 +0,0 @@
|
|||||||
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;
|
|
||||||
onError?: (msg: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FeedbackPanel({
|
|
||||||
annotationStore,
|
|
||||||
selectedObjectId,
|
|
||||||
userId,
|
|
||||||
boardId,
|
|
||||||
token,
|
|
||||||
canvasObjects,
|
|
||||||
onJumpToObject,
|
|
||||||
onError,
|
|
||||||
}: 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.version,
|
|
||||||
);
|
|
||||||
|
|
||||||
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 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;
|
|
||||||
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) => {
|
|
||||||
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) => {
|
|
||||||
try {
|
|
||||||
await apiFetch(`/api/boards/${boardId}/threads/${threadId}/comments/${commentId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
} catch {}
|
|
||||||
}, [boardId, token, apiFetch]);
|
|
||||||
|
|
||||||
const createThread = useCallback(async () => {
|
|
||||||
if (!newCommentText.trim() || !selectedObjectId) return;
|
|
||||||
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 ──
|
|
||||||
|
|
||||||
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>
|
|
||||||
{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>
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -315,7 +315,7 @@ export default function Toolbar({
|
|||||||
|
|
||||||
{/* Review / Feedback */}
|
{/* Review / Feedback */}
|
||||||
{onToggleReview && (
|
{onToggleReview && (
|
||||||
<ActionBtn onClick={onToggleReview} title="Review Mode (R)" active={reviewMode}>
|
<ActionBtn onClick={onToggleReview} title="Review Mode (.)" active={reviewMode}>
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
|
<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" />
|
<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>
|
</svg>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import React, { useRef, useEffect } from 'react';
|
||||||
|
import { ACCENT, INPUT_BG, INPUT_BORDER, INPUT_BORDER_FOCUS, TEXT_PRIMARY } from './feedbackStyles';
|
||||||
|
|
||||||
|
interface CommentInputProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
placeholder?: string;
|
||||||
|
submitLabel?: string;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommentInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
placeholder = 'Add a comment...',
|
||||||
|
submitLabel = 'Post',
|
||||||
|
autoFocus = false,
|
||||||
|
}: CommentInputProps) {
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFocus && ref.current) ref.current.focus();
|
||||||
|
}, [autoFocus]);
|
||||||
|
|
||||||
|
const hasText = value.trim().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={`${placeholder} (Enter to send)`}
|
||||||
|
rows={2}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
background: INPUT_BG,
|
||||||
|
border: `1px solid ${INPUT_BORDER}`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: TEXT_PRIMARY,
|
||||||
|
padding: '8px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
resize: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'border-color 0.1s ease',
|
||||||
|
}}
|
||||||
|
onFocus={(e) => (e.currentTarget.style.borderColor = INPUT_BORDER_FOCUS)}
|
||||||
|
onBlur={(e) => (e.currentTarget.style.borderColor = INPUT_BORDER)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (hasText) onSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '6px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (hasText) onSubmit(); }}
|
||||||
|
disabled={!hasText}
|
||||||
|
style={{
|
||||||
|
background: hasText ? ACCENT : '#1a1a1a',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
color: hasText ? '#fff' : '#444',
|
||||||
|
padding: '5px 14px',
|
||||||
|
cursor: hasText ? 'pointer' : 'default',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: 'all 0.1s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Comment } from '../../stores/annotationStore';
|
||||||
|
import { getAuthorColor, getAuthorInitial } from '../../utils/authorColors';
|
||||||
|
import { relativeTime } from '../../utils/relativeTime';
|
||||||
|
import { TEXT_PRIMARY, TEXT_MUTED, BORDER } from './feedbackStyles';
|
||||||
|
|
||||||
|
interface CommentItemProps {
|
||||||
|
comment: Comment;
|
||||||
|
isOwn: boolean;
|
||||||
|
onDelete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommentItem({ comment, isOwn, onDelete }: CommentItemProps) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '10px 0', borderBottom: `1px solid ${BORDER}` }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: '22px',
|
||||||
|
height: '22px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: getAuthorColor(comment.user_id),
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#fff',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getAuthorInitial(comment.author_name)}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: TEXT_PRIMARY, fontSize: '12px', fontWeight: 600 }}>
|
||||||
|
{comment.author_name}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: TEXT_MUTED, fontSize: '10px' }}>
|
||||||
|
{relativeTime(comment.created_at)}
|
||||||
|
</span>
|
||||||
|
{comment.edited_at && (
|
||||||
|
<span style={{ color: TEXT_MUTED, fontSize: '10px' }}>(edited)</span>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
{isOwn && onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#633',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '10px',
|
||||||
|
opacity: 0.6,
|
||||||
|
transition: 'opacity 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.opacity = '1')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.opacity = '0.6')}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: '#ccc',
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
marginLeft: '30px',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{comment.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import React, { useState, useCallback, useEffect, useSyncExternalStore } from 'react';
|
||||||
|
import { AnnotationStore } from '../../stores/annotationStore';
|
||||||
|
import ThreadList, { FilterType } from './ThreadList';
|
||||||
|
import ThreadDetail from './ThreadDetail';
|
||||||
|
import { PANEL_BG, BORDER, TEXT_MUTED, STATUS_OPEN } from './feedbackStyles';
|
||||||
|
|
||||||
|
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;
|
||||||
|
onError?: (msg: string) => void;
|
||||||
|
/** Set externally to expand a specific thread (e.g. from pin click) */
|
||||||
|
expandThreadId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackPanel({
|
||||||
|
annotationStore,
|
||||||
|
selectedObjectId,
|
||||||
|
userId,
|
||||||
|
boardId,
|
||||||
|
token,
|
||||||
|
canvasObjects,
|
||||||
|
onJumpToObject,
|
||||||
|
onError,
|
||||||
|
expandThreadId,
|
||||||
|
}: FeedbackPanelProps) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [expandedThreadId, setExpandedThreadId] = useState<string | null>(null);
|
||||||
|
const [filter, setFilter] = useState<FilterType>('open');
|
||||||
|
const [newCommentText, setNewCommentText] = useState('');
|
||||||
|
|
||||||
|
// Subscribe to store changes
|
||||||
|
const _version = useSyncExternalStore(
|
||||||
|
(cb) => annotationStore.subscribe(cb),
|
||||||
|
() => annotationStore.version,
|
||||||
|
);
|
||||||
|
|
||||||
|
// External expand trigger (from pin click)
|
||||||
|
useEffect(() => {
|
||||||
|
if (expandThreadId) {
|
||||||
|
setExpandedThreadId(expandThreadId);
|
||||||
|
setCollapsed(false);
|
||||||
|
}
|
||||||
|
}, [expandThreadId]);
|
||||||
|
|
||||||
|
const allThreads = Array.from(annotationStore.threads.values());
|
||||||
|
const openCount = allThreads.filter((t) => t.status === 'open').length;
|
||||||
|
|
||||||
|
// Apply filter
|
||||||
|
let threads = allThreads;
|
||||||
|
if (filter === 'open') threads = threads.filter((t) => t.status === 'open');
|
||||||
|
if (filter === 'resolved') threads = threads.filter((t) => t.status === 'resolved');
|
||||||
|
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
|
||||||
|
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 apiFetch = useCallback(
|
||||||
|
async (url: string, init?: RequestInit) => {
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
const msg = body.error || `Request failed (${res.status})`;
|
||||||
|
onError?.(msg);
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
[onError],
|
||||||
|
);
|
||||||
|
|
||||||
|
const headers = useCallback(
|
||||||
|
(json = true) => {
|
||||||
|
const h: Record<string, string> = { Authorization: `Bearer ${token}` };
|
||||||
|
if (json) h['Content-Type'] = 'application/json';
|
||||||
|
return h;
|
||||||
|
},
|
||||||
|
[token],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReply = useCallback(
|
||||||
|
async (threadId: string, content: string) => {
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/boards/${boardId}/threads/${threadId}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(),
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Error surfaced via onError
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[boardId, headers, apiFetch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResolve = useCallback(
|
||||||
|
async (threadId: string, status: string) => {
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/boards/${boardId}/threads/${threadId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: headers(),
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Error surfaced via onError
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[boardId, headers, apiFetch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteComment = useCallback(
|
||||||
|
async (threadId: string, commentId: string) => {
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/boards/${boardId}/threads/${threadId}/comments/${commentId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: headers(false),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Error surfaced via onError
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[boardId, headers, apiFetch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteThread = useCallback(
|
||||||
|
async (threadId: string) => {
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/boards/${boardId}/threads/${threadId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: headers(false),
|
||||||
|
});
|
||||||
|
setExpandedThreadId(null);
|
||||||
|
} catch {
|
||||||
|
// Error surfaced via onError
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[boardId, headers, apiFetch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateThread = useCallback(async () => {
|
||||||
|
if (!newCommentText.trim() || !selectedObjectId) return;
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/boards/${boardId}/threads`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
object_id: selectedObjectId,
|
||||||
|
anchor_type: 'object',
|
||||||
|
content: newCommentText.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setNewCommentText('');
|
||||||
|
} catch {
|
||||||
|
// Error surfaced via onError
|
||||||
|
}
|
||||||
|
}, [boardId, newCommentText, selectedObjectId, headers, apiFetch]);
|
||||||
|
|
||||||
|
// ── Collapsed state ──
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: '32px',
|
||||||
|
background: PANEL_BG,
|
||||||
|
borderLeft: `1px solid ${BORDER}`,
|
||||||
|
zIndex: 100,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingTop: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(false)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: TEXT_MUTED,
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
title="Open feedback panel"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
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>
|
||||||
|
{openCount > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: -2,
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: STATUS_OPEN,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Expanded thread detail ──
|
||||||
|
|
||||||
|
const expandedThread = expandedThreadId
|
||||||
|
? annotationStore.threads.get(expandedThreadId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (expandedThread) {
|
||||||
|
return (
|
||||||
|
<ThreadDetail
|
||||||
|
thread={expandedThread}
|
||||||
|
store={annotationStore}
|
||||||
|
userId={userId}
|
||||||
|
onBack={() => setExpandedThreadId(null)}
|
||||||
|
onReply={handleReply}
|
||||||
|
onResolve={handleResolve}
|
||||||
|
onDeleteComment={handleDeleteComment}
|
||||||
|
onDeleteThread={handleDeleteThread}
|
||||||
|
onJumpToObject={onJumpToObject}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Thread list ──
|
||||||
|
|
||||||
|
const selectedLabel =
|
||||||
|
canvasObjects.get(selectedObjectId || '')?.name ||
|
||||||
|
canvasObjects.get(selectedObjectId || '')?.type ||
|
||||||
|
selectedObjectId?.slice(0, 8) ||
|
||||||
|
'';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThreadList
|
||||||
|
threads={threads}
|
||||||
|
orphanedThreads={orphanedThreads}
|
||||||
|
store={annotationStore}
|
||||||
|
openCount={openCount}
|
||||||
|
filter={filter}
|
||||||
|
onFilterChange={setFilter}
|
||||||
|
onSelectThread={setExpandedThreadId}
|
||||||
|
onCollapse={() => setCollapsed(true)}
|
||||||
|
selectedObjectId={selectedObjectId}
|
||||||
|
selectedObjectLabel={selectedLabel}
|
||||||
|
newCommentText={newCommentText}
|
||||||
|
onNewCommentChange={setNewCommentText}
|
||||||
|
onCreateThread={handleCreateThread}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Thread, AnnotationStore } from '../../stores/annotationStore';
|
||||||
|
import CommentItem from './CommentItem';
|
||||||
|
import CommentInput from './CommentInput';
|
||||||
|
import {
|
||||||
|
PANEL_BG,
|
||||||
|
PANEL_WIDTH,
|
||||||
|
BORDER,
|
||||||
|
TEXT_PRIMARY,
|
||||||
|
TEXT_MUTED,
|
||||||
|
ACCENT,
|
||||||
|
STATUS_OPEN,
|
||||||
|
STATUS_RESOLVED,
|
||||||
|
} from './feedbackStyles';
|
||||||
|
|
||||||
|
interface ThreadDetailProps {
|
||||||
|
thread: Thread;
|
||||||
|
store: AnnotationStore;
|
||||||
|
userId: string;
|
||||||
|
onBack: () => void;
|
||||||
|
onReply: (threadId: string, content: string) => Promise<void>;
|
||||||
|
onResolve: (threadId: string, status: string) => Promise<void>;
|
||||||
|
onDeleteComment: (threadId: string, commentId: string) => Promise<void>;
|
||||||
|
onDeleteThread: (threadId: string) => Promise<void>;
|
||||||
|
onJumpToObject?: (objectId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThreadDetail({
|
||||||
|
thread,
|
||||||
|
store,
|
||||||
|
userId,
|
||||||
|
onBack,
|
||||||
|
onReply,
|
||||||
|
onResolve,
|
||||||
|
onDeleteComment,
|
||||||
|
onDeleteThread,
|
||||||
|
onJumpToObject,
|
||||||
|
}: ThreadDetailProps) {
|
||||||
|
const [replyText, setReplyText] = useState('');
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
|
const pinNumber = store.getPinNumber(thread.id);
|
||||||
|
const isOpen = thread.status === 'open';
|
||||||
|
const isOwner = thread.created_by === userId;
|
||||||
|
|
||||||
|
const handleReply = async () => {
|
||||||
|
if (!replyText.trim()) return;
|
||||||
|
await onReply(thread.id, replyText.trim());
|
||||||
|
setReplyText('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteThread = async () => {
|
||||||
|
if (!confirmDelete) {
|
||||||
|
setConfirmDelete(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onDeleteThread(thread.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: `${PANEL_WIDTH}px`,
|
||||||
|
background: PANEL_BG,
|
||||||
|
borderLeft: `1px solid ${BORDER}`,
|
||||||
|
zIndex: 100,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: TEXT_MUTED,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
padding: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<span style={{ color: TEXT_MUTED, fontSize: '11px', fontFamily: 'monospace' }}>
|
||||||
|
#{pinNumber}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
background: isOpen ? '#2a1515' : '#152a15',
|
||||||
|
color: isOpen ? STATUS_OPEN : STATUS_RESOLVED,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen ? 'Open' : 'Resolved'}
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
{onJumpToObject && (
|
||||||
|
<button
|
||||||
|
onClick={() => onJumpToObject(thread.object_id)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: ACCENT,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '11px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Jump
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isOpen ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onResolve(thread.id, 'resolved')}
|
||||||
|
style={{
|
||||||
|
background: '#152a15',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
color: STATUS_RESOLVED,
|
||||||
|
padding: '4px 10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '11px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Resolve
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => onResolve(thread.id, 'open')}
|
||||||
|
style={{
|
||||||
|
background: '#1a1a2a',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
color: '#88f',
|
||||||
|
padding: '4px 10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '11px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reopen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Delete thread */}
|
||||||
|
{isOwner &&
|
||||||
|
(confirmDelete ? (
|
||||||
|
<span style={{ fontSize: '11px', color: TEXT_MUTED, whiteSpace: 'nowrap' }}>
|
||||||
|
Delete?{' '}
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteThread}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: STATUS_OPEN,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>{' '}
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(false)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: TEXT_MUTED,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '11px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteThread}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#633',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
padding: '2px',
|
||||||
|
}}
|
||||||
|
title="Delete thread"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '0 14px' }}>
|
||||||
|
{thread.comments.map((c) => (
|
||||||
|
<CommentItem
|
||||||
|
key={c.id}
|
||||||
|
comment={c}
|
||||||
|
isOwn={c.user_id === userId}
|
||||||
|
onDelete={() => onDeleteComment(thread.id, c.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reply input */}
|
||||||
|
<div style={{ padding: '10px 14px', borderTop: `1px solid ${BORDER}` }}>
|
||||||
|
<CommentInput
|
||||||
|
value={replyText}
|
||||||
|
onChange={setReplyText}
|
||||||
|
onSubmit={handleReply}
|
||||||
|
placeholder="Reply..."
|
||||||
|
submitLabel="Reply"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Thread, AnnotationStore } from '../../stores/annotationStore';
|
||||||
|
import ThreadListItem from './ThreadListItem';
|
||||||
|
import CommentInput from './CommentInput';
|
||||||
|
import {
|
||||||
|
PANEL_BG,
|
||||||
|
PANEL_WIDTH,
|
||||||
|
BORDER,
|
||||||
|
TEXT_PRIMARY,
|
||||||
|
TEXT_MUTED,
|
||||||
|
STATUS_OPEN,
|
||||||
|
FILTER_ACTIVE_BG,
|
||||||
|
} from './feedbackStyles';
|
||||||
|
|
||||||
|
export type FilterType = 'open' | 'resolved' | 'all' | 'mine';
|
||||||
|
|
||||||
|
interface ThreadListProps {
|
||||||
|
threads: Thread[];
|
||||||
|
orphanedThreads: Thread[];
|
||||||
|
store: AnnotationStore;
|
||||||
|
openCount: number;
|
||||||
|
filter: FilterType;
|
||||||
|
onFilterChange: (f: FilterType) => void;
|
||||||
|
onSelectThread: (id: string) => void;
|
||||||
|
onCollapse: () => void;
|
||||||
|
// New comment
|
||||||
|
selectedObjectId: string | null;
|
||||||
|
selectedObjectLabel: string;
|
||||||
|
newCommentText: string;
|
||||||
|
onNewCommentChange: (text: string) => void;
|
||||||
|
onCreateThread: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThreadList({
|
||||||
|
threads,
|
||||||
|
orphanedThreads,
|
||||||
|
store,
|
||||||
|
openCount,
|
||||||
|
filter,
|
||||||
|
onFilterChange,
|
||||||
|
onSelectThread,
|
||||||
|
onCollapse,
|
||||||
|
selectedObjectId,
|
||||||
|
selectedObjectLabel,
|
||||||
|
newCommentText,
|
||||||
|
onNewCommentChange,
|
||||||
|
onCreateThread,
|
||||||
|
}: ThreadListProps) {
|
||||||
|
const [showOrphans, setShowOrphans] = React.useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: `${PANEL_WIDTH}px`,
|
||||||
|
background: PANEL_BG,
|
||||||
|
borderLeft: `1px solid ${BORDER}`,
|
||||||
|
zIndex: 100,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 14px',
|
||||||
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: TEXT_PRIMARY, fontSize: '14px', fontWeight: 600, flex: 1 }}>
|
||||||
|
Feedback
|
||||||
|
</span>
|
||||||
|
{openCount > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: STATUS_OPEN,
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: '1px 7px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
minWidth: '18px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{openCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onCollapse}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: TEXT_MUTED,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '16px',
|
||||||
|
lineHeight: 1,
|
||||||
|
padding: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
|
display: 'flex',
|
||||||
|
gap: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(['open', 'resolved', 'all', 'mine'] as const).map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => onFilterChange(f)}
|
||||||
|
style={{
|
||||||
|
background: filter === f ? FILTER_ACTIVE_BG : 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
color: filter === f ? '#fff' : TEXT_MUTED,
|
||||||
|
padding: '4px 10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: filter === f ? 600 : 400,
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
transition: 'all 0.1s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New comment input (when object selected) */}
|
||||||
|
{selectedObjectId && (
|
||||||
|
<div style={{ padding: '10px 14px', borderBottom: `1px solid ${BORDER}` }}>
|
||||||
|
<div style={{ color: TEXT_MUTED, fontSize: '10px', marginBottom: '6px' }}>
|
||||||
|
Comment on: <span style={{ color: TEXT_PRIMARY }}>{selectedObjectLabel}</span>
|
||||||
|
</div>
|
||||||
|
<CommentInput
|
||||||
|
value={newCommentText}
|
||||||
|
onChange={onNewCommentChange}
|
||||||
|
onSubmit={onCreateThread}
|
||||||
|
placeholder="Add a comment..."
|
||||||
|
submitLabel="Comment"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Thread list */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
{threads.length === 0 && (
|
||||||
|
<div style={{ padding: '32px 20px', textAlign: 'center' }}>
|
||||||
|
<svg
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
fill="none"
|
||||||
|
stroke="#333"
|
||||||
|
strokeWidth="1"
|
||||||
|
style={{ marginBottom: '12px' }}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<div style={{ color: TEXT_MUTED, fontSize: '12px', lineHeight: 1.5 }}>
|
||||||
|
{selectedObjectId
|
||||||
|
? 'No comments on this item.'
|
||||||
|
: 'No comments yet.\nSelect an image and add a comment.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{threads.map((t) => (
|
||||||
|
<ThreadListItem
|
||||||
|
key={t.id}
|
||||||
|
thread={t}
|
||||||
|
pinNumber={store.getPinNumber(t.id)}
|
||||||
|
onClick={() => onSelectThread(t.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Orphaned threads (deleted objects) */}
|
||||||
|
{orphanedThreads.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
onClick={() => setShowOrphans(!showOrphans)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: TEXT_MUTED,
|
||||||
|
fontSize: '11px',
|
||||||
|
borderTop: `1px solid ${BORDER}`,
|
||||||
|
transition: 'background 0.1s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = '#151515')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
>
|
||||||
|
{showOrphans ? '\u25BE' : '\u25B8'} Deleted items ({orphanedThreads.length})
|
||||||
|
</div>
|
||||||
|
{showOrphans &&
|
||||||
|
orphanedThreads.map((t) => (
|
||||||
|
<ThreadListItem
|
||||||
|
key={t.id}
|
||||||
|
thread={t}
|
||||||
|
pinNumber={store.getPinNumber(t.id)}
|
||||||
|
onClick={() => onSelectThread(t.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Thread } from '../../stores/annotationStore';
|
||||||
|
import { getAuthorColor, getAuthorInitial } from '../../utils/authorColors';
|
||||||
|
import { relativeTime } from '../../utils/relativeTime';
|
||||||
|
import {
|
||||||
|
TEXT_PRIMARY,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
TEXT_MUTED,
|
||||||
|
STATUS_OPEN,
|
||||||
|
STATUS_RESOLVED,
|
||||||
|
BORDER,
|
||||||
|
HOVER_BG,
|
||||||
|
} from './feedbackStyles';
|
||||||
|
|
||||||
|
interface ThreadListItemProps {
|
||||||
|
thread: Thread;
|
||||||
|
pinNumber: number;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThreadListItem({ thread, pinNumber, onClick }: ThreadListItemProps) {
|
||||||
|
const firstComment = thread.comments[0];
|
||||||
|
const isResolved = thread.status === 'resolved';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.1s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = HOVER_BG)}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
>
|
||||||
|
{/* Row 1: author circle + name + timestamp */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: isResolved ? '#333' : getAuthorColor(thread.created_by),
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#fff',
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: isResolved ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getAuthorInitial(firstComment?.author_name || '?')}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: isResolved ? TEXT_MUTED : TEXT_PRIMARY,
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{firstComment?.author_name || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: TEXT_MUTED, fontSize: '10px', flexShrink: 0 }}>
|
||||||
|
{relativeTime(thread.last_commented_at || thread.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: comment preview */}
|
||||||
|
{firstComment && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: isResolved ? TEXT_MUTED : TEXT_SECONDARY,
|
||||||
|
fontSize: '12px',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
marginLeft: '32px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{firstComment.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Row 3: meta — pin #, replies, status dot */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginTop: '6px',
|
||||||
|
marginLeft: '32px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: TEXT_MUTED, fontSize: '10px', fontFamily: 'monospace' }}>
|
||||||
|
#{pinNumber}
|
||||||
|
</span>
|
||||||
|
{thread.comment_count > 1 && (
|
||||||
|
<span style={{ color: TEXT_MUTED, fontSize: '10px' }}>
|
||||||
|
{thread.comment_count - 1} {thread.comment_count === 2 ? 'reply' : 'replies'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: '6px',
|
||||||
|
height: '6px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: isResolved ? STATUS_RESOLVED : STATUS_OPEN,
|
||||||
|
marginLeft: 'auto',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Design tokens for the feedback/annotation system
|
||||||
|
export const PANEL_WIDTH = 300;
|
||||||
|
export const PANEL_BG = '#0d0d0d';
|
||||||
|
export const BORDER = '#1e1e1e';
|
||||||
|
|
||||||
|
export const TEXT_PRIMARY = '#e0e0e0';
|
||||||
|
export const TEXT_SECONDARY = '#999';
|
||||||
|
export const TEXT_MUTED = '#666';
|
||||||
|
|
||||||
|
export const ACCENT = '#4a9eff';
|
||||||
|
export const STATUS_OPEN = '#ef4444';
|
||||||
|
export const STATUS_RESOLVED = '#22c55e';
|
||||||
|
|
||||||
|
export const INPUT_BG = '#141414';
|
||||||
|
export const INPUT_BORDER = '#2a2a2a';
|
||||||
|
export const INPUT_BORDER_FOCUS = '#3a3a3a';
|
||||||
|
export const HOVER_BG = '#151515';
|
||||||
|
export const FILTER_ACTIVE_BG = '#2a2a2a';
|
||||||
@@ -30,6 +30,7 @@ interface ShortcutHandlerDeps {
|
|||||||
setShowGrid: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowGrid: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setFocusMode: React.Dispatch<React.SetStateAction<boolean>>;
|
setFocusMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setReviewMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,7 +42,7 @@ export function useShortcutHandler(deps: ShortcutHandlerDeps) {
|
|||||||
onCanvasChange, showToast, refreshLayers,
|
onCanvasChange, showToast, refreshLayers,
|
||||||
handleGroup, handleUngroup,
|
handleGroup, handleUngroup,
|
||||||
setActiveTool, setCanUndo, setCanRedo, setZoom,
|
setActiveTool, setCanUndo, setCanRedo, setZoom,
|
||||||
setShowGrid, setShowHelp, setFocusMode,
|
setShowGrid, setShowHelp, setFocusMode, setReviewMode,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,6 +90,7 @@ export function useShortcutHandler(deps: ShortcutHandlerDeps) {
|
|||||||
toggleGrid: () => setShowGrid((v) => !v),
|
toggleGrid: () => setShowGrid((v) => !v),
|
||||||
toggleShowHelp: () => setShowHelp((v) => !v),
|
toggleShowHelp: () => setShowHelp((v) => !v),
|
||||||
toggleFocusMode: () => setFocusMode((v) => !v),
|
toggleFocusMode: () => setFocusMode((v) => !v),
|
||||||
|
toggleReviewMode: () => setReviewMode((v) => !v),
|
||||||
pasteFromSystemClipboard: async (): Promise<string> => {
|
pasteFromSystemClipboard: async (): Promise<string> => {
|
||||||
if (!resolvedBoardId) return 'No board';
|
if (!resolvedBoardId) return 'No board';
|
||||||
const msg = await pasteFromSystemClipboard(scene, viewport, resolvedBoardId, onCanvasChange);
|
const msg = await pasteFromSystemClipboard(scene, viewport, resolvedBoardId, onCanvasChange);
|
||||||
@@ -148,6 +150,6 @@ export function useShortcutHandler(deps: ShortcutHandlerDeps) {
|
|||||||
onCanvasChange, showToast, refreshLayers,
|
onCanvasChange, showToast, refreshLayers,
|
||||||
handleGroup, handleUngroup,
|
handleGroup, handleUngroup,
|
||||||
setActiveTool, setCanUndo, setCanRedo, setZoom,
|
setActiveTool, setCanUndo, setCanRedo, setZoom,
|
||||||
setShowGrid, setShowHelp, setFocusMode,
|
setShowGrid, setShowHelp, setFocusMode, setReviewMode,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +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 FeedbackPanel from '../components/feedback/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';
|
||||||
@@ -92,6 +92,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
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 [reviewMode, setReviewMode] = useState(false);
|
||||||
|
const [focusedThreadId, setFocusedThreadId] = useState<string | null>(null);
|
||||||
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[]>([]);
|
||||||
@@ -175,12 +176,27 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [objectCount]);
|
}, [objectCount]);
|
||||||
|
|
||||||
// Toggle pin overlay visibility with review mode
|
// Toggle pin overlay visibility with review mode + wire pin clicks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pinOverlay) {
|
if (!pinOverlay) return;
|
||||||
pinOverlay.visible = reviewMode;
|
pinOverlay.visible = reviewMode;
|
||||||
if (reviewMode) pinOverlay.refresh();
|
if (reviewMode) pinOverlay.refresh();
|
||||||
}
|
|
||||||
|
// Pin click → expand thread in panel
|
||||||
|
const onClick = (e: any) => {
|
||||||
|
const vp = canvasRef.current?.getViewport();
|
||||||
|
if (!vp) return;
|
||||||
|
const worldPos = vp.toWorld(e.global);
|
||||||
|
const threadId = pinOverlay.getThreadIdAtPoint(worldPos.x, worldPos.y);
|
||||||
|
if (threadId) {
|
||||||
|
setFocusedThreadId(threadId);
|
||||||
|
// Reset so it can be triggered again for the same pin
|
||||||
|
requestAnimationFrame(() => setFocusedThreadId(null));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pinOverlay.eventMode = 'static';
|
||||||
|
pinOverlay.on('pointerdown', onClick);
|
||||||
|
return () => { pinOverlay.off('pointerdown', onClick); };
|
||||||
}, [reviewMode, pinOverlay]);
|
}, [reviewMode, pinOverlay]);
|
||||||
|
|
||||||
// Tool activation
|
// Tool activation
|
||||||
@@ -264,7 +280,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
onCanvasChange, showToast, refreshLayers,
|
onCanvasChange, showToast, refreshLayers,
|
||||||
handleGroup, handleUngroup,
|
handleGroup, handleUngroup,
|
||||||
setActiveTool, setCanUndo, setCanRedo, setZoom,
|
setActiveTool, setCanUndo, setCanRedo, setZoom,
|
||||||
setShowGrid, setShowHelp, setFocusMode,
|
setShowGrid, setShowHelp, setFocusMode, setReviewMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Context menu
|
// Context menu
|
||||||
@@ -369,7 +385,10 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
contentBounds: { x: cMinX, y: cMinY, w: cMaxX - cMinX, h: cMaxY - cMinY },
|
contentBounds: { x: cMinX, y: cMinY, w: cMaxX - cMinX, h: cMaxY - cMinY },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
|
||||||
|
// Refresh annotation pins so they follow transforms
|
||||||
|
if (pinOverlay?.visible) pinOverlay.refresh();
|
||||||
|
}, [pinOverlay]);
|
||||||
|
|
||||||
// Listen to viewport moved event for overlay updates (throttled)
|
// Listen to viewport moved event for overlay updates (throttled)
|
||||||
// Depends on objectCount so it re-runs after canvas init (viewport becomes available)
|
// Depends on objectCount so it re-runs after canvas init (viewport becomes available)
|
||||||
@@ -678,6 +697,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
token={localStorage.getItem('refboard_token') || ''}
|
token={localStorage.getItem('refboard_token') || ''}
|
||||||
canvasObjects={canvasObjectMap}
|
canvasObjects={canvasObjectMap}
|
||||||
onError={(msg) => showToast(msg)}
|
onError={(msg) => showToast(msg)}
|
||||||
|
expandThreadId={focusedThreadId}
|
||||||
onJumpToObject={(objectId) => {
|
onJumpToObject={(objectId) => {
|
||||||
const scene = canvasRef.current?.getScene();
|
const scene = canvasRef.current?.getScene();
|
||||||
const vp = canvasRef.current?.getViewport();
|
const vp = canvasRef.current?.getViewport();
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function relativeTime(iso: string): string {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'now';
|
||||||
|
if (mins < 60) return `${mins}m`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
if (hrs < 24) return `${hrs}h`;
|
||||||
|
const days = Math.floor(hrs / 24);
|
||||||
|
if (days < 7) return `${days}d`;
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user