Procházet zdrojové kódy

feat: annotation UX overhaul — per-comment, undo, save, tick marks

Annotation system:
- Comments now hold up to 10 annotations (array field, renamed from single annotation)
- "Add annotation" button on each comment: seeks video, enables draw mode
- Each completed stroke added to pending strokes list; previewed in panel
- "Undo all" discards pending strokes; "Save" → prompt for note text → creates comment
- Owner can delete individual annotation badges from their own comments
- New API: PUT /api/comments/:id/annotations to update annotation array
- AnnotationCanvas: strokes persist visually while drawing (no disappearing)
- Canvas: mouse events stopPropagation to prevent play on click

Timeline:
- Comment markers changed from 12px circular buttons to 2px tick marks
- Colored green (resolved) / indigo (open) — compact, minimal footprint

UX improvements:
- Resolve/unresolve toggle: clicking the checkmark button now toggles both ways
- Show resolved: filter applied only to top-level comments (replies always visible)
- Video pauses when entering draw mode via C key or button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Son Nguyen před 1 měsícem
rodič
revize
d6b90d4dde

+ 1 - 1
packages/api/prisma/schema.prisma

@@ -80,7 +80,7 @@ model Comment {
   userId     String
   userId     String
   content    String
   content    String
   timestamp  Float?
   timestamp  Float?
-  annotation Json?
+  annotations Json?
   resolved   Boolean   @default(false)
   resolved   Boolean   @default(false)
   parentId   String?
   parentId   String?
   createdAt  DateTime  @default(now())
   createdAt  DateTime  @default(now())

+ 40 - 5
packages/api/src/routes/comments.ts

@@ -12,7 +12,6 @@ router.get('/:assetId/comments', async (req: Request, res: Response) => {
   try {
   try {
     const { resolved } = req.query;
     const { resolved } = req.query;
 
 
-    // Verify user has access to this asset's project
     const asset = await prisma.asset.findFirst({
     const asset = await prisma.asset.findFirst({
       where: {
       where: {
         id: str(req.params.assetId),
         id: str(req.params.assetId),
@@ -55,14 +54,13 @@ router.get('/:assetId/comments', async (req: Request, res: Response) => {
 // POST /api/assets/:assetId/comments
 // POST /api/assets/:assetId/comments
 router.post('/:assetId/comments', async (req: Request, res: Response) => {
 router.post('/:assetId/comments', async (req: Request, res: Response) => {
   try {
   try {
-    const { content, timestamp, annotation, parentId } = req.body;
+    const { content, timestamp, annotations: commentAnnotations, parentId } = req.body;
 
 
     if (!content?.trim()) {
     if (!content?.trim()) {
       res.status(400).json({ error: 'content is required' });
       res.status(400).json({ error: 'content is required' });
       return;
       return;
     }
     }
 
 
-    // Verify user has access
     const asset = await prisma.asset.findFirst({
     const asset = await prisma.asset.findFirst({
       where: {
       where: {
         id: str(req.params.assetId),
         id: str(req.params.assetId),
@@ -81,7 +79,7 @@ router.post('/:assetId/comments', async (req: Request, res: Response) => {
         userId: req.user!.userId,
         userId: req.user!.userId,
         content: content.trim(),
         content: content.trim(),
         timestamp: timestamp ?? null,
         timestamp: timestamp ?? null,
-        annotation: annotation ?? null,
+        annotations: commentAnnotations ?? null,
         parentId: parentId ?? null,
         parentId: parentId ?? null,
       },
       },
       include: {
       include: {
@@ -101,7 +99,7 @@ router.post('/:assetId/comments', async (req: Request, res: Response) => {
   }
   }
 });
 });
 
 
-// PUT /api/comments/:id/resolve
+// PUT /api/comments/:id/resolve — toggle resolved state
 router.put('/:id/resolve', async (req: Request, res: Response) => {
 router.put('/:id/resolve', async (req: Request, res: Response) => {
   try {
   try {
     const comment = await prisma.comment.findFirst({
     const comment = await prisma.comment.findFirst({
@@ -136,6 +134,43 @@ router.put('/:id/resolve', async (req: Request, res: Response) => {
   }
   }
 });
 });
 
 
+// PUT /api/comments/:id/annotations — update annotations array
+router.put('/:id/annotations', async (req: Request, res: Response) => {
+  try {
+    const { annotations } = req.body;
+
+    const comment = await prisma.comment.findFirst({
+      where: {
+        id: str(req.params.id),
+        userId: req.user!.userId,
+      },
+    });
+
+    if (!comment) {
+      res.status(404).json({ error: 'Comment not found' });
+      return;
+    }
+
+    const updated = await prisma.comment.update({
+      where: { id: str(req.params.id) },
+      data: { annotations: annotations ?? null },
+      include: {
+        user: { select: { id: true, name: true, email: true, avatarUrl: true } },
+        replies: {
+          include: {
+            user: { select: { id: true, name: true, email: true, avatarUrl: true } },
+          },
+        },
+      },
+    });
+
+    res.json({ comment: updated });
+  } catch (err) {
+    console.error('Update annotations error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
 // DELETE /api/comments/:id
 // DELETE /api/comments/:id
 router.delete('/:id', async (req: Request, res: Response) => {
 router.delete('/:id', async (req: Request, res: Response) => {
   try {
   try {

+ 272 - 103
src/app/review/[assetId]/page.tsx

@@ -10,6 +10,8 @@ import { Tool } from '@/components/video-player/AnnotationCanvas';
 
 
 const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
 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 }> = {
 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' },
   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' },
   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' },
   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 {
 function formatTimecode(seconds: number, fps: number = 30): string {
   if (!seconds || isNaN(seconds)) return '00:00:00:00';
   if (!seconds || isNaN(seconds)) return '00:00:00:00';
   const h = Math.floor(seconds / 3600);
   const h = Math.floor(seconds / 3600);
   const m = Math.floor((seconds % 3600) / 60);
   const m = Math.floor((seconds % 3600) / 60);
   const s = Math.floor(seconds % 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')}`;
   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 [comments, setComments] = useState<Comment[]>([]);
   const [loading, setLoading] = useState(true);
   const [loading, setLoading] = useState(true);
   const [currentTime, setCurrentTime] = useState(0);
   const [currentTime, setCurrentTime] = useState(0);
-  const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(null);
   const [panelWidth, setPanelWidth] = useState(380);
   const [panelWidth, setPanelWidth] = useState(380);
   const [showApproval, setShowApproval] = useState(false);
   const [showApproval, setShowApproval] = useState(false);
   const [updatingStatus, setUpdatingStatus] = useState(false);
   const [updatingStatus, setUpdatingStatus] = useState(false);
@@ -45,9 +45,24 @@ export default function ReviewPage() {
   const [submitting, setSubmitting] = useState(false);
   const [submitting, setSubmitting] = useState(false);
   const [replyTo, setReplyTo] = useState<Comment | null>(null);
   const [replyTo, setReplyTo] = useState<Comment | null>(null);
   const [showResolved, setShowResolved] = useState(false);
   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 isDraggingRef = useRef(false);
   const panelRef = useRef<HTMLDivElement>(null);
   const panelRef = useRef<HTMLDivElement>(null);
   const resizeStartRef = useRef<{ x: number; w: number } | null>(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;
   const fps = asset?.fps ?? 30;
 
 
@@ -70,7 +85,7 @@ export default function ReviewPage() {
 
 
   useEffect(() => { loadData(); }, [loadData]);
   useEffect(() => { loadData(); }, [loadData]);
 
 
-  // Resize panel
+  // ── Panel resize ─────────────────────────────────────────────────────────
   const handleMouseMove = useCallback((e: MouseEvent) => {
   const handleMouseMove = useCallback((e: MouseEvent) => {
     if (!isDraggingRef.current || !resizeStartRef.current) return;
     if (!isDraggingRef.current || !resizeStartRef.current) return;
     const dx = e.clientX - resizeStartRef.current.x;
     const dx = e.clientX - resizeStartRef.current.x;
@@ -101,14 +116,15 @@ export default function ReviewPage() {
     document.body.style.cursor = 'col-resize';
     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;
     if (!token || !content.trim()) return;
     setSubmitting(true);
     setSubmitting(true);
     try {
     try {
       const { comment } = await commentsApi.create(token, assetId, {
       const { comment } = await commentsApi.create(token, assetId, {
         content: content.trim(),
         content: content.trim(),
         timestamp,
         timestamp,
-        annotation: annotation as AnnotationData | undefined,
+        annotations,
         parentId: replyTo?.id,
         parentId: replyTo?.id,
       });
       });
       if (replyTo) {
       if (replyTo) {
@@ -121,7 +137,7 @@ export default function ReviewPage() {
         setComments(prev => [...prev, comment]);
         setComments(prev => [...prev, comment]);
       }
       }
       setNewComment('');
       setNewComment('');
-      setPendingAnnotation(null);
+      setPendingStrokes([]);
       setReplyTo(null);
       setReplyTo(null);
     } catch (err) {
     } catch (err) {
       alert(err instanceof Error ? err.message : 'Failed to add comment');
       alert(err instanceof Error ? err.message : 'Failed to add comment');
@@ -145,13 +161,99 @@ export default function ReviewPage() {
     if (!confirm('Delete this comment?')) return;
     if (!confirm('Delete this comment?')) return;
     try {
     try {
       await commentsApi.delete(token, commentId);
       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 {
     } catch {
       alert('Failed to delete comment');
       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) => {
   const handleStatusUpdate = async (status: string) => {
     if (!token) return;
     if (!token) return;
     setUpdatingStatus(true);
     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);
     setCurrentTime(time);
+    const videoEl = document.querySelector('video') as HTMLVideoElement | null;
+    if (videoEl) {
+      videoEl.pause();
+      videoEl.currentTime = time;
+    }
   }, []);
   }, []);
 
 
   const status = asset?.status ?? 'PENDING_REVIEW';
   const status = asset?.status ?? 'PENDING_REVIEW';
   const statusCfg = STATUS_CONFIG[status];
   const statusCfg = STATUS_CONFIG[status];
 
 
-  // Determine video URL: prefer HLS if available
   const videoUrl = asset?.hlsPath
   const videoUrl = asset?.hlsPath
     ? `${API_BASE}/uploads${asset.hlsPath}`
     ? `${API_BASE}/uploads${asset.hlsPath}`
     : asset
     : asset
@@ -211,11 +313,7 @@ export default function ReviewPage() {
 
 
       {/* ── Top bar ──────────────────────────────────────────── */}
       {/* ── Top bar ──────────────────────────────────────────── */}
       <header className="h-12 flex items-center px-4 gap-3 shrink-0"
       <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
         <button
           onClick={() => router.push(`/projects/${asset.projectId}`)}
           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="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
 
 
         <div className="flex-1 min-w-0">
         <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>
         </div>
 
 
         <span className="text-xs hidden sm:inline shrink-0" style={{ color: 'var(--text-subtle)' }}>
         <span className="text-xs hidden sm:inline shrink-0" style={{ color: 'var(--text-subtle)' }}>
@@ -290,14 +386,18 @@ export default function ReviewPage() {
         {/* Video area */}
         {/* Video area */}
         <div className="flex-1 overflow-y-auto p-4 flex flex-col gap-3 min-w-0">
         <div className="flex-1 overflow-y-auto p-4 flex flex-col gap-3 min-w-0">
 
 
-          {/* Custom video player */}
           <VideoPlayer
           <VideoPlayer
             src={videoUrl}
             src={videoUrl}
             mimeType={asset.mimeType}
             mimeType={asset.mimeType}
             fps={fps}
             fps={fps}
             comments={allComments}
             comments={allComments}
-            pendingAnnotation={pendingAnnotation}
-            onAnnotationCreated={handleAnnotationCreated}
+            drawMode={drawMode}
+            drawTool={drawTool}
+            drawColor={drawColor}
+            onDrawModeChange={setDrawMode}
+            onDrawToolChange={setDrawTool}
+            onDrawColorChange={setDrawColor}
+            onStrokeComplete={handleStrokeComplete}
             onTimeUpdate={handleTimeUpdate}
             onTimeUpdate={handleTimeUpdate}
             onCommentClick={handleCommentSeek}
             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)' }}>
           <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)' }}>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)' }}>←</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>
             <span className="font-mono text-[11px]">{formatTimecode(currentTime, fps)}</span>
           </div>
           </div>
         </div>
         </div>
 
 
         {/* Resize handle */}
         {/* Resize handle */}
-        <div
-          className="resize-handle"
-          onMouseDown={handleResizeStart}
-          style={{ width: '4px' }}
-        />
+        <div className="resize-handle" onMouseDown={handleResizeStart} style={{ width: '4px' }} />
 
 
         {/* ── Comment panel ─────────────────────────────────── */}
         {/* ── Comment panel ─────────────────────────────────── */}
         <div
         <div
           ref={panelRef}
           ref={panelRef}
           className="flex flex-col overflow-hidden shrink-0"
           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 */}
           {/* Panel header */}
           <div className="px-4 py-3 flex items-center justify-between shrink-0"
           <div className="px-4 py-3 flex items-center justify-between shrink-0"
@@ -339,23 +432,55 @@ export default function ReviewPage() {
                 {comments.length}
                 {comments.length}
               </span>
               </span>
             </div>
             </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' }}>
               <span className="font-mono text-xs" style={{ color: '#818CF8' }}>
                 {formatTimecode(currentTime, fps)}
                 {formatTimecode(currentTime, fps)}
               </span>
               </span>
-              {/* Toggle resolved */}
               <button
               <button
                 onClick={() => setShowResolved(v => !v)}
                 onClick={() => setShowResolved(v => !v)}
                 className={`text-xs px-2 py-0.5 rounded-md transition-colors ${showResolved ? 'bg-indigo-600 text-white' : ''}`}
                 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)' } : {}}
                 style={!showResolved ? { background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' } : {}}
-                title="Toggle resolved comments"
               >
               >
                 {showResolved ? 'Hide resolved' : 'Show resolved'}
                 {showResolved ? 'Hide resolved' : 'Show resolved'}
               </button>
               </button>
             </div>
             </div>
           </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 */}
           {/* Comment list */}
           <div className="flex-1 overflow-y-auto scroll-area">
           <div className="flex-1 overflow-y-auto scroll-area">
             {visibleComments.length === 0 ? (
             {visibleComments.length === 0 ? (
@@ -368,7 +493,7 @@ export default function ReviewPage() {
                 </div>
                 </div>
                 <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>No comments yet</p>
                 <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)' }}>
                 <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>
                 </p>
               </div>
               </div>
             ) : (
             ) : (
@@ -383,6 +508,8 @@ export default function ReviewPage() {
                     onReply={() => { setReplyTo(comment); }}
                     onReply={() => { setReplyTo(comment); }}
                     onResolve={() => handleResolve(comment.id)}
                     onResolve={() => handleResolve(comment.id)}
                     onDelete={() => handleDeleteComment(comment.id)}
                     onDelete={() => handleDeleteComment(comment.id)}
+                    onAddAnnotation={() => handleAddAnnotationClick(comment)}
+                    onDeleteAnnotation={(anns) => handleDeleteAnnotation(comment.id, anns)}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>
@@ -406,24 +533,23 @@ export default function ReviewPage() {
               </div>
               </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
             <form
               onSubmit={e => {
               onSubmit={e => {
                 e.preventDefault();
                 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"
               className="flex gap-2"
@@ -440,13 +566,15 @@ export default function ReviewPage() {
                   onKeyDown={e => {
                   onKeyDown={e => {
                     if (e.key === 'Enter' && !e.shiftKey) {
                     if (e.key === 'Enter' && !e.shiftKey) {
                       e.preventDefault();
                       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
                 <button
                   type="submit"
                   type="submit"
-                  disabled={submitting || !newComment.trim()}
+                  disabled={submitting || (!newComment.trim() && pendingStrokes.length === 0)}
                   className="btn btn-primary btn-sm px-3"
                   className="btn btn-primary btn-sm px-3"
                 >
                 >
                   {submitting ? (
                   {submitting ? (
@@ -467,6 +595,7 @@ export default function ReviewPage() {
   );
   );
 }
 }
 
 
+// ── CommentItem ─────────────────────────────────────────────────────────────
 function CommentItem({
 function CommentItem({
   comment,
   comment,
   currentUserId,
   currentUserId,
@@ -475,6 +604,8 @@ function CommentItem({
   onReply,
   onReply,
   onResolve,
   onResolve,
   onDelete,
   onDelete,
+  onAddAnnotation,
+  onDeleteAnnotation,
 }: {
 }: {
   comment: Comment;
   comment: Comment;
   currentUserId: string;
   currentUserId: string;
@@ -483,10 +614,14 @@ function CommentItem({
   onReply: () => void;
   onReply: () => void;
   onResolve: () => void;
   onResolve: () => void;
   onDelete: () => void;
   onDelete: () => void;
+  onAddAnnotation: () => void;
+  onDeleteAnnotation: (annotations: AnnotationData[]) => void;
 }) {
 }) {
   const isOwner = comment.userId === currentUserId;
   const isOwner = comment.userId === currentUserId;
   const name = comment.user?.name ?? 'Unknown';
   const name = comment.user?.name ?? 'Unknown';
   const isReply = !!comment.parentId;
   const isReply = !!comment.parentId;
+  const annotations = comment.annotations ?? [];
+  const canAddMore = annotations.length < MAX_ANNOTATIONS;
 
 
   return (
   return (
     <div
     <div
@@ -497,7 +632,7 @@ function CommentItem({
         <Avatar name={name} size="sm" />
         <Avatar name={name} size="sm" />
 
 
         <div className="flex-1 min-w-0">
         <div className="flex-1 min-w-0">
-          {/* Meta */}
+          {/* Meta row */}
           <div className="flex items-center gap-2 mb-1 flex-wrap">
           <div className="flex items-center gap-2 mb-1 flex-wrap">
             <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>{name}</span>
             <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>{name}</span>
             {comment.timestamp != null && (
             {comment.timestamp != null && (
@@ -520,14 +655,35 @@ function CommentItem({
             </span>
             </span>
           </div>
           </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>
             </div>
           )}
           )}
 
 
@@ -537,44 +693,55 @@ function CommentItem({
           </p>
           </p>
 
 
           {/* Actions */}
           {/* 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
               <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"
                 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}>
                 <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>
                 </svg>
               </button>
               </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 */}
           {/* Replies */}
           {comment.replies && comment.replies.length > 0 && (
           {comment.replies && comment.replies.length > 0 && (
@@ -589,6 +756,8 @@ function CommentItem({
                   onReply={() => {}}
                   onReply={() => {}}
                   onResolve={onResolve}
                   onResolve={onResolve}
                   onDelete={onDelete}
                   onDelete={onDelete}
+                  onAddAnnotation={() => {}}
+                  onDeleteAnnotation={onDeleteAnnotation}
                 />
                 />
               ))}
               ))}
             </div>
             </div>

+ 1 - 1
src/components/comments/CommentPanel.tsx

@@ -225,7 +225,7 @@ function CommentItem({
           </div>
           </div>
 
 
           {/* Annotation preview */}
           {/* Annotation preview */}
-          {comment.annotation && (
+          {comment.annotations && (
             <div className="mt-1 text-xs text-gray-500 italic flex items-center gap-1">
             <div className="mt-1 text-xs text-gray-500 italic flex items-center gap-1">
               <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
               <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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" />
                 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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" />

+ 98 - 212
src/components/video-player/AnnotationCanvas.tsx

@@ -1,28 +1,27 @@
 'use client';
 'use client';
 
 
-import { useRef, useEffect, useCallback, useState } from 'react';
+import { useRef, useEffect, useCallback } from 'react';
 import { AnnotationData } from '../../lib/api';
 import { AnnotationData } from '../../lib/api';
 
 
 const COLORS = [
 const COLORS = [
-  { name: 'Red', value: '#ef4444' },
+  { name: 'Red',    value: '#ef4444' },
   { name: 'Orange', value: '#f97316' },
   { name: 'Orange', value: '#f97316' },
   { name: 'Yellow', value: '#eab308' },
   { name: 'Yellow', value: '#eab308' },
-  { name: 'Green', value: '#22c55e' },
-  { name: 'Blue', value: '#3b82f6' },
+  { name: 'Green',  value: '#22c55e' },
+  { name: 'Blue',   value: '#3b82f6' },
   { name: 'Purple', value: '#a855f7' },
   { name: 'Purple', value: '#a855f7' },
-  { name: 'White', value: '#ffffff' },
+  { name: 'White',  value: '#ffffff' },
 ];
 ];
 
 
-type Tool = 'pen' | 'arrow' | 'rect' | 'ellipse' | 'eraser';
+type Tool = 'pen' | 'arrow' | 'rect' | 'ellipse';
 
 
 interface Props {
 interface Props {
-  isDrawingMode: boolean;
+  isActive: boolean;
   tool: Tool;
   tool: Tool;
   color: string;
   color: string;
-  annotations: AnnotationData[];
   width: number;
   width: number;
   height: number;
   height: number;
-  onAnnotationCreated: (annotation: AnnotationData) => void;
+  onStrokeComplete: (stroke: AnnotationData) => void;
 }
 }
 
 
 interface DrawState {
 interface DrawState {
@@ -34,67 +33,27 @@ interface DrawState {
 }
 }
 
 
 export function AnnotationCanvas({
 export function AnnotationCanvas({
-  isDrawingMode,
+  isActive,
   tool,
   tool,
   color,
   color,
-  annotations,
   width,
   width,
   height,
   height,
-  onAnnotationCreated,
+  onStrokeComplete,
 }: Props) {
 }: Props) {
   const canvasRef = useRef<HTMLCanvasElement>(null);
   const canvasRef = useRef<HTMLCanvasElement>(null);
-  const [isDrawing, setIsDrawing] = useState(false);
-  const [drawState, setDrawState] = useState<DrawState | null>(null);
-  const historyRef = useRef<AnnotationData[]>([]);
+  const isDrawingRef = useRef(false);
+  const drawRef = useRef<DrawState | null>(null);
 
 
-  // Render all annotations
-  const render = useCallback(() => {
-    const canvas = canvasRef.current;
-    if (!canvas) return;
-    const ctx = canvas.getContext('2d');
-    if (!ctx) return;
-
-    ctx.clearRect(0, 0, canvas.width, canvas.height);
-
-    for (const ann of historyRef.current) {
-      drawAnnotation(ctx, ann);
-    }
-  }, []);
-
-  // Update history and re-render when annotations change
-  useEffect(() => {
-    historyRef.current = [...annotations];
-    render();
-  }, [annotations, render]);
-
-  // Re-render on resize
-  useEffect(() => {
-    const canvas = canvasRef.current;
-    if (!canvas) return;
-    canvas.width = width;
-    canvas.height = height;
-    render();
-  }, [width, height, render]);
-
-  function getPos(e: React.MouseEvent<HTMLCanvasElement>): [number, number] {
-    const canvas = canvasRef.current!;
-    const rect = canvas.getBoundingClientRect();
-    return [
-      (e.clientX - rect.left) / rect.width,
-      (e.clientY - rect.top) / rect.height,
-    ];
-  }
-
-  function drawAnnotation(ctx: CanvasRenderingContext2D, ann: AnnotationData) {
+  // ── Draw a single annotation onto ctx ──────────────────────────────────────
+  function drawShape(ctx: CanvasRenderingContext2D, ann: AnnotationData) {
+    ctx.save();
     ctx.strokeStyle = ann.color;
     ctx.strokeStyle = ann.color;
     ctx.fillStyle = ann.color;
     ctx.fillStyle = ann.color;
     ctx.lineWidth = 3;
     ctx.lineWidth = 3;
     ctx.lineCap = 'round';
     ctx.lineCap = 'round';
     ctx.lineJoin = 'round';
     ctx.lineJoin = 'round';
-    ctx.shadowBlur = 0;
 
 
-    if (ann.type === 'pen' && ann.points) {
-      if (ann.points.length < 2) return;
+    if (ann.type === 'pen' && ann.points && ann.points.length >= 2) {
       ctx.beginPath();
       ctx.beginPath();
       ctx.moveTo(ann.points[0][0] * ctx.canvas.width, ann.points[0][1] * ctx.canvas.height);
       ctx.moveTo(ann.points[0][0] * ctx.canvas.width, ann.points[0][1] * ctx.canvas.height);
       for (let i = 1; i < ann.points.length; i++) {
       for (let i = 1; i < ann.points.length; i++) {
@@ -106,17 +65,11 @@ export function AnnotationCanvas({
       const [x2, y2] = ann.points[ann.points.length - 1];
       const [x2, y2] = ann.points[ann.points.length - 1];
       const sx = x1 * ctx.canvas.width, sy = y1 * ctx.canvas.height;
       const sx = x1 * ctx.canvas.width, sy = y1 * ctx.canvas.height;
       const ex = x2 * ctx.canvas.width, ey = y2 * ctx.canvas.height;
       const ex = x2 * ctx.canvas.width, ey = y2 * ctx.canvas.height;
-
-      // Line
-      ctx.beginPath();
-      ctx.moveTo(sx, sy);
-      ctx.lineTo(ex, ey);
-      ctx.stroke();
-
-      // Arrowhead
       const angle = Math.atan2(ey - sy, ex - sx);
       const angle = Math.atan2(ey - sy, ex - sx);
       const headLen = 16;
       const headLen = 16;
       ctx.beginPath();
       ctx.beginPath();
+      ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke();
+      ctx.beginPath();
       ctx.moveTo(ex, ey);
       ctx.moveTo(ex, ey);
       ctx.lineTo(ex - headLen * Math.cos(angle - Math.PI / 6), ey - headLen * Math.sin(angle - Math.PI / 6));
       ctx.lineTo(ex - headLen * Math.cos(angle - Math.PI / 6), ey - headLen * Math.sin(angle - Math.PI / 6));
       ctx.moveTo(ex, ey);
       ctx.moveTo(ex, ey);
@@ -124,165 +77,97 @@ export function AnnotationCanvas({
       ctx.stroke();
       ctx.stroke();
     } else if (ann.type === 'rect' && ann.boundingBox) {
     } else if (ann.type === 'rect' && ann.boundingBox) {
       const { x, y, width: w, height: h } = ann.boundingBox;
       const { x, y, width: w, height: h } = ann.boundingBox;
-      ctx.strokeRect(
-        x * ctx.canvas.width,
-        y * ctx.canvas.height,
-        w * ctx.canvas.width,
-        h * ctx.canvas.height
-      );
+      ctx.strokeRect(x * ctx.canvas.width, y * ctx.canvas.height, w * ctx.canvas.width, h * ctx.canvas.height);
     } else if (ann.type === 'ellipse' && ann.boundingBox) {
     } else if (ann.type === 'ellipse' && ann.boundingBox) {
       const { x, y, width: w, height: h } = ann.boundingBox;
       const { x, y, width: w, height: h } = ann.boundingBox;
       ctx.beginPath();
       ctx.beginPath();
       ctx.ellipse(
       ctx.ellipse(
-        (x + w / 2) * ctx.canvas.width,
-        (y + h / 2) * ctx.canvas.height,
-        (w / 2) * ctx.canvas.width,
-        (h / 2) * ctx.canvas.height,
-        0, 0, 2 * Math.PI
+        (x + w / 2) * ctx.canvas.width, (y + h / 2) * ctx.canvas.height,
+        (w / 2) * ctx.canvas.width, (h / 2) * ctx.canvas.height, 0, 0, 2 * Math.PI
       );
       );
       ctx.stroke();
       ctx.stroke();
     }
     }
+    ctx.restore();
   }
   }
 
 
-  function drawLivePreview(ctx: CanvasRenderingContext2D) {
-    if (!drawState) return;
-    ctx.strokeStyle = drawState.color;
-    ctx.fillStyle = drawState.color;
-    ctx.lineWidth = 3;
-    ctx.lineCap = 'round';
-    ctx.lineJoin = 'round';
-
-    const { type, startX, startY, points } = drawState;
-    const w = ctx.canvas.width;
-    const h = ctx.canvas.height;
-
-    if (type === 'pen' && points.length >= 2) {
-      ctx.beginPath();
-      ctx.moveTo(points[0][0] * w, points[0][1] * h);
-      for (let i = 1; i < points.length; i++) {
-        ctx.lineTo(points[i][0] * w, points[i][1] * h);
-      }
-      ctx.stroke();
-    } else if (type === 'arrow' && points.length >= 2) {
-      const [x1, y1] = points[0];
-      const [x2, y2] = points[points.length - 1];
-      const sx = x1 * w, sy = y1 * h;
-      const ex = x2 * w, ey = y2 * h;
-
-      ctx.beginPath();
-      ctx.moveTo(sx, sy);
-      ctx.lineTo(ex, ey);
-      ctx.stroke();
-
-      const angle = Math.atan2(ey - sy, ex - sx);
-      const headLen = 16;
-      ctx.beginPath();
-      ctx.moveTo(ex, ey);
-      ctx.lineTo(ex - headLen * Math.cos(angle - Math.PI / 6), ey - headLen * Math.sin(angle - Math.PI / 6));
-      ctx.moveTo(ex, ey);
-      ctx.lineTo(ex - headLen * Math.cos(angle + Math.PI / 6), ey - headLen * Math.sin(angle + Math.PI / 6));
-      ctx.stroke();
-    } else if (type === 'rect' && points.length >= 2) {
-      const sx = startX * w, sy = startY * h;
-      const ex = points[points.length - 1][0] * w;
-      const ey = points[points.length - 1][1] * h;
-      ctx.strokeRect(sx, sy, ex - sx, ey - sy);
-    } else if (type === 'ellipse' && points.length >= 2) {
-      const sx = startX * w, sy = startY * h;
-      const ex = points[points.length - 1][0] * w;
-      const ey = points[points.length - 1][1] * h;
-      ctx.beginPath();
-      ctx.ellipse(
-        (sx + ex) / 2, (sy + ey) / 2,
-        Math.abs(ex - sx) / 2, Math.abs(ey - sy) / 2,
-        0, 0, 2 * Math.PI
-      );
-      ctx.stroke();
-    }
+  // ── Full canvas redraw (clear + live stroke) ─────────────────────────────────
+  function redraw() {
+    const canvas = canvasRef.current;
+    if (!canvas) return;
+    const ctx = canvas.getContext('2d');
+    if (!ctx) return;
+    ctx.clearRect(0, 0, canvas.width, canvas.height);
+    if (drawRef.current) drawShape(ctx, toAnnotation(drawRef.current));
   }
   }
 
 
-  const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
-    if (!isDrawingMode) return;
-    const [x, y] = getPos(e);
-    if (tool === 'eraser') {
-      // Erase: remove annotations near click
-      return;
+  // ── Convert draw state → AnnotationData ────────────────────────────────────
+  function toAnnotation(ds: DrawState): AnnotationData {
+    if (ds.type === 'rect') {
+      const minX = Math.min(ds.startX, ds.points[ds.points.length - 1][0]);
+      const minY = Math.min(ds.startY, ds.points[ds.points.length - 1][1]);
+      const maxX = Math.max(ds.startX, ds.points[ds.points.length - 1][0]);
+      const maxY = Math.max(ds.startY, ds.points[ds.points.length - 1][1]);
+      return { type: 'rect', color: ds.color, boundingBox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY }, points: ds.points };
     }
     }
-    setIsDrawing(true);
-    setDrawState({ type: tool, color, startX: x, startY: y, points: [[x, y]] });
-  };
+    if (ds.type === 'ellipse') {
+      const minX = Math.min(ds.startX, ds.points[ds.points.length - 1][0]);
+      const minY = Math.min(ds.startY, ds.points[ds.points.length - 1][1]);
+      const maxX = Math.max(ds.startX, ds.points[ds.points.length - 1][0]);
+      const maxY = Math.max(ds.startY, ds.points[ds.points.length - 1][1]);
+      return { type: 'ellipse', color: ds.color, boundingBox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY }, points: ds.points };
+    }
+    return { type: ds.type === 'arrow' ? 'arrow' : 'pen', color: ds.color, points: ds.points };
+  }
 
 
-  const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
-    if (!isDrawing || !drawState) return;
-    const [x, y] = getPos(e);
-    setDrawState(prev => {
-      if (!prev) return prev;
-      const points = [...prev.points, [x, y] as [number, number]];
+  // ── Canvas resize ────────────────────────────────────────────────────────────
+  useEffect(() => {
+    const canvas = canvasRef.current;
+    if (!canvas) return;
+    canvas.width = width;
+    canvas.height = height;
+    redraw();
+  }, [width, height]);
 
 
-      // Live render
-      const canvas = canvasRef.current;
-      if (!canvas) return prev;
-      const ctx = canvas.getContext('2d');
-      if (!ctx) return prev;
+  // ── Normalise mouse / touch position ────────────────────────────────────────
+  function pos(e: React.MouseEvent<HTMLCanvasElement>): [number, number] {
+    const rect = canvasRef.current!.getBoundingClientRect();
+    return [
+      (e.clientX - rect.left) / rect.width,
+      (e.clientY - rect.top) / rect.height,
+    ];
+  }
 
 
-      ctx.clearRect(0, 0, canvas.width, canvas.height);
-      for (const ann of historyRef.current) drawAnnotation(ctx, ann);
-      drawLivePreview(ctx);
-      return { ...prev, points };
-    });
+  // ── Mouse handlers ───────────────────────────────────────────────────────────
+  const onDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
+    if (!isActive) return;
+    e.preventDefault();
+    e.stopPropagation();
+    const [x, y] = pos(e);
+    isDrawingRef.current = true;
+    drawRef.current = { type: tool, color, startX: x, startY: y, points: [[x, y]] };
   };
   };
 
 
-  const handleMouseUp = (e: React.MouseEvent<HTMLCanvasElement>) => {
-    if (!isDrawing || !drawState) return;
-    setIsDrawing(false);
-
-    const [x, y] = getPos(e);
-    const allPoints = [...drawState.points, [x, y] as [number, number]];
-
-    let annotation: AnnotationData;
-
-    if (drawState.type === 'rect') {
-      const minX = Math.min(drawState.startX, x);
-      const minY = Math.min(drawState.startY, y);
-      const maxX = Math.max(drawState.startX, x);
-      const maxY = Math.max(drawState.startY, y);
-      annotation = {
-        type: 'rect',
-        color: drawState.color,
-        boundingBox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
-        points: allPoints,
-      };
-    } else if (drawState.type === 'ellipse') {
-      const minX = Math.min(drawState.startX, x);
-      const minY = Math.min(drawState.startY, y);
-      const maxX = Math.max(drawState.startX, x);
-      const maxY = Math.max(drawState.startY, y);
-      annotation = {
-        type: 'ellipse',
-        color: drawState.color,
-        boundingBox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
-        points: allPoints,
-      };
-    } else {
-      annotation = {
-        type: drawState.type === 'arrow' ? 'arrow' : 'pen',
-        color: drawState.color,
-        points: allPoints,
-      };
-    }
-
-    onAnnotationCreated(annotation);
-    setDrawState(null);
+  const onMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
+    if (!isDrawingRef.current || !drawRef.current) return;
+    e.preventDefault();
+    e.stopPropagation();
+    const [x, y] = pos(e);
+    const pts = drawRef.current.points;
+    drawRef.current = { ...drawRef.current, points: [...pts, [x, y]] };
+    redraw();
+  };
 
 
-    // Re-render final
-    const canvas = canvasRef.current;
-    if (canvas) {
-      const ctx = canvas.getContext('2d');
-      if (ctx) {
-        ctx.clearRect(0, 0, canvas.width, canvas.height);
-        for (const ann of historyRef.current) drawAnnotation(ctx, ann);
-      }
-    }
+  const onUp = (e: React.MouseEvent<HTMLCanvasElement>) => {
+    if (!isDrawingRef.current || !drawRef.current) return;
+    e.preventDefault();
+    e.stopPropagation();
+    isDrawingRef.current = false;
+    const [x, y] = pos(e);
+    const pts = [...drawRef.current.points, [x, y] as [number, number]];
+    drawRef.current = { ...drawRef.current, points: pts };
+    onStrokeComplete(toAnnotation(drawRef.current));
+    drawRef.current = null;
+    redraw();
   };
   };
 
 
   return (
   return (
@@ -290,13 +175,14 @@ export function AnnotationCanvas({
       ref={canvasRef}
       ref={canvasRef}
       className="absolute inset-0 z-10"
       className="absolute inset-0 z-10"
       style={{
       style={{
-        cursor: isDrawingMode ? 'crosshair' : 'default',
-        pointerEvents: isDrawingMode ? 'auto' : 'none',
+        cursor: isActive ? 'crosshair' : 'default',
+        pointerEvents: isActive ? 'auto' : 'none',
       }}
       }}
-      onMouseDown={handleMouseDown}
-      onMouseMove={handleMouseMove}
-      onMouseUp={handleMouseUp}
-      onMouseLeave={handleMouseUp}
+      onClick={e => { if (isActive) e.stopPropagation(); }}
+      onMouseDown={onDown}
+      onMouseMove={onMove}
+      onMouseUp={onUp}
+      onMouseLeave={onUp}
     />
     />
   );
   );
 }
 }

+ 42 - 39
src/components/video-player/Timeline.tsx

@@ -1,20 +1,24 @@
 'use client';
 'use client';
 
 
+import { useRef } from 'react';
 import { Comment } from '../../lib/api';
 import { Comment } from '../../lib/api';
 
 
 interface Props {
 interface Props {
   duration: number;
   duration: number;
   currentTime: number;
   currentTime: number;
+  fps: number;
   comments: Comment[];
   comments: Comment[];
   onSeek: (time: number) => void;
   onSeek: (time: number) => void;
   onCommentClick: (comment: Comment) => void;
   onCommentClick: (comment: Comment) => void;
 }
 }
 
 
-export function Timeline({ duration, currentTime, comments, onSeek, onCommentClick }: Props) {
+export function Timeline({ duration, currentTime, fps, comments, onSeek, onCommentClick }: Props) {
+  const trackRef = useRef<HTMLDivElement>(null);
   const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
   const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
 
 
-  const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
-    const rect = e.currentTarget.getBoundingClientRect();
+  const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
+    if (!trackRef.current) return;
+    const rect = trackRef.current.getBoundingClientRect();
     const x = e.clientX - rect.left;
     const x = e.clientX - rect.left;
     const ratio = Math.max(0, Math.min(1, x / rect.width));
     const ratio = Math.max(0, Math.min(1, x / rect.width));
     onSeek(ratio * duration);
     onSeek(ratio * duration);
@@ -22,45 +26,42 @@ export function Timeline({ duration, currentTime, comments, onSeek, onCommentCli
 
 
   return (
   return (
     <div className="relative py-2">
     <div className="relative py-2">
-      {/* Comment markers */}
-      <div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-3 pointer-events-none">
-        {comments
-          .filter(c => c.timestamp != null)
-          .map(comment => {
-            const pos = duration > 0 ? ((comment.timestamp ?? 0) / duration) * 100 : 0;
-            return (
-              <button
-                key={comment.id}
-                className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow transition-transform hover:scale-150 ${
-                  comment.resolved ? 'bg-green-500' : 'bg-blue-500'
-                }`}
-                style={{ left: `${pos}%`, pointerEvents: 'auto' }}
-                title={`${comment.user.name}: ${comment.content.slice(0, 40)}`}
-                onClick={(e) => {
-                  e.stopPropagation();
-                  onCommentClick(comment);
-                }}
-              />
-            );
-          })}
+      {/* Tick marks for comments */}
+      <div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-0 pointer-events-none">
+        {comments.map(comment => {
+          if (comment.timestamp == null) return null;
+          const pos = duration > 0 ? ((comment.timestamp) / duration) * 100 : 0;
+          return (
+            <button
+              key={comment.id}
+              className="absolute top-1/2 -translate-y-1/2 w-0.5 h-3 rounded-full transition-transform hover:h-4"
+              style={{
+                left: `${pos}%`,
+                backgroundColor: comment.resolved ? '#22c55e' : '#818CF8',
+                pointerEvents: 'auto',
+              }}
+              title={`${comment.user?.name ?? ''}: ${comment.content.slice(0, 40)}`}
+              onClick={(e) => {
+                e.stopPropagation();
+                onCommentClick(comment);
+              }}
+            />
+          );
+        })}
       </div>
       </div>
 
 
-      {/* Progress bar + click area */}
+      {/* Progress bar */}
       <div
       <div
+        ref={trackRef}
         className="relative h-1.5 bg-gray-600 rounded-full cursor-pointer group"
         className="relative h-1.5 bg-gray-600 rounded-full cursor-pointer group"
-        onClick={handleClick}
+        onClick={handleTrackClick}
       >
       >
-        {/* Buffered (optional) */}
-        <div
-          className="absolute h-full bg-gray-500 rounded-full opacity-50"
-          style={{ width: '100%' }}
-        />
         {/* Played */}
         {/* Played */}
         <div
         <div
           className="absolute h-full bg-blue-500 rounded-full transition-all"
           className="absolute h-full bg-blue-500 rounded-full transition-all"
           style={{ width: `${progress}%` }}
           style={{ width: `${progress}%` }}
         />
         />
-        {/* Scrubber handle */}
+        {/* Scrubber */}
         <div
         <div
           className="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 bg-white rounded-full shadow-lg border-2 border-blue-500 opacity-0 group-hover:opacity-100 transition-opacity"
           className="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 bg-white rounded-full shadow-lg border-2 border-blue-500 opacity-0 group-hover:opacity-100 transition-opacity"
           style={{ left: `calc(${progress}% - 7px)` }}
           style={{ left: `calc(${progress}% - 7px)` }}
@@ -68,17 +69,19 @@ export function Timeline({ duration, currentTime, comments, onSeek, onCommentCli
       </div>
       </div>
 
 
       {/* Time display */}
       {/* Time display */}
-      <div className="flex justify-between mt-1 text-xs text-gray-400">
-        <span>{formatTime(currentTime)}</span>
-        <span>{formatTime(duration)}</span>
+      <div className="flex justify-between mt-1 text-xs text-gray-400 font-mono">
+        <span>{formatTimecode(currentTime, fps)}</span>
+        <span>{formatTimecode(duration, fps)}</span>
       </div>
       </div>
     </div>
     </div>
   );
   );
 }
 }
 
 
-function formatTime(s: number): string {
-  if (!s || isNaN(s)) return '0:00';
-  const m = Math.floor(s / 60);
+function formatTimecode(s: number, fps: number = 30): string {
+  if (!s || isNaN(s)) return '00:00:00:00';
+  const h = Math.floor(s / 3600);
+  const m = Math.floor((s % 3600) / 60);
   const sec = Math.floor(s % 60);
   const sec = Math.floor(s % 60);
-  return `${m}:${sec.toString().padStart(2, '0')}`;
+  const f = Math.round(s * fps) % fps;
+  return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}:${String(f).padStart(2,'0')}`;
 }
 }

+ 77 - 47
src/components/video-player/VideoPlayer.tsx

@@ -13,9 +13,18 @@ interface Props {
   mimeType: string;
   mimeType: string;
   fps?: number;
   fps?: number;
   comments: Comment[];
   comments: Comment[];
-  pendingAnnotation: AnnotationData | null;
-  onAnnotationCreated: (annotation: AnnotationData) => void;
+  // Draw mode — controlled externally by parent
+  drawMode: boolean;
+  drawTool: Tool;
+  drawColor: string;
+  onDrawModeChange: (active: boolean) => void;
+  onDrawToolChange: (tool: Tool) => void;
+  onDrawColorChange: (color: string) => void;
+  // Called after each completed stroke (mouseUp)
+  onStrokeComplete: (stroke: AnnotationData) => void;
+  // Called when video time updates
   onTimeUpdate: (time: number) => void;
   onTimeUpdate: (time: number) => void;
+  // Called when user clicks a comment marker on timeline
   onCommentClick: (comment: Comment) => void;
   onCommentClick: (comment: Comment) => void;
 }
 }
 
 
@@ -24,8 +33,13 @@ export function VideoPlayer({
   mimeType,
   mimeType,
   fps = 30,
   fps = 30,
   comments,
   comments,
-  pendingAnnotation,
-  onAnnotationCreated,
+  drawMode,
+  drawTool,
+  drawColor,
+  onDrawModeChange,
+  onDrawToolChange,
+  onDrawColorChange,
+  onStrokeComplete,
   onTimeUpdate,
   onTimeUpdate,
   onCommentClick,
   onCommentClick,
 }: Props) {
 }: Props) {
@@ -38,9 +52,6 @@ export function VideoPlayer({
   const [muted, setMuted] = useState(false);
   const [muted, setMuted] = useState(false);
   const [playbackRate, setPlaybackRate] = useState(1);
   const [playbackRate, setPlaybackRate] = useState(1);
   const [fullscreen, setFullscreen] = useState(false);
   const [fullscreen, setFullscreen] = useState(false);
-  const [drawMode, setDrawMode] = useState(false);
-  const [tool, setTool] = useState<Tool>('pen');
-  const [color, setColor] = useState(COLORS[0].value);
   const [showControls, setShowControls] = useState(true);
   const [showControls, setShowControls] = useState(true);
   const [dims, setDims] = useState({ width: 0, height: 0 });
   const [dims, setDims] = useState({ width: 0, height: 0 });
 
 
@@ -80,22 +91,40 @@ export function VideoPlayer({
       const video = videoRef.current;
       const video = videoRef.current;
       if (!video || e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
       if (!video || e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
 
 
-      if (e.code === 'Space') { e.preventDefault(); video.paused ? video.play() : video.pause(); }
-      if (e.code === 'ArrowLeft') { e.preventDefault(); video.currentTime = Math.max(0, video.currentTime - 5); }
-      if (e.code === 'ArrowRight') { e.preventDefault(); video.currentTime = Math.min(duration, video.currentTime + 5); }
+      if (e.code === 'Space') {
+        e.preventDefault();
+        video.paused ? video.play() : video.pause();
+      }
+      if (e.code === 'ArrowLeft') {
+        e.preventDefault();
+        video.currentTime = Math.max(0, video.currentTime - 5);
+      }
+      if (e.code === 'ArrowRight') {
+        e.preventDefault();
+        video.currentTime = Math.min(duration, video.currentTime + 5);
+      }
       if (e.code === 'KeyC') {
       if (e.code === 'KeyC') {
         e.preventDefault();
         e.preventDefault();
-        if (!drawMode) videoRef.current?.pause();
-        setDrawMode(v => !v);
+        // Entering draw mode → auto-pause; exiting → no auto-play
+        if (!drawMode) {
+          video.pause();
+          onDrawModeChange(true);
+        } else {
+          onDrawModeChange(false);
+        }
       }
       }
       if (e.code === 'KeyF') { e.preventDefault(); toggleFullscreen(); }
       if (e.code === 'KeyF') { e.preventDefault(); toggleFullscreen(); }
       if (e.code === 'KeyM') { e.preventDefault(); video.muted = !video.muted; }
       if (e.code === 'KeyM') { e.preventDefault(); video.muted = !video.muted; }
       if (e.code === 'KeyU') { e.preventDefault(); stepFrame(-1); }
       if (e.code === 'KeyU') { e.preventDefault(); stepFrame(-1); }
       if (e.code === 'KeyI') { e.preventDefault(); stepFrame(1); }
       if (e.code === 'KeyI') { e.preventDefault(); stepFrame(1); }
+      if (e.code === 'Escape' && drawMode) {
+        e.preventDefault();
+        onDrawModeChange(false);
+      }
     };
     };
     window.addEventListener('keydown', handleKey);
     window.addEventListener('keydown', handleKey);
     return () => window.removeEventListener('keydown', handleKey);
     return () => window.removeEventListener('keydown', handleKey);
-  }, [duration]);
+  }, [duration, drawMode, onDrawModeChange]);
 
 
   // Auto-hide controls
   // Auto-hide controls
   const resetHideTimer = useCallback(() => {
   const resetHideTimer = useCallback(() => {
@@ -109,8 +138,7 @@ export function VideoPlayer({
   function stepFrame(dir: 1 | -1) {
   function stepFrame(dir: 1 | -1) {
     const video = videoRef.current;
     const video = videoRef.current;
     if (!video) return;
     if (!video) return;
-    // Approximate frame step: 1/fps (assume 30fps)
-    const frameTime = 1 / 30;
+    const frameTime = 1 / (fps || 30);
     video.pause();
     video.pause();
     video.currentTime = Math.max(0, Math.min(duration, video.currentTime + dir * frameTime));
     video.currentTime = Math.max(0, Math.min(duration, video.currentTime + dir * frameTime));
   }
   }
@@ -126,11 +154,11 @@ export function VideoPlayer({
     }
     }
   }
   }
 
 
-  const togglePlay = () => {
+  function togglePlay() {
     const video = videoRef.current;
     const video = videoRef.current;
     if (!video) return;
     if (!video) return;
     video.paused ? video.play() : video.pause();
     video.paused ? video.play() : video.pause();
-  };
+  }
 
 
   const handleVolume = (v: number) => {
   const handleVolume = (v: number) => {
     const video = videoRef.current;
     const video = videoRef.current;
@@ -155,11 +183,6 @@ export function VideoPlayer({
     onTimeUpdate(time);
     onTimeUpdate(time);
   };
   };
 
 
-  // Annotations visible at current time (±0.5s)
-  const visibleAnnotations = comments
-    .filter(c => c.annotation && c.timestamp != null && Math.abs(c.timestamp - currentTime) < 0.5)
-    .map(c => c.annotation as AnnotationData);
-
   return (
   return (
     <div
     <div
       ref={containerRef}
       ref={containerRef}
@@ -171,7 +194,7 @@ export function VideoPlayer({
       <video
       <video
         ref={videoRef}
         ref={videoRef}
         className="w-full block"
         className="w-full block"
-        onClick={togglePlay}
+        onClick={() => { if (!drawMode) togglePlay(); }}
         onPlay={() => setPlaying(true)}
         onPlay={() => setPlaying(true)}
         onPause={() => setPlaying(false)}
         onPause={() => setPlaying(false)}
         onTimeUpdate={() => {
         onTimeUpdate={() => {
@@ -183,19 +206,18 @@ export function VideoPlayer({
         playsInline
         playsInline
       />
       />
 
 
-      {/* Annotation Canvas */}
+      {/* Annotation drawing layer — only active when drawMode */}
       <AnnotationCanvas
       <AnnotationCanvas
-        isDrawingMode={drawMode}
-        tool={tool}
-        color={color}
-        annotations={[...visibleAnnotations, ...(pendingAnnotation ? [pendingAnnotation] : [])]}
+        isActive={drawMode}
+        tool={drawTool}
+        color={drawColor}
         width={dims.width}
         width={dims.width}
         height={dims.height}
         height={dims.height}
-        onAnnotationCreated={onAnnotationCreated}
+        onStrokeComplete={onStrokeComplete}
       />
       />
 
 
       {/* Big play button overlay */}
       {/* Big play button overlay */}
-      {!playing && (
+      {!playing && !drawMode && (
         <button
         <button
           className="absolute inset-0 flex items-center justify-center bg-black/30 z-20"
           className="absolute inset-0 flex items-center justify-center bg-black/30 z-20"
           onClick={togglePlay}
           onClick={togglePlay}
@@ -211,19 +233,19 @@ export function VideoPlayer({
       {/* Controls overlay */}
       {/* Controls overlay */}
       <div
       <div
         className={`absolute bottom-0 left-0 right-0 z-30 bg-gradient-to-t from-black/80 to-transparent pt-12 pb-3 px-4 transition-opacity duration-300 ${
         className={`absolute bottom-0 left-0 right-0 z-30 bg-gradient-to-t from-black/80 to-transparent pt-12 pb-3 px-4 transition-opacity duration-300 ${
-          showControls || !playing ? 'opacity-100' : 'opacity-0 pointer-events-none'
+          showControls || !playing || drawMode ? 'opacity-100' : 'opacity-0 pointer-events-none'
         }`}
         }`}
       >
       >
         {/* Draw toolbar */}
         {/* Draw toolbar */}
         {drawMode && (
         {drawMode && (
           <div className="flex items-center gap-2 mb-2">
           <div className="flex items-center gap-2 mb-2">
-            <span className="text-xs text-white/60 mr-1">Draw:</span>
+            <span className="text-xs text-white/60">Draw:</span>
             {(['pen', 'arrow', 'rect', 'ellipse'] as Tool[]).map(t => (
             {(['pen', 'arrow', 'rect', 'ellipse'] as Tool[]).map(t => (
               <button
               <button
                 key={t}
                 key={t}
-                onClick={() => setTool(t)}
+                onClick={() => onDrawToolChange(t)}
                 className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
                 className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
-                  tool === t ? 'bg-blue-600 text-white' : 'bg-white/20 text-white hover:bg-white/30'
+                  drawTool === t ? 'bg-blue-600 text-white' : 'bg-white/20 text-white hover:bg-white/30'
                 }`}
                 }`}
               >
               >
                 {t.charAt(0).toUpperCase() + t.slice(1)}
                 {t.charAt(0).toUpperCase() + t.slice(1)}
@@ -234,16 +256,17 @@ export function VideoPlayer({
               <button
               <button
                 key={c.value}
                 key={c.value}
                 className={`w-5 h-5 rounded-full border-2 transition-transform hover:scale-125 ${
                 className={`w-5 h-5 rounded-full border-2 transition-transform hover:scale-125 ${
-                  color === c.value ? 'border-white scale-125' : 'border-transparent'
+                  drawColor === c.value ? 'border-white scale-125' : 'border-transparent'
                 }`}
                 }`}
                 style={{ backgroundColor: c.value }}
                 style={{ backgroundColor: c.value }}
-                onClick={() => setColor(c.value)}
+                onClick={() => onDrawColorChange(c.value)}
                 title={c.name}
                 title={c.name}
               />
               />
             ))}
             ))}
+            <div className="w-px h-5 bg-white/30 mx-1" />
             <button
             <button
-              onClick={() => setDrawMode(false)}
-              className="ml-2 text-xs text-white/60 hover:text-white"
+              onClick={() => onDrawModeChange(false)}
+              className="ml-1 text-xs text-white/60 hover:text-white px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors"
             >
             >
               Done
               Done
             </button>
             </button>
@@ -254,6 +277,7 @@ export function VideoPlayer({
         <Timeline
         <Timeline
           duration={duration}
           duration={duration}
           currentTime={currentTime}
           currentTime={currentTime}
+          fps={fps}
           comments={comments}
           comments={comments}
           onSeek={handleSeek}
           onSeek={handleSeek}
           onCommentClick={onCommentClick}
           onCommentClick={onCommentClick}
@@ -262,7 +286,11 @@ export function VideoPlayer({
         {/* Bottom controls */}
         {/* Bottom controls */}
         <div className="flex items-center gap-2 mt-1">
         <div className="flex items-center gap-2 mt-1">
           {/* Play/Pause */}
           {/* Play/Pause */}
-          <button onClick={togglePlay} className="text-white hover:text-blue-300 transition-colors">
+          <button
+            onClick={togglePlay}
+            className="text-white hover:text-blue-300 transition-colors"
+            disabled={drawMode}
+          >
             {playing ? (
             {playing ? (
               <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
               <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
                 <path d="M6 4h4v16H6zM14 4h4v16h-4z" />
                 <path d="M6 4h4v16H6zM14 4h4v16h-4z" />
@@ -292,15 +320,13 @@ export function VideoPlayer({
             )}
             )}
           </button>
           </button>
           <input
           <input
-            type="range"
-            min={0}
-            max={1}
-            step={0.05}
+            type="range" min={0} max={1} step={0.05}
             value={muted ? 0 : volume}
             value={muted ? 0 : volume}
             onChange={e => handleVolume(parseFloat(e.target.value))}
             onChange={e => handleVolume(parseFloat(e.target.value))}
             className="w-16 h-1 accent-blue-500"
             className="w-16 h-1 accent-blue-500"
           />
           />
 
 
+          {/* Timecode */}
           <span className="text-xs text-white/60 ml-1 font-mono">
           <span className="text-xs text-white/60 ml-1 font-mono">
             {formatTimecode(currentTime, fps)} / {formatTimecode(duration, fps)}
             {formatTimecode(currentTime, fps)} / {formatTimecode(duration, fps)}
           </span>
           </span>
@@ -318,11 +344,15 @@ export function VideoPlayer({
             ))}
             ))}
           </select>
           </select>
 
 
-          {/* Draw mode */}
+          {/* Draw mode toggle */}
           <button
           <button
             onClick={() => {
             onClick={() => {
-              if (!drawMode) videoRef.current?.pause();
-              setDrawMode(v => !v);
+              if (!drawMode) {
+                videoRef.current?.pause();
+                onDrawModeChange(true);
+              } else {
+                onDrawModeChange(false);
+              }
             }}
             }}
             className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
             className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
               drawMode ? 'bg-blue-600 text-white' : 'bg-white/20 text-white hover:bg-white/30'
               drawMode ? 'bg-blue-600 text-white' : 'bg-white/20 text-white hover:bg-white/30'
@@ -355,6 +385,6 @@ function formatTimecode(s: number, fps: number = 30): string {
   const h = Math.floor(s / 3600);
   const h = Math.floor(s / 3600);
   const m = Math.floor((s % 3600) / 60);
   const m = Math.floor((s % 3600) / 60);
   const sec = Math.floor(s % 60);
   const sec = Math.floor(s % 60);
-  const f = Math.floor((s % 1) * fps);
+  const f = Math.round(s * fps) % fps;
   return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}:${String(f).padStart(2,'0')}`;
   return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}:${String(f).padStart(2,'0')}`;
 }
 }

+ 9 - 2
src/lib/api.ts

@@ -127,7 +127,7 @@ export const commentsApi = {
   create: (token: string, assetId: string, data: {
   create: (token: string, assetId: string, data: {
     content: string;
     content: string;
     timestamp?: number;
     timestamp?: number;
-    annotation?: AnnotationData;
+    annotations?: AnnotationData[];
     parentId?: string;
     parentId?: string;
   }) =>
   }) =>
     apiFetch<{ comment: Comment }>(`/api/assets/${assetId}/comments`, {
     apiFetch<{ comment: Comment }>(`/api/assets/${assetId}/comments`, {
@@ -139,6 +139,13 @@ export const commentsApi = {
   resolve: (token: string, id: string) =>
   resolve: (token: string, id: string) =>
     apiFetch<{ comment: Comment }>(`/api/comments/${id}/resolve`, { method: 'PUT', token }),
     apiFetch<{ comment: Comment }>(`/api/comments/${id}/resolve`, { method: 'PUT', token }),
 
 
+  updateAnnotations: (token: string, id: string, annotations: AnnotationData[]) =>
+    apiFetch<{ comment: Comment }>(`/api/comments/${id}/annotations`, {
+      method: 'PUT',
+      body: JSON.stringify({ annotations }),
+      token,
+    }),
+
   delete: (token: string, id: string) =>
   delete: (token: string, id: string) =>
     apiFetch(`/api/comments/${id}`, { method: 'DELETE', token }),
     apiFetch(`/api/comments/${id}`, { method: 'DELETE', token }),
 };
 };
@@ -237,7 +244,7 @@ export interface Comment {
   userId: string;
   userId: string;
   content: string;
   content: string;
   timestamp?: number | null;
   timestamp?: number | null;
-  annotation?: AnnotationData | null;
+  annotations?: AnnotationData[] | null;
   resolved: boolean;
   resolved: boolean;
   parentId?: string | null;
   parentId?: string | null;
   createdAt: string;
   createdAt: string;