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:
Hiren Kangad
2026-03-11 01:08:05 +05:30
parent f24414e25c
commit ed8424d9a1
15 changed files with 1228 additions and 419 deletions
@@ -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',
}}
>
&larr;
</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"
>
&times;
</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',
}}
>
&times;
</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';