diff --git a/app/api/comments/[commentId]/route.ts b/app/api/comments/[commentId]/route.ts index df27073..01e3225 100644 --- a/app/api/comments/[commentId]/route.ts +++ b/app/api/comments/[commentId]/route.ts @@ -176,10 +176,10 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { } const body = await request.json(); - const { content, isResolved, tagId } = body; + const { content, isResolved, tagId, annotationData } = body; // Only author can edit content or tag - if ((content !== undefined || tagId !== undefined) && !isAuthor) { + if ((content !== undefined || tagId !== undefined || annotationData !== undefined) && !isAuthor) { return apiErrors.forbidden('Only the author can edit comment content'); } @@ -191,6 +191,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const updateData: Record = {}; if (content !== undefined && typeof content === 'string') updateData.content = content.trim(); if (tagId !== undefined) updateData.tagId = tagId; + if (annotationData !== undefined) updateData.annotationData = annotationData; if (isResolved !== undefined) { updateData.isResolved = isResolved; updateData.resolvedAt = isResolved ? new Date() : null; diff --git a/app/api/projects/[projectId]/videos/[videoId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/route.ts index d7ffa6e..7556502 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/route.ts @@ -44,6 +44,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { voiceUrl: true, voiceDuration: true, imageUrl: true, + annotationData: true, parentId: true, authorId: true, tagId: true, @@ -67,6 +68,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { voiceUrl: true, voiceDuration: true, imageUrl: true, + annotationData: true, parentId: true, authorId: true, tagId: true, diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts index feb201f..cd65227 100644 --- a/app/api/versions/[versionId]/comments/route.ts +++ b/app/api/versions/[versionId]/comments/route.ts @@ -83,6 +83,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { voiceUrl: true, voiceDuration: true, imageUrl: true, + annotationData: true, parentId: true, authorId: true, tagId: true, @@ -104,6 +105,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { voiceUrl: true, voiceDuration: true, imageUrl: true, + annotationData: true, parentId: true, authorId: true, tagId: true, @@ -184,7 +186,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { } const body = await request.json(); - const { content, timestamp, timestampEnd, parentId, voiceUrl, voiceDuration, guestName, guestEmail, tagId, imageUrl } = body; + const { content, timestamp, timestampEnd, parentId, voiceUrl, voiceDuration, guestName, guestEmail, tagId, imageUrl, annotationData } = body; // Validate required fields if (timestamp === undefined || timestamp === null) { @@ -196,8 +198,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return apiErrors.badRequest('Timestamp must be a valid number'); } - if (!content && !voiceUrl && !imageUrl) { - return apiErrors.badRequest('Either content, a voice recording, or an image attachment is required'); + if (!content && !voiceUrl && !imageUrl && !annotationData) { + return apiErrors.badRequest('Either content, a voice recording, an image attachment, or an annotation is required'); } // If replying, verify parent exists in same version @@ -240,6 +242,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { voiceUrl: voiceUrl || null, voiceDuration: voiceDuration || null, imageUrl: imageUrl || null, + annotationData: annotationData || null, authorId: session?.user?.id || null, guestName: isGuest ? guestName : null, guestEmail: isGuest ? guestEmail : null, diff --git a/components/annotation-canvas.tsx b/components/annotation-canvas.tsx new file mode 100644 index 0000000..6ee3cd6 --- /dev/null +++ b/components/annotation-canvas.tsx @@ -0,0 +1,248 @@ +'use client'; + +import { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react'; +import { Button } from '@/components/ui/button'; +import { Undo2, Trash2, Minus, Plus } from 'lucide-react'; + +export interface AnnotationStroke { + points: { x: number; y: number }[]; + color: string; + width: number; +} + +export interface AnnotationCanvasHandle { + getStrokes: () => AnnotationStroke[]; +} + +interface AnnotationCanvasProps { + mode: 'draw' | 'view'; + strokes?: AnnotationStroke[]; + onConfirm?: (strokes: AnnotationStroke[]) => void; + onCancel?: () => void; + onDismiss?: () => void; // For view mode, close overlay +} + +const COLORS = ['#FF3B30', '#FF9500', '#FFCC00', '#34C759', '#007AFF', '#AF52DE', '#FFFFFF']; +const DEFAULT_COLOR = '#FF3B30'; +const DEFAULT_WIDTH = 3; +const MIN_WIDTH = 1; +const MAX_WIDTH = 10; + +// Reference canvas width for stroke scaling +const REF_WIDTH = 1000; + +export const AnnotationCanvas = forwardRef(function AnnotationCanvas({ mode, strokes: initialStrokes, onConfirm, onCancel, onDismiss }, ref) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [strokes, setStrokes] = useState(initialStrokes || []); + const [currentStroke, setCurrentStroke] = useState(null); + const [color, setColor] = useState(DEFAULT_COLOR); + const [width, setWidth] = useState(DEFAULT_WIDTH); + const isDrawingRef = useRef(false); + + // Expose getStrokes so parent can grab current drawing without confirm + useImperativeHandle(ref, () => ({ + getStrokes: () => strokes, + }), [strokes]); + + // Render all strokes + const renderStrokes = useCallback((ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, strokeList: AnnotationStroke[], active?: AnnotationStroke | null) => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + const scale = canvas.width / REF_WIDTH; + + const draw = (s: AnnotationStroke) => { + if (s.points.length < 2) return; + ctx.strokeStyle = s.color; + ctx.lineWidth = s.width * scale; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.beginPath(); + ctx.moveTo(s.points[0].x * canvas.width, s.points[0].y * canvas.height); + for (let i = 1; i < s.points.length; i++) { + ctx.lineTo(s.points[i].x * canvas.width, s.points[i].y * canvas.height); + } + ctx.stroke(); + }; + + strokeList.forEach(draw); + if (active) draw(active); + }, []); + + // Resize canvas to match container + useEffect(() => { + const resizeCanvas = () => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + + const rect = container.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height; + + const ctx = canvas.getContext('2d'); + if (ctx) renderStrokes(ctx, canvas, strokes, currentStroke); + }; + + resizeCanvas(); + window.addEventListener('resize', resizeCanvas); + return () => window.removeEventListener('resize', resizeCanvas); + }, [strokes, currentStroke, renderStrokes]); + + // Re-render on stroke changes + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + renderStrokes(ctx, canvas, strokes, currentStroke); + }, [strokes, currentStroke, renderStrokes]); + + const getPoint = useCallback((e: React.MouseEvent | React.TouchEvent) => { + const canvas = canvasRef.current; + if (!canvas) return null; + const rect = canvas.getBoundingClientRect(); + let clientX: number, clientY: number; + if ('touches' in e) { + clientX = e.touches[0].clientX; + clientY = e.touches[0].clientY; + } else { + clientX = e.clientX; + clientY = e.clientY; + } + return { + x: (clientX - rect.left) / rect.width, + y: (clientY - rect.top) / rect.height, + }; + }, []); + + const handlePointerDown = useCallback((e: React.MouseEvent | React.TouchEvent) => { + if (mode !== 'draw') return; + e.preventDefault(); + e.stopPropagation(); + const pt = getPoint(e); + if (!pt) return; + isDrawingRef.current = true; + setCurrentStroke({ points: [pt], color, width }); + }, [mode, color, width, getPoint]); + + const handlePointerMove = useCallback((e: React.MouseEvent | React.TouchEvent) => { + if (mode !== 'draw' || !isDrawingRef.current) return; + e.preventDefault(); + e.stopPropagation(); + const pt = getPoint(e); + if (!pt) return; + setCurrentStroke(prev => { + if (!prev) return prev; + return { ...prev, points: [...prev.points, pt] }; + }); + }, [mode, getPoint]); + + const handlePointerUp = useCallback((e: React.MouseEvent | React.TouchEvent) => { + if (mode !== 'draw' || !isDrawingRef.current) return; + e.preventDefault(); + e.stopPropagation(); + isDrawingRef.current = false; + setCurrentStroke(prev => { + if (prev && prev.points.length >= 2) { + setStrokes(s => [...s, prev]); + } + return null; + }); + }, [mode]); + + const handleUndo = useCallback(() => { + setStrokes(prev => prev.slice(0, -1)); + }, []); + + const handleClear = useCallback(() => { + setStrokes([]); + }, []); + + const handleConfirm = useCallback(() => { + if (strokes.length === 0) return; + onConfirm?.(strokes); + }, [strokes, onConfirm]); + + // View mode: click to dismiss + const handleViewClick = useCallback((e: React.MouseEvent) => { + if (mode === 'view') { + e.stopPropagation(); + onDismiss?.(); + } + }, [mode, onDismiss]); + + if (mode === 'view') { + return ( +
+ +
+ ); + } + + return ( +
e.stopPropagation()} + > + + + {/* Toolbar */} +
+ {/* Colors */} + {COLORS.map(c => ( + + {width} + + +
+ + {/* Actions */} + + +
+
+ ); +}); diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index 6cf6f3d..7cc9bdb 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -76,6 +76,7 @@ import { } from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; import { parseVideoUrl, getThumbnailUrl, fetchVideoMetadata, type VideoSource } from '@/lib/video-providers'; +import { AnnotationCanvas, type AnnotationStroke, type AnnotationCanvasHandle } from '@/components/annotation-canvas'; import { Linkify } from '@/components/linkify'; interface Version { @@ -105,6 +106,7 @@ interface Comment { voiceUrl: string | null; voiceDuration: number | null; imageUrl: string | null; + annotationData: string | null; isResolved: boolean; createdAt: string; author: { id: string; name: string | null; image: string | null } | null; @@ -116,6 +118,7 @@ interface Comment { voiceUrl: string | null; voiceDuration: number | null; imageUrl: string | null; + annotationData: string | null; createdAt: string; author: { id: string; name: string | null; image: string | null } | null; guestName: string | null; @@ -236,11 +239,20 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const [editingCommentId, setEditingCommentId] = useState(null); const [editText, setEditText] = useState(''); const [editTagId, setEditTagId] = useState(null); + const [editAnnotationData, setEditAnnotationData] = useState(undefined); + const [isEditingAnnotation, setIsEditingAnnotation] = useState(false); + const editAnnotationCanvasRef = useRef(null); const [isSubmittingEdit, setIsSubmittingEdit] = useState(false); const [deletingCommentId, setDeletingCommentId] = useState(null); const isMutatingRef = useRef(false); const [previewImage, setPreviewImage] = useState(null); + // Annotation state + const [isAnnotating, setIsAnnotating] = useState(false); + const [annotationStrokes, setAnnotationStrokes] = useState(null); + const [viewingAnnotation, setViewingAnnotation] = useState(null); + const annotationCanvasRef = useRef(null); + const [guestName, setGuestName] = useState(''); const [guestNameConfirmed, setGuestNameConfirmed] = useState(mode === 'dashboard'); @@ -833,10 +845,22 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi } }, [isPlaying]); - const handleSeekToTimestamp = useCallback((timestamp: number) => { + const handleSeekToTimestamp = useCallback((timestamp: number, annotation?: string | null) => { setCurrentTime(timestamp); if (playerRef.current?.seekTo) { playerRef.current.seekTo(timestamp, true); + playerRef.current.pauseVideo(); + } + // Show annotation overlay if present + if (annotation) { + try { + const strokes = JSON.parse(annotation) as AnnotationStroke[]; + setViewingAnnotation(strokes); + } catch { + setViewingAnnotation(null); + } + } else { + setViewingAnnotation(null); } }, []); @@ -905,10 +929,20 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi }, [isDragging, currentTime, handleSeekToTimestamp]); const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }, imageData?: { url: string }) => { - if (!voiceData && !imageBlob && !commentText.trim()) return; + if (!voiceData && !imageBlob && !commentText.trim() && !annotationStrokes && !isAnnotating) return; if (!activeVersion) return; + // Auto-capture strokes from canvas if still in draw mode + let effectiveStrokes = annotationStrokes; + if (isAnnotating && annotationCanvasRef.current) { + const canvasStrokes = annotationCanvasRef.current.getStrokes(); + if (canvasStrokes.length > 0) { + effectiveStrokes = canvasStrokes; + } + } + const tempId = `temp-${Date.now()}`; + const serializedAnnotation = effectiveStrokes ? JSON.stringify(effectiveStrokes) : null; const optimisticComment: Comment = { id: tempId, content: (voiceData || imageBlob) ? commentText.trim() || null : commentText, @@ -916,6 +950,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi voiceUrl: voiceData?.url ?? null, voiceDuration: voiceData?.duration ?? null, imageUrl: imageBlob ? URL.createObjectURL(imageBlob) : null, + annotationData: serializedAnnotation, isResolved: false, createdAt: new Date().toISOString(), author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null }, @@ -941,6 +976,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi setSelectedTagId(availableTags.length > 0 ? availableTags[0].id : null); setAudioBlob(null); setImageBlob(null); + setAnnotationStrokes(null); + setIsAnnotating(false); + setViewingAnnotation(effectiveStrokes || null); setIsSubmittingComment(true); isMutatingRef.current = true; @@ -973,6 +1011,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi ...(imageData && { imageUrl: imageData.url }), ...(isGuest && guestName && { guestName }), ...(selectedTagId && { tagId: selectedTagId }), + ...(serializedAnnotation && { annotationData: serializedAnnotation }), }), }); @@ -1022,7 +1061,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi setIsUploadingImage(false); isMutatingRef.current = false; } - }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags, imageBlob]); + }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags, imageBlob, annotationStrokes, isAnnotating]); const handleImageSelect = useCallback((e: React.ChangeEvent, isReply: boolean = false) => { const file = e.target.files?.[0]; @@ -1374,6 +1413,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi voiceUrl: voiceData?.url ?? null, voiceDuration: voiceData?.duration ?? null, imageUrl: replyImageBlob ? URL.createObjectURL(replyImageBlob) : null, + annotationData: null, createdAt: new Date().toISOString(), author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null }, guestName: isGuest ? guestName : null, @@ -1614,12 +1654,23 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi }, [replyAudioBlob, replyImageBlob, activeVersion, replyRecordingTime, replyText, submitVoiceReply, handleReplyComment]); const handleEditComment = useCallback(async (commentId: string) => { - if (!editText.trim()) return; + if (!editText.trim() && !editAnnotationData) return; setIsSubmittingEdit(true); isMutatingRef.current = true; + + // Auto-capture strokes from edit canvas if still drawing + let finalAnnotationData = editAnnotationData; + if (isEditingAnnotation && editAnnotationCanvasRef.current) { + const strokes = editAnnotationCanvasRef.current.getStrokes(); + if (strokes.length > 0) { + finalAnnotationData = JSON.stringify(strokes); + } + } + try { const body: Record = { content: editText }; if (editTagId !== undefined) body.tagId = editTagId; + if (finalAnnotationData !== undefined) body.annotationData = finalAnnotationData; const res = await fetch(`/api/comments/${commentId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -1636,7 +1687,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi ? { ...v, comments: v.comments.map((c) => { - if (c.id === commentId) return { ...c, content: editText.trim(), tag: editTagId !== undefined ? editedTag : c.tag }; + if (c.id === commentId) return { ...c, content: editText.trim(), tag: editTagId !== undefined ? editedTag : c.tag, annotationData: finalAnnotationData !== undefined ? finalAnnotationData : c.annotationData }; return { ...c, replies: (c.replies || []).map((r) => @@ -1652,6 +1703,16 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi setEditingCommentId(null); setEditText(''); setEditTagId(null); + setEditAnnotationData(undefined); + setIsEditingAnnotation(false); + // Update the viewing overlay if it was showing this annotation + if (finalAnnotationData !== undefined && finalAnnotationData) { + try { + setViewingAnnotation(JSON.parse(finalAnnotationData)); + } catch { /* ignore parse errors */ } + } else if (finalAnnotationData === null) { + setViewingAnnotation(null); + } } } catch (err) { console.error('Failed to edit comment:', err); @@ -1659,7 +1720,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi setIsSubmittingEdit(false); isMutatingRef.current = false; } - }, [editText, editTagId, activeVersionId, availableTags]); + }, [editText, editTagId, editAnnotationData, isEditingAnnotation, activeVersionId, availableTags]); const handleDeleteComment = useCallback(async (commentId: string) => { setDeletingCommentId(commentId); @@ -2258,6 +2319,47 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
)} + + {/* Annotation canvas overlay – drawing mode */} + {isAnnotating && ( + { + setAnnotationStrokes(strokes); + setIsAnnotating(false); + }} + onCancel={() => { + setIsAnnotating(false); + setAnnotationStrokes(null); + }} + /> + )} + + {/* Annotation canvas overlay – viewing mode */} + {viewingAnnotation && !isAnnotating && !isEditingAnnotation && ( + setViewingAnnotation(null)} + /> + )} + + {/* Annotation canvas overlay – edit annotation mode */} + {isEditingAnnotation && ( + { try { return JSON.parse(editAnnotationData); } catch { return undefined; } })() : (() => { const c = comments.find(c => c.id === editingCommentId); if (c?.annotationData) { try { return JSON.parse(c.annotationData); } catch { return undefined; } } return undefined; })()} + onConfirm={(strokes) => { + setEditAnnotationData(JSON.stringify(strokes)); + setIsEditingAnnotation(false); + }} + onCancel={() => { + setIsEditingAnnotation(false); + }} + /> + )} @@ -2382,7 +2484,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi key={comment.id} onClick={(e) => { e.stopPropagation(); - handleSeekToTimestamp(comment.timestamp); + handleSeekToTimestamp(comment.timestamp, comment.annotationData); }} className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full transition-transform hover:scale-150 z-10" style={{ @@ -2465,7 +2567,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
+ {availableTags.length > 0 && ( @@ -2661,6 +2778,12 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi

{new Date(comment.createdAt).toLocaleDateString()}

+ {comment.annotationData && ( + + + Annotated + + )} {comment.tag && ( ) : ( <> + {(annotationStrokes || isAnnotating) && ( +
+ + Annotation attached + +
+ )} {imageBlob && (
{/* eslint-disable-next-line @next/next/no-img-element */} @@ -3160,7 +3295,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi +