|
|
@@ -10,6 +10,8 @@ import { Tool } from '@/components/video-player/AnnotationCanvas';
|
|
|
|
|
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
|
|
|
|
|
|
+const MAX_ANNOTATIONS = 10;
|
|
|
+
|
|
|
const STATUS_CONFIG: Record<string, { label: string; colorClass: string; bgClass: string; dotClass: string }> = {
|
|
|
PENDING_REVIEW: { label: 'Pending Review', colorClass: 'text-warning', bgClass: 'badge-warning', dotClass: 'status-dot-pending' },
|
|
|
CHANGES_REQUESTED: { label: 'Changes Requested', colorClass: 'text-warning', bgClass: 'badge-warning', dotClass: 'status-dot-changes' },
|
|
|
@@ -17,13 +19,12 @@ const STATUS_CONFIG: Record<string, { label: string; colorClass: string; bgClass
|
|
|
REJECTED: { label: 'Rejected', colorClass: 'text-danger', bgClass: 'badge-danger', dotClass: 'status-dot-rejected' },
|
|
|
};
|
|
|
|
|
|
-// HH:MM:SS:FF format
|
|
|
function formatTimecode(seconds: number, fps: number = 30): string {
|
|
|
if (!seconds || isNaN(seconds)) return '00:00:00:00';
|
|
|
const h = Math.floor(seconds / 3600);
|
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
|
const s = Math.floor(seconds % 60);
|
|
|
- const f = Math.floor((seconds % 1) * fps);
|
|
|
+ const f = Math.round(seconds * fps) % fps;
|
|
|
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}:${String(f).padStart(2,'0')}`;
|
|
|
}
|
|
|
|
|
|
@@ -37,7 +38,6 @@ export default function ReviewPage() {
|
|
|
const [comments, setComments] = useState<Comment[]>([]);
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
|
- const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(null);
|
|
|
const [panelWidth, setPanelWidth] = useState(380);
|
|
|
const [showApproval, setShowApproval] = useState(false);
|
|
|
const [updatingStatus, setUpdatingStatus] = useState(false);
|
|
|
@@ -45,9 +45,24 @@ export default function ReviewPage() {
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
const [replyTo, setReplyTo] = useState<Comment | null>(null);
|
|
|
const [showResolved, setShowResolved] = useState(false);
|
|
|
+
|
|
|
+ // Drawing state — lifted to page level
|
|
|
+ const [drawMode, setDrawMode] = useState(false);
|
|
|
+ const [drawTool, setDrawTool] = useState<Tool>('pen');
|
|
|
+ const [drawColor, setDrawColor] = useState('#ef4444');
|
|
|
+ const [pendingStrokes, setPendingStrokes] = useState<AnnotationData[]>([]);
|
|
|
+ // The comment we're annotating (null = annotating the main video, not a specific comment)
|
|
|
+ const [annotatingComment, setAnnotatingComment] = useState<Comment | null>(null);
|
|
|
+
|
|
|
const isDraggingRef = useRef(false);
|
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
|
const resizeStartRef = useRef<{ x: number; w: number } | null>(null);
|
|
|
+ // Ref to capture strokes for save callback (avoids closure stale value)
|
|
|
+ const pendingStrokesRef = useRef<AnnotationData[]>([]);
|
|
|
+ const annotatingCommentRef = useRef<Comment | null>(null);
|
|
|
+ // Keep refs in sync with state
|
|
|
+ useEffect(() => { pendingStrokesRef.current = pendingStrokes; }, [pendingStrokes]);
|
|
|
+ useEffect(() => { annotatingCommentRef.current = annotatingComment; }, [annotatingComment]);
|
|
|
|
|
|
const fps = asset?.fps ?? 30;
|
|
|
|
|
|
@@ -70,7 +85,7 @@ export default function ReviewPage() {
|
|
|
|
|
|
useEffect(() => { loadData(); }, [loadData]);
|
|
|
|
|
|
- // Resize panel
|
|
|
+ // ── Panel resize ─────────────────────────────────────────────────────────
|
|
|
const handleMouseMove = useCallback((e: MouseEvent) => {
|
|
|
if (!isDraggingRef.current || !resizeStartRef.current) return;
|
|
|
const dx = e.clientX - resizeStartRef.current.x;
|
|
|
@@ -101,14 +116,15 @@ export default function ReviewPage() {
|
|
|
document.body.style.cursor = 'col-resize';
|
|
|
};
|
|
|
|
|
|
- const handleAddComment = async (content: string, timestamp?: number, annotation?: unknown) => {
|
|
|
+ // ── Comment actions ───────────────────────────────────────────────────────
|
|
|
+ const handleAddComment = async (content: string, timestamp?: number, annotations?: AnnotationData[]) => {
|
|
|
if (!token || !content.trim()) return;
|
|
|
setSubmitting(true);
|
|
|
try {
|
|
|
const { comment } = await commentsApi.create(token, assetId, {
|
|
|
content: content.trim(),
|
|
|
timestamp,
|
|
|
- annotation: annotation as AnnotationData | undefined,
|
|
|
+ annotations,
|
|
|
parentId: replyTo?.id,
|
|
|
});
|
|
|
if (replyTo) {
|
|
|
@@ -121,7 +137,7 @@ export default function ReviewPage() {
|
|
|
setComments(prev => [...prev, comment]);
|
|
|
}
|
|
|
setNewComment('');
|
|
|
- setPendingAnnotation(null);
|
|
|
+ setPendingStrokes([]);
|
|
|
setReplyTo(null);
|
|
|
} catch (err) {
|
|
|
alert(err instanceof Error ? err.message : 'Failed to add comment');
|
|
|
@@ -145,13 +161,99 @@ export default function ReviewPage() {
|
|
|
if (!confirm('Delete this comment?')) return;
|
|
|
try {
|
|
|
await commentsApi.delete(token, commentId);
|
|
|
- setComments(prev => prev.filter(c => c.id !== commentId && c.replies?.some(r => r.id !== commentId)));
|
|
|
- setComments(prev => prev.map(c => c.id === commentId ? c : { ...c, replies: c.replies?.filter(r => r.id !== commentId) }));
|
|
|
+ setComments(prev => prev
|
|
|
+ .filter(c => c.id !== commentId)
|
|
|
+ .map(c => ({ ...c, replies: c.replies?.filter(r => r.id !== commentId) }))
|
|
|
+ );
|
|
|
} catch {
|
|
|
alert('Failed to delete comment');
|
|
|
}
|
|
|
};
|
|
|
|
|
|
+ // ── Annotation actions ─────────────────────────────────────────────────────
|
|
|
+ // User clicks "Add annotation" on a comment
|
|
|
+ const handleAddAnnotationClick = (comment: Comment) => {
|
|
|
+ const existingCount = comment.annotations?.length ?? 0;
|
|
|
+ if (existingCount >= MAX_ANNOTATIONS) {
|
|
|
+ alert(`Maximum ${MAX_ANNOTATIONS} annotations per comment.`);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // Seek to comment timestamp if it exists
|
|
|
+ if (comment.timestamp != null) {
|
|
|
+ const videoEl = document.querySelector('video') as HTMLVideoElement | null;
|
|
|
+ if (videoEl) {
|
|
|
+ videoEl.pause();
|
|
|
+ videoEl.currentTime = comment.timestamp;
|
|
|
+ }
|
|
|
+ setCurrentTime(comment.timestamp);
|
|
|
+ }
|
|
|
+ setPendingStrokes([]);
|
|
|
+ setAnnotatingComment(comment);
|
|
|
+ setDrawMode(true);
|
|
|
+ };
|
|
|
+
|
|
|
+ // Each completed stroke is added to pendingStrokes
|
|
|
+ const handleStrokeComplete = (stroke: AnnotationData) => {
|
|
|
+ setPendingStrokes(prev => {
|
|
|
+ const next = [...prev, stroke];
|
|
|
+ if (next.length >= MAX_ANNOTATIONS) {
|
|
|
+ setDrawMode(false);
|
|
|
+ }
|
|
|
+ return next;
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ // Save pending strokes — use handleAddComment with replyTo set if annotating a comment
|
|
|
+ const handleSaveAnnotations = (content: string, timestamp?: number) => {
|
|
|
+ const strokes = pendingStrokesRef.current;
|
|
|
+ const parent = annotatingCommentRef.current;
|
|
|
+ setPendingStrokes([]);
|
|
|
+ setDrawMode(false);
|
|
|
+ setAnnotatingComment(null);
|
|
|
+ if (parent) {
|
|
|
+ setReplyTo(parent);
|
|
|
+ setNewComment(content.trim() || '(annotation)');
|
|
|
+ // Also call handleAddComment with strokes and parentId
|
|
|
+ const parentId = parent.id;
|
|
|
+ if (!token) return;
|
|
|
+ setSubmitting(true);
|
|
|
+ commentsApi.create(token, assetId, {
|
|
|
+ content: content.trim() || '(annotation)',
|
|
|
+ timestamp,
|
|
|
+ annotations: strokes,
|
|
|
+ parentId,
|
|
|
+ }).then(({ comment }) => {
|
|
|
+ setComments(prev => prev.map(c =>
|
|
|
+ c.id === parentId
|
|
|
+ ? { ...c, replies: [...(c.replies ?? []), comment] }
|
|
|
+ : c
|
|
|
+ ));
|
|
|
+ setReplyTo(null);
|
|
|
+ setNewComment('');
|
|
|
+ }).catch(err => alert(err instanceof Error ? err.message : 'Failed to save')).finally(() => setSubmitting(false));
|
|
|
+ } else {
|
|
|
+ handleAddComment(content.trim() || '(annotation)', timestamp, strokes);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // Discard pending strokes
|
|
|
+ const handleUndoAnnotations = () => {
|
|
|
+ setPendingStrokes([]);
|
|
|
+ setDrawMode(false);
|
|
|
+ setAnnotatingComment(null);
|
|
|
+ };
|
|
|
+
|
|
|
+ // Delete a single annotation from a comment (owner only)
|
|
|
+ const handleDeleteAnnotation = async (commentId: string, remainingAnnotations: AnnotationData[]) => {
|
|
|
+ if (!token) return;
|
|
|
+ try {
|
|
|
+ const { comment } = await commentsApi.updateAnnotations(token, commentId, remainingAnnotations);
|
|
|
+ setComments(prev => prev.map(c => c.id === commentId ? comment : c));
|
|
|
+ } catch {
|
|
|
+ alert('Failed to delete annotation');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
const handleStatusUpdate = async (status: string) => {
|
|
|
if (!token) return;
|
|
|
setUpdatingStatus(true);
|
|
|
@@ -166,23 +268,23 @@ export default function ReviewPage() {
|
|
|
}
|
|
|
};
|
|
|
|
|
|
- const handleAnnotationCreated = (annotation: AnnotationData) => {
|
|
|
- setPendingAnnotation(annotation);
|
|
|
- };
|
|
|
-
|
|
|
- // Seek to comment WITHOUT auto-play
|
|
|
- const handleCommentSeek = useCallback((comment: Comment) => {
|
|
|
- setCurrentTime(comment.timestamp ?? 0);
|
|
|
+ const handleTimeUpdate = useCallback((time: number) => {
|
|
|
+ setCurrentTime(time);
|
|
|
}, []);
|
|
|
|
|
|
- const handleTimeUpdate = useCallback((time: number) => {
|
|
|
+ const handleCommentSeek = useCallback((comment: Comment) => {
|
|
|
+ const time = comment.timestamp ?? 0;
|
|
|
setCurrentTime(time);
|
|
|
+ const videoEl = document.querySelector('video') as HTMLVideoElement | null;
|
|
|
+ if (videoEl) {
|
|
|
+ videoEl.pause();
|
|
|
+ videoEl.currentTime = time;
|
|
|
+ }
|
|
|
}, []);
|
|
|
|
|
|
const status = asset?.status ?? 'PENDING_REVIEW';
|
|
|
const statusCfg = STATUS_CONFIG[status];
|
|
|
|
|
|
- // Determine video URL: prefer HLS if available
|
|
|
const videoUrl = asset?.hlsPath
|
|
|
? `${API_BASE}/uploads${asset.hlsPath}`
|
|
|
: asset
|
|
|
@@ -211,11 +313,7 @@ export default function ReviewPage() {
|
|
|
|
|
|
{/* ── Top bar ──────────────────────────────────────────── */}
|
|
|
<header className="h-12 flex items-center px-4 gap-3 shrink-0"
|
|
|
- style={{
|
|
|
- background: 'rgba(10,11,20,0.95)',
|
|
|
- borderBottom: '1px solid rgba(255,255,255,0.06)',
|
|
|
- zIndex: 50,
|
|
|
- }}>
|
|
|
+ style={{ background: 'rgba(10,11,20,0.95)', borderBottom: '1px solid rgba(255,255,255,0.06)', zIndex: 50 }}>
|
|
|
|
|
|
<button
|
|
|
onClick={() => router.push(`/projects/${asset.projectId}`)}
|
|
|
@@ -231,9 +329,7 @@ export default function ReviewPage() {
|
|
|
<div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
- <h1 className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>
|
|
|
- {asset.title}
|
|
|
- </h1>
|
|
|
+ <h1 className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>{asset.title}</h1>
|
|
|
</div>
|
|
|
|
|
|
<span className="text-xs hidden sm:inline shrink-0" style={{ color: 'var(--text-subtle)' }}>
|
|
|
@@ -290,14 +386,18 @@ export default function ReviewPage() {
|
|
|
{/* Video area */}
|
|
|
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-3 min-w-0">
|
|
|
|
|
|
- {/* Custom video player */}
|
|
|
<VideoPlayer
|
|
|
src={videoUrl}
|
|
|
mimeType={asset.mimeType}
|
|
|
fps={fps}
|
|
|
comments={allComments}
|
|
|
- pendingAnnotation={pendingAnnotation}
|
|
|
- onAnnotationCreated={handleAnnotationCreated}
|
|
|
+ drawMode={drawMode}
|
|
|
+ drawTool={drawTool}
|
|
|
+ drawColor={drawColor}
|
|
|
+ onDrawModeChange={setDrawMode}
|
|
|
+ onDrawToolChange={setDrawTool}
|
|
|
+ onDrawColorChange={setDrawColor}
|
|
|
+ onStrokeComplete={handleStrokeComplete}
|
|
|
onTimeUpdate={handleTimeUpdate}
|
|
|
onCommentClick={handleCommentSeek}
|
|
|
/>
|
|
|
@@ -306,28 +406,21 @@ export default function ReviewPage() {
|
|
|
<div className="flex flex-wrap gap-3 text-xs shrink-0" style={{ color: 'var(--text-subtle)' }}>
|
|
|
<span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Space</kbd> play/pause</span>
|
|
|
<span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>←</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>→</kbd> seek ±5s</span>
|
|
|
- <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>U</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>I</kbd> prev/next frame</span>
|
|
|
- <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>C</kbd> draw annotation</span>
|
|
|
+ <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>U</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>I</kbd> frame</span>
|
|
|
+ <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>C</kbd> draw mode</span>
|
|
|
+ <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Esc</kbd> exit draw</span>
|
|
|
<span className="font-mono text-[11px]">{formatTimecode(currentTime, fps)}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* Resize handle */}
|
|
|
- <div
|
|
|
- className="resize-handle"
|
|
|
- onMouseDown={handleResizeStart}
|
|
|
- style={{ width: '4px' }}
|
|
|
- />
|
|
|
+ <div className="resize-handle" onMouseDown={handleResizeStart} style={{ width: '4px' }} />
|
|
|
|
|
|
{/* ── Comment panel ─────────────────────────────────── */}
|
|
|
<div
|
|
|
ref={panelRef}
|
|
|
className="flex flex-col overflow-hidden shrink-0"
|
|
|
- style={{
|
|
|
- width: panelWidth,
|
|
|
- background: 'rgba(10,11,20,0.98)',
|
|
|
- borderLeft: '1px solid rgba(255,255,255,0.06)',
|
|
|
- }}
|
|
|
+ style={{ width: panelWidth, background: 'rgba(10,11,20,0.98)', borderLeft: '1px solid rgba(255,255,255,0.06)' }}
|
|
|
>
|
|
|
{/* Panel header */}
|
|
|
<div className="px-4 py-3 flex items-center justify-between shrink-0"
|
|
|
@@ -339,23 +432,55 @@ export default function ReviewPage() {
|
|
|
{comments.length}
|
|
|
</span>
|
|
|
</div>
|
|
|
- <div className="flex items-center gap-3">
|
|
|
- {/* Timecode display */}
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
<span className="font-mono text-xs" style={{ color: '#818CF8' }}>
|
|
|
{formatTimecode(currentTime, fps)}
|
|
|
</span>
|
|
|
- {/* Toggle resolved */}
|
|
|
<button
|
|
|
onClick={() => setShowResolved(v => !v)}
|
|
|
className={`text-xs px-2 py-0.5 rounded-md transition-colors ${showResolved ? 'bg-indigo-600 text-white' : ''}`}
|
|
|
style={!showResolved ? { background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' } : {}}
|
|
|
- title="Toggle resolved comments"
|
|
|
>
|
|
|
{showResolved ? 'Hide resolved' : 'Show resolved'}
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
+ {/* Drawing mode banner */}
|
|
|
+ {drawMode && (
|
|
|
+ <div className="px-4 py-2 shrink-0 flex items-center gap-2"
|
|
|
+ style={{ background: 'rgba(59,130,246,0.12)', borderBottom: '1px solid rgba(59,130,246,0.2)' }}>
|
|
|
+ <svg className="w-4 h-4 shrink-0" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
|
|
+ </svg>
|
|
|
+ <span className="text-xs flex-1" style={{ color: '#818CF8' }}>
|
|
|
+ {annotatingComment
|
|
|
+ ? `Drawing on comment by ${annotatingComment.user?.name} — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`
|
|
|
+ : `Drawing on video — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`}
|
|
|
+ </span>
|
|
|
+ <div className="flex items-center gap-1.5">
|
|
|
+ <button
|
|
|
+ onClick={handleUndoAnnotations}
|
|
|
+ className="text-xs px-2 py-0.5 rounded transition-colors"
|
|
|
+ style={{ background: 'rgba(239,68,68,0.15)', color: '#FCA5A5' }}
|
|
|
+ >
|
|
|
+ Undo all
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={() => {
|
|
|
+ const text = window.prompt('Add a note (optional):') ?? '';
|
|
|
+ handleSaveAnnotations(text, currentTime);
|
|
|
+ }}
|
|
|
+ disabled={submitting}
|
|
|
+ className="text-xs px-2 py-0.5 rounded transition-colors"
|
|
|
+ style={{ background: 'rgba(34,197,94,0.15)', color: '#86EFAC' }}
|
|
|
+ >
|
|
|
+ {submitting ? 'Saving…' : 'Save'}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
{/* Comment list */}
|
|
|
<div className="flex-1 overflow-y-auto scroll-area">
|
|
|
{visibleComments.length === 0 ? (
|
|
|
@@ -368,7 +493,7 @@ export default function ReviewPage() {
|
|
|
</div>
|
|
|
<p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>No comments yet</p>
|
|
|
<p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
|
|
|
- Pause video, press <kbd className="px-1 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>C</kbd> to draw, then submit
|
|
|
+ Add a comment below or click <strong>Add annotation</strong> on an existing comment
|
|
|
</p>
|
|
|
</div>
|
|
|
) : (
|
|
|
@@ -383,6 +508,8 @@ export default function ReviewPage() {
|
|
|
onReply={() => { setReplyTo(comment); }}
|
|
|
onResolve={() => handleResolve(comment.id)}
|
|
|
onDelete={() => handleDeleteComment(comment.id)}
|
|
|
+ onAddAnnotation={() => handleAddAnnotationClick(comment)}
|
|
|
+ onDeleteAnnotation={(anns) => handleDeleteAnnotation(comment.id, anns)}
|
|
|
/>
|
|
|
))}
|
|
|
</div>
|
|
|
@@ -406,24 +533,23 @@ export default function ReviewPage() {
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
- <div className="flex items-center gap-1.5 mb-2 text-xs" style={{ color: 'var(--text-muted)' }}>
|
|
|
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
- </svg>
|
|
|
- {formatTimecode(currentTime, fps)}
|
|
|
- {!!pendingAnnotation && (
|
|
|
- <span className="ml-1" style={{ color: '#818CF8' }}>(+ annotation)</span>
|
|
|
- )}
|
|
|
- {!!replyTo && (
|
|
|
- <span className="ml-1" style={{ color: '#818CF8' }}>(reply)</span>
|
|
|
- )}
|
|
|
- </div>
|
|
|
+ {/* Pending strokes indicator */}
|
|
|
+ {pendingStrokes.length > 0 && (
|
|
|
+ <div className="flex items-center gap-2 mb-2 text-xs" style={{ color: '#818CF8' }}>
|
|
|
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
|
|
+ </svg>
|
|
|
+ {pendingStrokes.length} stroke{pendingStrokes.length !== 1 ? 's' : ''} ready
|
|
|
+ {annotatingComment ? ` → will be saved as reply to "${annotatingComment.user?.name}"` : ' → will be saved as new comment'}
|
|
|
+ <button onClick={handleUndoAnnotations} className="ml-auto text-xs" style={{ color: '#FCA5A5' }}>Undo</button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
|
|
|
<form
|
|
|
onSubmit={e => {
|
|
|
e.preventDefault();
|
|
|
- if (newComment.trim()) {
|
|
|
- handleAddComment(newComment, currentTime, pendingAnnotation ?? undefined);
|
|
|
+ if (newComment.trim() || pendingStrokes.length > 0) {
|
|
|
+ handleAddComment(newComment, currentTime, pendingStrokes.length > 0 ? pendingStrokes : undefined);
|
|
|
}
|
|
|
}}
|
|
|
className="flex gap-2"
|
|
|
@@ -440,13 +566,15 @@ export default function ReviewPage() {
|
|
|
onKeyDown={e => {
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
e.preventDefault();
|
|
|
- if (newComment.trim()) handleAddComment(newComment, currentTime, pendingAnnotation ?? undefined);
|
|
|
+ if (newComment.trim() || pendingStrokes.length > 0) {
|
|
|
+ handleAddComment(newComment, currentTime, pendingStrokes.length > 0 ? pendingStrokes : undefined);
|
|
|
+ }
|
|
|
}
|
|
|
}}
|
|
|
/>
|
|
|
<button
|
|
|
type="submit"
|
|
|
- disabled={submitting || !newComment.trim()}
|
|
|
+ disabled={submitting || (!newComment.trim() && pendingStrokes.length === 0)}
|
|
|
className="btn btn-primary btn-sm px-3"
|
|
|
>
|
|
|
{submitting ? (
|
|
|
@@ -467,6 +595,7 @@ export default function ReviewPage() {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+// ── CommentItem ─────────────────────────────────────────────────────────────
|
|
|
function CommentItem({
|
|
|
comment,
|
|
|
currentUserId,
|
|
|
@@ -475,6 +604,8 @@ function CommentItem({
|
|
|
onReply,
|
|
|
onResolve,
|
|
|
onDelete,
|
|
|
+ onAddAnnotation,
|
|
|
+ onDeleteAnnotation,
|
|
|
}: {
|
|
|
comment: Comment;
|
|
|
currentUserId: string;
|
|
|
@@ -483,10 +614,14 @@ function CommentItem({
|
|
|
onReply: () => void;
|
|
|
onResolve: () => void;
|
|
|
onDelete: () => void;
|
|
|
+ onAddAnnotation: () => void;
|
|
|
+ onDeleteAnnotation: (annotations: AnnotationData[]) => void;
|
|
|
}) {
|
|
|
const isOwner = comment.userId === currentUserId;
|
|
|
const name = comment.user?.name ?? 'Unknown';
|
|
|
const isReply = !!comment.parentId;
|
|
|
+ const annotations = comment.annotations ?? [];
|
|
|
+ const canAddMore = annotations.length < MAX_ANNOTATIONS;
|
|
|
|
|
|
return (
|
|
|
<div
|
|
|
@@ -497,7 +632,7 @@ function CommentItem({
|
|
|
<Avatar name={name} size="sm" />
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
- {/* Meta */}
|
|
|
+ {/* Meta row */}
|
|
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
|
<span className="text-xs font-medium" style={{ color: 'var(--text)' }}>{name}</span>
|
|
|
{comment.timestamp != null && (
|
|
|
@@ -520,14 +655,35 @@ function CommentItem({
|
|
|
</span>
|
|
|
</div>
|
|
|
|
|
|
- {/* Annotation preview */}
|
|
|
- {comment.annotation && (
|
|
|
- <div className="mb-2 text-xs px-2 py-1 rounded inline-flex items-center gap-1"
|
|
|
- style={{ background: 'rgba(99,102,241,0.08)', color: '#818CF8', border: '1px solid rgba(99,102,241,0.15)' }}>
|
|
|
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
|
|
- </svg>
|
|
|
- {comment.annotation.type} annotation
|
|
|
+ {/* Annotation preview badges */}
|
|
|
+ {annotations.length > 0 && (
|
|
|
+ <div className="flex flex-wrap gap-1 mb-2">
|
|
|
+ {annotations.map((ann, i) => (
|
|
|
+ <div
|
|
|
+ key={i}
|
|
|
+ className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded"
|
|
|
+ style={{ background: `${ann.color}20`, color: ann.color, border: `1px solid ${ann.color}40` }}
|
|
|
+ >
|
|
|
+ <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
|
|
+ </svg>
|
|
|
+ {ann.type}
|
|
|
+ {isOwner && (
|
|
|
+ <button
|
|
|
+ onClick={() => {
|
|
|
+ const remaining = annotations.filter((_, j) => j !== i);
|
|
|
+ onDeleteAnnotation(remaining);
|
|
|
+ }}
|
|
|
+ className="ml-0.5 hover:opacity-70 transition-opacity"
|
|
|
+ title="Delete this annotation"
|
|
|
+ >
|
|
|
+ <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
@@ -537,44 +693,55 @@ function CommentItem({
|
|
|
</p>
|
|
|
|
|
|
{/* Actions */}
|
|
|
- {!comment.resolved && (
|
|
|
- <div className="flex items-center gap-1">
|
|
|
- {!isReply && (
|
|
|
- <button
|
|
|
- onClick={onReply}
|
|
|
- className="text-xs px-2 py-1 rounded-md transition-colors"
|
|
|
- style={{ color: 'var(--text-muted)' }}
|
|
|
- title="Reply"
|
|
|
- >
|
|
|
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
|
|
- </svg>
|
|
|
- </button>
|
|
|
- )}
|
|
|
+ <div className="flex items-center gap-1">
|
|
|
+ {!isReply && (
|
|
|
<button
|
|
|
- onClick={onResolve}
|
|
|
+ onClick={onAddAnnotation}
|
|
|
+ disabled={!canAddMore}
|
|
|
+ className="text-xs px-2 py-1 rounded-md transition-colors disabled:opacity-30"
|
|
|
+ style={{ color: '#818CF8' }}
|
|
|
+ title={canAddMore ? `Add annotation (${annotations.length}/${MAX_ANNOTATIONS})` : `Max ${MAX_ANNOTATIONS} annotations reached`}
|
|
|
+ >
|
|
|
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ {!isReply && (
|
|
|
+ <button
|
|
|
+ onClick={onReply}
|
|
|
className="text-xs px-2 py-1 rounded-md transition-colors"
|
|
|
- style={{ color: '#6366F1' }}
|
|
|
- title="Mark as resolved"
|
|
|
+ style={{ color: 'var(--text-muted)' }}
|
|
|
+ title="Reply"
|
|
|
>
|
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
|
|
</svg>
|
|
|
</button>
|
|
|
- {isOwner && (
|
|
|
- <button
|
|
|
- onClick={onDelete}
|
|
|
- className="text-xs px-2 py-1 rounded-md transition-colors"
|
|
|
- style={{ color: 'var(--text-subtle)' }}
|
|
|
- title="Delete comment"
|
|
|
- >
|
|
|
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
|
- </svg>
|
|
|
- </button>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- )}
|
|
|
+ )}
|
|
|
+ <button
|
|
|
+ onClick={onResolve}
|
|
|
+ className="text-xs px-2 py-1 rounded-md transition-colors"
|
|
|
+ style={{ color: comment.resolved ? '#86EFAC' : '#6366F1' }}
|
|
|
+ title={comment.resolved ? 'Unresolve' : 'Mark as resolved'}
|
|
|
+ >
|
|
|
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ {isOwner && (
|
|
|
+ <button
|
|
|
+ onClick={onDelete}
|
|
|
+ className="text-xs px-2 py-1 rounded-md transition-colors"
|
|
|
+ style={{ color: 'var(--text-subtle)' }}
|
|
|
+ title="Delete comment"
|
|
|
+ >
|
|
|
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
|
|
|
{/* Replies */}
|
|
|
{comment.replies && comment.replies.length > 0 && (
|
|
|
@@ -589,6 +756,8 @@ function CommentItem({
|
|
|
onReply={() => {}}
|
|
|
onResolve={onResolve}
|
|
|
onDelete={onDelete}
|
|
|
+ onAddAnnotation={() => {}}
|
|
|
+ onDeleteAnnotation={onDeleteAnnotation}
|
|
|
/>
|
|
|
))}
|
|
|
</div>
|