feat: annotation UX overhaul, animated packing, toolbar redesign
- PinOverlay: zero-allocation rendering with in-place Graphics/Text updates, fix WebGL texture crash (addressModeU null) from orphaned Text objects - Pins always visible on media, track during drag via updatePositions() fast path - Replace eraser with Review mode in toolbar tool group (orange gradient) - Add tool instruction hints bar below toolbar (context-sensitive per tool) - Feedback panel: glassmorphism, improved ThreadDetail/CommentItem/ThreadListItem - Timestamps: relative with "ago" suffix, full date on hover tooltip - De-emphasize pin numbers, show status text in thread list - Animated arrangement transitions (easeOutCubic, 320ms) for pack/grid/row/column - Disable snap guides (user requested) - Paste now selects newly created items - Remove eraser keyboard shortcuts, remap tool shortcuts - Orphaned thread cleanup: safe destroy check for deleted media pins
This commit is contained in:
@@ -73,11 +73,13 @@ function IconText() {
|
||||
);
|
||||
}
|
||||
|
||||
function IconEraser() {
|
||||
function IconReview() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M6.5 14H14M2 10l4.5-4.5 4 4L6 14H3l-1-1v-3z" strokeLinejoin="round" />
|
||||
<path d="M6.5 5.5L14 2" strokeLinecap="round" />
|
||||
<path d="M2.5 3A1.5 1.5 0 014 1.5h8A1.5 1.5 0 0113.5 3v7A1.5 1.5 0 0112 11.5H7l-3.5 3V11.5H4A1.5 1.5 0 012.5 10V3z" />
|
||||
<circle cx="6" cy="6.5" r="0.7" fill="currentColor" stroke="none" />
|
||||
<circle cx="8" cy="6.5" r="0.7" fill="currentColor" stroke="none" />
|
||||
<circle cx="10" cy="6.5" r="0.7" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -118,14 +120,15 @@ function IconLayers() {
|
||||
);
|
||||
}
|
||||
|
||||
const toolButtons: { tool: ToolType; label: string; shortcut: string; numKey: string; Icon: React.FC }[] = [
|
||||
{ tool: ToolType.SELECT, label: 'Select', shortcut: 'V', numKey: '1', Icon: IconSelect },
|
||||
{ tool: ToolType.PAN, label: 'Pan', shortcut: 'H', numKey: '2', Icon: IconPan },
|
||||
{ tool: ToolType.PEN, label: 'Draw', shortcut: 'P', numKey: '3', Icon: IconPen },
|
||||
{ tool: ToolType.TEXT, label: 'Text', shortcut: 'T', numKey: '4', Icon: IconText },
|
||||
{ tool: ToolType.ERASER, label: 'Eraser', shortcut: 'E', numKey: '5', Icon: IconEraser },
|
||||
const toolButtons: { tool: ToolType; label: string; shortcut: string; numKey: string; Icon: React.FC; hint?: string }[] = [
|
||||
{ tool: ToolType.SELECT, label: 'Select', shortcut: 'V', numKey: '1', Icon: IconSelect, hint: 'Click to select, drag to move, Shift+click for multi-select' },
|
||||
{ tool: ToolType.PAN, label: 'Pan', shortcut: 'H', numKey: '2', Icon: IconPan, hint: 'Click and drag to pan the canvas' },
|
||||
{ tool: ToolType.PEN, label: 'Draw', shortcut: 'P', numKey: '3', Icon: IconPen, hint: 'Click and drag to draw freehand' },
|
||||
{ tool: ToolType.TEXT, label: 'Text', shortcut: 'T', numKey: '4', Icon: IconText, hint: 'Click on the canvas to place text' },
|
||||
];
|
||||
|
||||
const REVIEW_HINT = 'Click an image to leave a comment. Press . to toggle.';
|
||||
|
||||
export default function Toolbar({
|
||||
activeTool,
|
||||
onToolChange,
|
||||
@@ -158,26 +161,30 @@ export default function Toolbar({
|
||||
const showStroke = activeTool === ToolType.PEN;
|
||||
const showFontSize = activeTool === ToolType.TEXT;
|
||||
|
||||
// Determine active hint
|
||||
const activeToolDef = toolButtons.find((t) => t.tool === activeTool);
|
||||
const activeHint = reviewMode ? REVIEW_HINT : activeToolDef?.hint || '';
|
||||
|
||||
return (
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
padding: '4px 8px',
|
||||
background: '#1a1a1a',
|
||||
borderBottom: '1px solid #2a2a2a',
|
||||
flexShrink: 0,
|
||||
borderBottom: activeHint ? 'none' : '1px solid #2a2a2a',
|
||||
height: '44px',
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
{/* Tool buttons */}
|
||||
<div style={{ display: 'flex', gap: '1px', background: '#222', borderRadius: '8px', padding: '2px' }}>
|
||||
{toolButtons.map(({ tool, label, shortcut, numKey, Icon }) => {
|
||||
const active = activeTool === tool;
|
||||
const active = activeTool === tool && !reviewMode;
|
||||
return (
|
||||
<button
|
||||
key={tool}
|
||||
onClick={() => onToolChange(tool)}
|
||||
onClick={() => { onToolChange(tool); if (reviewMode && onToggleReview) onToggleReview(); }}
|
||||
title={`${label} (${shortcut} or ${numKey})`}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -205,6 +212,36 @@ export default function Toolbar({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* Review mode — in tool group */}
|
||||
{onToggleReview && (
|
||||
<button
|
||||
onClick={onToggleReview}
|
||||
title="Review (.)"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
gap: '4px', height: '32px', padding: '0 10px',
|
||||
background: reviewMode ? 'linear-gradient(135deg, #f97316, #ea580c)' : 'transparent',
|
||||
border: 'none', borderRadius: '6px',
|
||||
color: reviewMode ? '#fff' : '#777',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
boxShadow: reviewMode ? '0 1px 4px rgba(249,115,22,0.3)' : 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!reviewMode) { e.currentTarget.style.background = '#2a2a2a'; e.currentTarget.style.color = '#bbb'; } }}
|
||||
onMouseLeave={(e) => { if (!reviewMode) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#777'; } }}
|
||||
>
|
||||
<IconReview />
|
||||
<span style={{ fontSize: '11px', fontWeight: reviewMode ? 600 : 400, letterSpacing: '0.2px' }}>Review</span>
|
||||
<span style={{
|
||||
fontSize: '9px', color: reviewMode ? 'rgba(255,255,255,0.5)' : '#555',
|
||||
background: reviewMode ? 'rgba(255,255,255,0.1)' : '#1a1a1a',
|
||||
padding: '1px 4px', borderRadius: '3px', fontWeight: 500,
|
||||
lineHeight: '14px', minWidth: '14px', textAlign: 'center',
|
||||
}}>
|
||||
.
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
@@ -313,15 +350,6 @@ export default function Toolbar({
|
||||
</ActionBtn>
|
||||
)}
|
||||
|
||||
{/* Review / Feedback */}
|
||||
{onToggleReview && (
|
||||
<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">
|
||||
<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>
|
||||
</ActionBtn>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
@@ -370,6 +398,21 @@ export default function Toolbar({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Tool hint bar */}
|
||||
{activeHint && (
|
||||
<div style={{
|
||||
padding: '4px 16px',
|
||||
background: '#141416',
|
||||
borderBottom: '1px solid #2a2a2a',
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
letterSpacing: '0.2px',
|
||||
lineHeight: '16px',
|
||||
}}>
|
||||
{activeHint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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';
|
||||
import { relativeTime, fullTimestamp } from '../../utils/relativeTime';
|
||||
import { TEXT_PRIMARY, TEXT_SECONDARY, TEXT_MUTED, BORDER, HOVER_BG } from './feedbackStyles';
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: Comment;
|
||||
@@ -11,13 +11,23 @@ interface CommentItemProps {
|
||||
}
|
||||
|
||||
export default function CommentItem({ comment, isOwn, onDelete }: CommentItemProps) {
|
||||
const [hovered, setHovered] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '10px 0', borderBottom: `1px solid ${BORDER}` }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 0',
|
||||
borderBottom: `1px solid ${BORDER}`,
|
||||
transition: 'background 0.1s ease',
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
|
||||
<span
|
||||
style={{
|
||||
width: '22px',
|
||||
height: '22px',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '50%',
|
||||
background: getAuthorColor(comment.user_id),
|
||||
display: 'flex',
|
||||
@@ -31,30 +41,31 @@ export default function CommentItem({ comment, isOwn, onDelete }: CommentItemPro
|
||||
>
|
||||
{getAuthorInitial(comment.author_name)}
|
||||
</span>
|
||||
<span style={{ color: TEXT_PRIMARY, fontSize: '12px', fontWeight: 600 }}>
|
||||
<span style={{ color: TEXT_PRIMARY, fontSize: '12px', fontWeight: 600, letterSpacing: '0.2px' }}>
|
||||
{comment.author_name}
|
||||
</span>
|
||||
<span style={{ color: TEXT_MUTED, fontSize: '10px' }}>
|
||||
<span style={{ color: TEXT_MUTED, fontSize: '10px' }} title={fullTimestamp(comment.created_at)}>
|
||||
{relativeTime(comment.created_at)}
|
||||
</span>
|
||||
{comment.edited_at && (
|
||||
<span style={{ color: TEXT_MUTED, fontSize: '10px' }}>(edited)</span>
|
||||
<span style={{ color: TEXT_MUTED, fontSize: '10px', fontStyle: 'italic' }}>edited</span>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
{isOwn && onDelete && (
|
||||
{isOwn && onDelete && hovered && (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#633',
|
||||
background: 'rgba(240, 72, 72, 0.08)',
|
||||
border: '1px solid rgba(240, 72, 72, 0.12)',
|
||||
borderRadius: '4px',
|
||||
color: TEXT_MUTED,
|
||||
cursor: 'pointer',
|
||||
fontSize: '10px',
|
||||
opacity: 0.6,
|
||||
transition: 'opacity 0.1s',
|
||||
padding: '2px 6px',
|
||||
transition: 'all 0.1s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.opacity = '1')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.opacity = '0.6')}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#f04848'; e.currentTarget.style.background = 'rgba(240, 72, 72, 0.15)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = TEXT_MUTED; e.currentTarget.style.background = 'rgba(240, 72, 72, 0.08)'; }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
@@ -62,10 +73,10 @@ export default function CommentItem({ comment, isOwn, onDelete }: CommentItemPro
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: '#ccc',
|
||||
color: TEXT_SECONDARY,
|
||||
fontSize: '13px',
|
||||
lineHeight: 1.5,
|
||||
marginLeft: '30px',
|
||||
marginLeft: '32px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
|
||||
@@ -3,12 +3,13 @@ import { Thread, AnnotationStore } from '../../stores/annotationStore';
|
||||
import CommentItem from './CommentItem';
|
||||
import CommentInput from './CommentInput';
|
||||
import {
|
||||
PANEL_BG,
|
||||
PANEL_WIDTH,
|
||||
panelContainerStyle,
|
||||
BORDER,
|
||||
TEXT_PRIMARY,
|
||||
TEXT_SECONDARY,
|
||||
TEXT_MUTED,
|
||||
ACCENT,
|
||||
HOVER_BG,
|
||||
STATUS_OPEN,
|
||||
STATUS_RESOLVED,
|
||||
} from './feedbackStyles';
|
||||
@@ -58,25 +59,11 @@ export default function ThreadDetail({
|
||||
};
|
||||
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<div style={panelContainerStyle}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
padding: '14px 16px',
|
||||
borderBottom: `1px solid ${BORDER}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -90,13 +77,17 @@ export default function ThreadDetail({
|
||||
border: 'none',
|
||||
color: TEXT_MUTED,
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
padding: '2px',
|
||||
fontSize: '16px',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '4px',
|
||||
transition: 'color 0.1s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = TEXT_PRIMARY)}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = TEXT_MUTED)}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span style={{ color: TEXT_MUTED, fontSize: '11px', fontFamily: 'monospace' }}>
|
||||
<span style={{ color: TEXT_SECONDARY, fontSize: '11px', fontFamily: 'monospace', fontWeight: 500 }}>
|
||||
#{pinNumber}
|
||||
</span>
|
||||
<span
|
||||
@@ -105,8 +96,9 @@ export default function ThreadDetail({
|
||||
fontWeight: 600,
|
||||
padding: '2px 8px',
|
||||
borderRadius: '10px',
|
||||
background: isOpen ? '#2a1515' : '#152a15',
|
||||
background: isOpen ? 'rgba(240, 72, 72, 0.1)' : 'rgba(52, 210, 123, 0.1)',
|
||||
color: isOpen ? STATUS_OPEN : STATUS_RESOLVED,
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
{isOpen ? 'Open' : 'Resolved'}
|
||||
@@ -116,12 +108,17 @@ export default function ThreadDetail({
|
||||
<button
|
||||
onClick={() => onJumpToObject(thread.object_id)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
background: 'rgba(74, 158, 255, 0.08)',
|
||||
border: `1px solid rgba(74, 158, 255, 0.15)`,
|
||||
borderRadius: '6px',
|
||||
color: ACCENT,
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
padding: '4px 10px',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(74, 158, 255, 0.15)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(74, 158, 255, 0.08)')}
|
||||
>
|
||||
Jump
|
||||
</button>
|
||||
@@ -130,14 +127,18 @@ export default function ThreadDetail({
|
||||
<button
|
||||
onClick={() => onResolve(thread.id, 'resolved')}
|
||||
style={{
|
||||
background: '#152a15',
|
||||
border: 'none',
|
||||
background: 'rgba(52, 210, 123, 0.1)',
|
||||
border: `1px solid rgba(52, 210, 123, 0.15)`,
|
||||
borderRadius: '6px',
|
||||
color: STATUS_RESOLVED,
|
||||
padding: '4px 10px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(52, 210, 123, 0.18)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(52, 210, 123, 0.1)')}
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
@@ -145,14 +146,18 @@ export default function ThreadDetail({
|
||||
<button
|
||||
onClick={() => onResolve(thread.id, 'open')}
|
||||
style={{
|
||||
background: '#1a1a2a',
|
||||
border: 'none',
|
||||
background: 'rgba(74, 158, 255, 0.08)',
|
||||
border: `1px solid rgba(74, 158, 255, 0.15)`,
|
||||
borderRadius: '6px',
|
||||
color: '#88f',
|
||||
color: ACCENT,
|
||||
padding: '4px 10px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(74, 158, 255, 0.15)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(74, 158, 255, 0.08)')}
|
||||
>
|
||||
Reopen
|
||||
</button>
|
||||
@@ -194,11 +199,16 @@ export default function ThreadDetail({
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#633',
|
||||
color: TEXT_MUTED,
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
padding: '2px',
|
||||
fontSize: '14px',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '4px',
|
||||
opacity: 0.6,
|
||||
transition: 'all 0.1s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.color = STATUS_OPEN; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.6'; e.currentTarget.style.color = TEXT_MUTED; }}
|
||||
title="Delete thread"
|
||||
>
|
||||
×
|
||||
@@ -207,7 +217,7 @@ export default function ThreadDetail({
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 14px' }}>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 16px', scrollbarWidth: 'thin', scrollbarColor: '#2a2a2e transparent' }}>
|
||||
{thread.comments.map((c) => (
|
||||
<CommentItem
|
||||
key={c.id}
|
||||
@@ -219,7 +229,7 @@ export default function ThreadDetail({
|
||||
</div>
|
||||
|
||||
{/* Reply input */}
|
||||
<div style={{ padding: '10px 14px', borderTop: `1px solid ${BORDER}` }}>
|
||||
<div style={{ padding: '12px 16px', borderTop: `1px solid ${BORDER}` }}>
|
||||
<CommentInput
|
||||
value={replyText}
|
||||
onChange={setReplyText}
|
||||
|
||||
@@ -3,13 +3,14 @@ import { Thread, AnnotationStore } from '../../stores/annotationStore';
|
||||
import ThreadListItem from './ThreadListItem';
|
||||
import CommentInput from './CommentInput';
|
||||
import {
|
||||
PANEL_BG,
|
||||
PANEL_WIDTH,
|
||||
panelContainerStyle,
|
||||
BORDER,
|
||||
TEXT_PRIMARY,
|
||||
TEXT_SECONDARY,
|
||||
TEXT_MUTED,
|
||||
STATUS_OPEN,
|
||||
FILTER_ACTIVE_BG,
|
||||
ACCENT,
|
||||
} from './feedbackStyles';
|
||||
|
||||
export type FilterType = 'open' | 'resolved' | 'all' | 'mine';
|
||||
@@ -23,7 +24,6 @@ interface ThreadListProps {
|
||||
onFilterChange: (f: FilterType) => void;
|
||||
onSelectThread: (id: string) => void;
|
||||
onCollapse: () => void;
|
||||
// New comment
|
||||
selectedObjectId: string | null;
|
||||
selectedObjectLabel: string;
|
||||
newCommentText: string;
|
||||
@@ -49,32 +49,21 @@ export default function ThreadList({
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<div style={panelContainerStyle}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 14px',
|
||||
padding: '14px 16px',
|
||||
borderBottom: `1px solid ${BORDER}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: TEXT_PRIMARY, fontSize: '14px', fontWeight: 600, flex: 1 }}>
|
||||
<svg width="16" height="16" viewBox="0 0 14 14" fill="none" stroke={TEXT_SECONDARY} strokeWidth="1.2" style={{ flexShrink: 0 }}>
|
||||
<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>
|
||||
<span style={{ color: TEXT_PRIMARY, fontSize: '13px', fontWeight: 600, flex: 1, letterSpacing: '0.3px' }}>
|
||||
Feedback
|
||||
</span>
|
||||
{openCount > 0 && (
|
||||
@@ -82,12 +71,13 @@ export default function ThreadList({
|
||||
style={{
|
||||
background: STATUS_OPEN,
|
||||
color: '#fff',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
padding: '1px 7px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
padding: '2px 7px',
|
||||
borderRadius: '10px',
|
||||
minWidth: '18px',
|
||||
textAlign: 'center',
|
||||
lineHeight: '14px',
|
||||
}}
|
||||
>
|
||||
{openCount}
|
||||
@@ -102,8 +92,12 @@ export default function ThreadList({
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
lineHeight: 1,
|
||||
padding: '2px',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '4px',
|
||||
transition: 'color 0.1s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = TEXT_PRIMARY)}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = TEXT_MUTED)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -112,10 +106,10 @@ export default function ThreadList({
|
||||
{/* Filter bar */}
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
padding: '8px 16px',
|
||||
borderBottom: `1px solid ${BORDER}`,
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
{(['open', 'resolved', 'all', 'mine'] as const).map((f) => (
|
||||
@@ -124,15 +118,16 @@ export default function ThreadList({
|
||||
onClick={() => onFilterChange(f)}
|
||||
style={{
|
||||
background: filter === f ? FILTER_ACTIVE_BG : 'transparent',
|
||||
border: 'none',
|
||||
border: filter === f ? `1px solid ${BORDER}` : '1px solid transparent',
|
||||
borderRadius: '6px',
|
||||
color: filter === f ? '#fff' : TEXT_MUTED,
|
||||
color: filter === f ? TEXT_PRIMARY : TEXT_MUTED,
|
||||
padding: '4px 10px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
fontWeight: filter === f ? 600 : 400,
|
||||
textTransform: 'capitalize',
|
||||
transition: 'all 0.1s ease',
|
||||
transition: 'all 0.15s ease',
|
||||
lineHeight: '16px',
|
||||
}}
|
||||
>
|
||||
{f}
|
||||
@@ -142,9 +137,12 @@ export default function ThreadList({
|
||||
|
||||
{/* 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 style={{ padding: '12px 16px', borderBottom: `1px solid ${BORDER}`, background: 'rgba(74, 158, 255, 0.03)' }}>
|
||||
<div style={{ color: TEXT_MUTED, fontSize: '10px', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
Comment on
|
||||
</div>
|
||||
<div style={{ color: TEXT_PRIMARY, fontSize: '12px', marginBottom: '10px', fontWeight: 500 }}>
|
||||
{selectedObjectLabel}
|
||||
</div>
|
||||
<CommentInput
|
||||
value={newCommentText}
|
||||
@@ -157,24 +155,23 @@ export default function ThreadList({
|
||||
)}
|
||||
|
||||
{/* Thread list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<div style={{ flex: 1, overflowY: 'auto', scrollbarWidth: 'thin', scrollbarColor: '#2a2a2e transparent' }}>
|
||||
{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 }}>
|
||||
<div style={{ padding: '40px 24px', textAlign: 'center' }}>
|
||||
<div style={{
|
||||
width: '48px', height: '48px', borderRadius: '50%',
|
||||
background: 'rgba(74, 158, 255, 0.06)', border: `1px solid rgba(74, 158, 255, 0.1)`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
margin: '0 auto 16px',
|
||||
}}>
|
||||
<svg width="20" height="20" viewBox="0 0 14 14" fill="none" stroke={TEXT_MUTED} strokeWidth="1">
|
||||
<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>
|
||||
<div style={{ color: TEXT_MUTED, fontSize: '12px', lineHeight: 1.6 }}>
|
||||
{selectedObjectId
|
||||
? 'No comments on this item.'
|
||||
: 'No comments yet.\nSelect an image and add a comment.'}
|
||||
: <>No comments yet.<br /><span style={{ color: TEXT_SECONDARY }}>Select an image to leave feedback.</span></>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -193,14 +190,14 @@ export default function ThreadList({
|
||||
<div
|
||||
onClick={() => setShowOrphans(!showOrphans)}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
color: TEXT_MUTED,
|
||||
fontSize: '11px',
|
||||
borderTop: `1px solid ${BORDER}`,
|
||||
transition: 'background 0.1s ease',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#151515')}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#16161a')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
{showOrphans ? '\u25BE' : '\u25B8'} Deleted items ({orphanedThreads.length})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Thread } from '../../stores/annotationStore';
|
||||
import { getAuthorColor, getAuthorInitial } from '../../utils/authorColors';
|
||||
import { relativeTime } from '../../utils/relativeTime';
|
||||
import { relativeTime, fullTimestamp } from '../../utils/relativeTime';
|
||||
import {
|
||||
TEXT_PRIMARY,
|
||||
TEXT_SECONDARY,
|
||||
@@ -26,7 +26,7 @@ export default function ThreadListItem({ thread, pinNumber, onClick }: ThreadLis
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
padding: '12px 16px',
|
||||
borderBottom: `1px solid ${BORDER}`,
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s ease',
|
||||
@@ -67,7 +67,7 @@ export default function ThreadListItem({ thread, pinNumber, onClick }: ThreadLis
|
||||
>
|
||||
{firstComment?.author_name || 'Unknown'}
|
||||
</span>
|
||||
<span style={{ color: TEXT_MUTED, fontSize: '10px', flexShrink: 0 }}>
|
||||
<span style={{ color: TEXT_MUTED, fontSize: '10px', flexShrink: 0 }} title={fullTimestamp(thread.last_commented_at || thread.created_at)}>
|
||||
{relativeTime(thread.last_commented_at || thread.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -89,7 +89,7 @@ export default function ThreadListItem({ thread, pinNumber, onClick }: ThreadLis
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 3: meta — pin #, replies, status dot */}
|
||||
{/* Row 3: meta — replies, status */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -99,9 +99,6 @@ export default function ThreadListItem({ thread, pinNumber, onClick }: ThreadLis
|
||||
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'}
|
||||
@@ -109,13 +106,15 @@ export default function ThreadListItem({ thread, pinNumber, onClick }: ThreadLis
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: isResolved ? STATUS_RESOLVED : STATUS_OPEN,
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
color: isResolved ? STATUS_RESOLVED : STATUS_OPEN,
|
||||
marginLeft: 'auto',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{thread.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,40 @@
|
||||
// Design tokens for the feedback/annotation system
|
||||
export const PANEL_WIDTH = 300;
|
||||
export const PANEL_BG = '#0d0d0d';
|
||||
export const BORDER = '#1e1e1e';
|
||||
export const PANEL_WIDTH = 320;
|
||||
export const PANEL_BG = 'rgba(12, 12, 14, 0.95)';
|
||||
export const PANEL_BG_SOLID = '#0c0c0e';
|
||||
export const BORDER = '#1a1a1e';
|
||||
|
||||
export const TEXT_PRIMARY = '#e0e0e0';
|
||||
export const TEXT_SECONDARY = '#999';
|
||||
export const TEXT_MUTED = '#666';
|
||||
export const TEXT_PRIMARY = '#eaeaea';
|
||||
export const TEXT_SECONDARY = '#a0a0a8';
|
||||
export const TEXT_MUTED = '#5a5a64';
|
||||
|
||||
export const ACCENT = '#4a9eff';
|
||||
export const STATUS_OPEN = '#ef4444';
|
||||
export const STATUS_RESOLVED = '#22c55e';
|
||||
export const STATUS_OPEN = '#f04848';
|
||||
export const STATUS_RESOLVED = '#34d27b';
|
||||
|
||||
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';
|
||||
export const INPUT_BG = '#131316';
|
||||
export const INPUT_BORDER = '#26262c';
|
||||
export const INPUT_BORDER_FOCUS = '#4a9eff44';
|
||||
export const HOVER_BG = '#16161a';
|
||||
export const FILTER_ACTIVE_BG = '#1e1e24';
|
||||
|
||||
// Shared panel container style
|
||||
export const panelContainerStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: `${PANEL_WIDTH}px`,
|
||||
background: PANEL_BG,
|
||||
backdropFilter: 'blur(16px)',
|
||||
WebkitBackdropFilter: 'blur(16px)',
|
||||
borderLeft: `1px solid ${BORDER}`,
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
userSelect: 'none',
|
||||
fontFamily: "'Inter', system-ui, -apple-system, sans-serif",
|
||||
};
|
||||
|
||||
// Import this in components that need React.CSSProperties type
|
||||
import type React from 'react';
|
||||
|
||||
Reference in New Issue
Block a user