feat: Add drawing annotation functionality to comments, including a new canvas component and API integration for creation, retrieval, and updates.

This commit is contained in:
Yusuf İpek
2026-02-21 20:23:15 +03:00
parent 4cb4089caa
commit d731c9434a
6 changed files with 424 additions and 16 deletions
+3 -2
View File
@@ -176,10 +176,10 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
} }
const body = await request.json(); const body = await request.json();
const { content, isResolved, tagId } = body; const { content, isResolved, tagId, annotationData } = body;
// Only author can edit content or tag // 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'); 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<string, unknown> = {}; const updateData: Record<string, unknown> = {};
if (content !== undefined && typeof content === 'string') updateData.content = content.trim(); if (content !== undefined && typeof content === 'string') updateData.content = content.trim();
if (tagId !== undefined) updateData.tagId = tagId; if (tagId !== undefined) updateData.tagId = tagId;
if (annotationData !== undefined) updateData.annotationData = annotationData;
if (isResolved !== undefined) { if (isResolved !== undefined) {
updateData.isResolved = isResolved; updateData.isResolved = isResolved;
updateData.resolvedAt = isResolved ? new Date() : null; updateData.resolvedAt = isResolved ? new Date() : null;
@@ -44,6 +44,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
voiceUrl: true, voiceUrl: true,
voiceDuration: true, voiceDuration: true,
imageUrl: true, imageUrl: true,
annotationData: true,
parentId: true, parentId: true,
authorId: true, authorId: true,
tagId: true, tagId: true,
@@ -67,6 +68,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
voiceUrl: true, voiceUrl: true,
voiceDuration: true, voiceDuration: true,
imageUrl: true, imageUrl: true,
annotationData: true,
parentId: true, parentId: true,
authorId: true, authorId: true,
tagId: true, tagId: true,
@@ -83,6 +83,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
voiceUrl: true, voiceUrl: true,
voiceDuration: true, voiceDuration: true,
imageUrl: true, imageUrl: true,
annotationData: true,
parentId: true, parentId: true,
authorId: true, authorId: true,
tagId: true, tagId: true,
@@ -104,6 +105,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
voiceUrl: true, voiceUrl: true,
voiceDuration: true, voiceDuration: true,
imageUrl: true, imageUrl: true,
annotationData: true,
parentId: true, parentId: true,
authorId: true, authorId: true,
tagId: true, tagId: true,
@@ -184,7 +186,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
} }
const body = await request.json(); 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 // Validate required fields
if (timestamp === undefined || timestamp === null) { 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'); return apiErrors.badRequest('Timestamp must be a valid number');
} }
if (!content && !voiceUrl && !imageUrl) { if (!content && !voiceUrl && !imageUrl && !annotationData) {
return apiErrors.badRequest('Either content, a voice recording, or an image attachment is required'); return apiErrors.badRequest('Either content, a voice recording, an image attachment, or an annotation is required');
} }
// If replying, verify parent exists in same version // If replying, verify parent exists in same version
@@ -240,6 +242,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
voiceUrl: voiceUrl || null, voiceUrl: voiceUrl || null,
voiceDuration: voiceDuration || null, voiceDuration: voiceDuration || null,
imageUrl: imageUrl || null, imageUrl: imageUrl || null,
annotationData: annotationData || null,
authorId: session?.user?.id || null, authorId: session?.user?.id || null,
guestName: isGuest ? guestName : null, guestName: isGuest ? guestName : null,
guestEmail: isGuest ? guestEmail : null, guestEmail: isGuest ? guestEmail : null,
+248
View File
@@ -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<AnnotationCanvasHandle, AnnotationCanvasProps>(function AnnotationCanvas({ mode, strokes: initialStrokes, onConfirm, onCancel, onDismiss }, ref) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [strokes, setStrokes] = useState<AnnotationStroke[]>(initialStrokes || []);
const [currentStroke, setCurrentStroke] = useState<AnnotationStroke | null>(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 (
<div
ref={containerRef}
className="absolute inset-0 z-20 cursor-pointer"
onClick={handleViewClick}
title="Click to dismiss annotation"
>
<canvas
ref={canvasRef}
className="w-full h-full pointer-events-none"
/>
</div>
);
}
return (
<div
ref={containerRef}
className="absolute inset-0 z-20"
onClick={(e) => e.stopPropagation()}
>
<canvas
ref={canvasRef}
className="w-full h-full cursor-crosshair"
onMouseDown={handlePointerDown}
onMouseMove={handlePointerMove}
onMouseUp={handlePointerUp}
onMouseLeave={handlePointerUp}
onTouchStart={handlePointerDown}
onTouchMove={handlePointerMove}
onTouchEnd={handlePointerUp}
/>
{/* Toolbar */}
<div className="absolute top-3 left-1/2 -translate-x-1/2 flex items-center gap-2 bg-background/90 backdrop-blur-sm rounded-lg px-3 py-2 shadow-lg border z-30">
{/* Colors */}
{COLORS.map(c => (
<button
key={c}
className="w-6 h-6 rounded-full border-2 transition-transform hover:scale-110 shrink-0"
style={{
backgroundColor: c,
borderColor: color === c ? 'white' : 'transparent',
boxShadow: color === c ? `0 0 0 2px ${c}` : 'none',
}}
onClick={() => setColor(c)}
/>
))}
<div className="w-px h-6 bg-border mx-1" />
{/* Brush size */}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setWidth(w => Math.max(MIN_WIDTH, w - 1))}>
<Minus className="h-3 w-3" />
</Button>
<span className="text-xs tabular-nums w-4 text-center">{width}</span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setWidth(w => Math.min(MAX_WIDTH, w + 1))}>
<Plus className="h-3 w-3" />
</Button>
<div className="w-px h-6 bg-border mx-1" />
{/* Actions */}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleUndo} disabled={strokes.length === 0} title="Undo">
<Undo2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleClear} disabled={strokes.length === 0} title="Clear all">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
});
+162 -11
View File
@@ -76,6 +76,7 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { parseVideoUrl, getThumbnailUrl, fetchVideoMetadata, type VideoSource } from '@/lib/video-providers'; 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'; import { Linkify } from '@/components/linkify';
interface Version { interface Version {
@@ -105,6 +106,7 @@ interface Comment {
voiceUrl: string | null; voiceUrl: string | null;
voiceDuration: number | null; voiceDuration: number | null;
imageUrl: string | null; imageUrl: string | null;
annotationData: string | null;
isResolved: boolean; isResolved: boolean;
createdAt: string; createdAt: string;
author: { id: string; name: string | null; image: string | null } | null; author: { id: string; name: string | null; image: string | null } | null;
@@ -116,6 +118,7 @@ interface Comment {
voiceUrl: string | null; voiceUrl: string | null;
voiceDuration: number | null; voiceDuration: number | null;
imageUrl: string | null; imageUrl: string | null;
annotationData: string | null;
createdAt: string; createdAt: string;
author: { id: string; name: string | null; image: string | null } | null; author: { id: string; name: string | null; image: string | null } | null;
guestName: string | null; guestName: string | null;
@@ -236,11 +239,20 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const [editingCommentId, setEditingCommentId] = useState<string | null>(null); const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [editText, setEditText] = useState(''); const [editText, setEditText] = useState('');
const [editTagId, setEditTagId] = useState<string | null>(null); const [editTagId, setEditTagId] = useState<string | null>(null);
const [editAnnotationData, setEditAnnotationData] = useState<string | null | undefined>(undefined);
const [isEditingAnnotation, setIsEditingAnnotation] = useState(false);
const editAnnotationCanvasRef = useRef<AnnotationCanvasHandle>(null);
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false); const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
const [deletingCommentId, setDeletingCommentId] = useState<string | null>(null); const [deletingCommentId, setDeletingCommentId] = useState<string | null>(null);
const isMutatingRef = useRef(false); const isMutatingRef = useRef(false);
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
// Annotation state
const [isAnnotating, setIsAnnotating] = useState(false);
const [annotationStrokes, setAnnotationStrokes] = useState<AnnotationStroke[] | null>(null);
const [viewingAnnotation, setViewingAnnotation] = useState<AnnotationStroke[] | null>(null);
const annotationCanvasRef = useRef<AnnotationCanvasHandle>(null);
const [guestName, setGuestName] = useState(''); const [guestName, setGuestName] = useState('');
const [guestNameConfirmed, setGuestNameConfirmed] = useState(mode === 'dashboard'); const [guestNameConfirmed, setGuestNameConfirmed] = useState(mode === 'dashboard');
@@ -833,10 +845,22 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
} }
}, [isPlaying]); }, [isPlaying]);
const handleSeekToTimestamp = useCallback((timestamp: number) => { const handleSeekToTimestamp = useCallback((timestamp: number, annotation?: string | null) => {
setCurrentTime(timestamp); setCurrentTime(timestamp);
if (playerRef.current?.seekTo) { if (playerRef.current?.seekTo) {
playerRef.current.seekTo(timestamp, true); 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]); }, [isDragging, currentTime, handleSeekToTimestamp]);
const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }, imageData?: { url: string }) => { 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; 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 tempId = `temp-${Date.now()}`;
const serializedAnnotation = effectiveStrokes ? JSON.stringify(effectiveStrokes) : null;
const optimisticComment: Comment = { const optimisticComment: Comment = {
id: tempId, id: tempId,
content: (voiceData || imageBlob) ? commentText.trim() || null : commentText, content: (voiceData || imageBlob) ? commentText.trim() || null : commentText,
@@ -916,6 +950,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
voiceUrl: voiceData?.url ?? null, voiceUrl: voiceData?.url ?? null,
voiceDuration: voiceData?.duration ?? null, voiceDuration: voiceData?.duration ?? null,
imageUrl: imageBlob ? URL.createObjectURL(imageBlob) : null, imageUrl: imageBlob ? URL.createObjectURL(imageBlob) : null,
annotationData: serializedAnnotation,
isResolved: false, isResolved: false,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null }, 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); setSelectedTagId(availableTags.length > 0 ? availableTags[0].id : null);
setAudioBlob(null); setAudioBlob(null);
setImageBlob(null); setImageBlob(null);
setAnnotationStrokes(null);
setIsAnnotating(false);
setViewingAnnotation(effectiveStrokes || null);
setIsSubmittingComment(true); setIsSubmittingComment(true);
isMutatingRef.current = true; isMutatingRef.current = true;
@@ -973,6 +1011,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
...(imageData && { imageUrl: imageData.url }), ...(imageData && { imageUrl: imageData.url }),
...(isGuest && guestName && { guestName }), ...(isGuest && guestName && { guestName }),
...(selectedTagId && { tagId: selectedTagId }), ...(selectedTagId && { tagId: selectedTagId }),
...(serializedAnnotation && { annotationData: serializedAnnotation }),
}), }),
}); });
@@ -1022,7 +1061,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setIsUploadingImage(false); setIsUploadingImage(false);
isMutatingRef.current = 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<HTMLInputElement>, isReply: boolean = false) => { const handleImageSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>, isReply: boolean = false) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -1374,6 +1413,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
voiceUrl: voiceData?.url ?? null, voiceUrl: voiceData?.url ?? null,
voiceDuration: voiceData?.duration ?? null, voiceDuration: voiceData?.duration ?? null,
imageUrl: replyImageBlob ? URL.createObjectURL(replyImageBlob) : null, imageUrl: replyImageBlob ? URL.createObjectURL(replyImageBlob) : null,
annotationData: null,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null }, author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
guestName: isGuest ? guestName : null, guestName: isGuest ? guestName : null,
@@ -1614,12 +1654,23 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}, [replyAudioBlob, replyImageBlob, activeVersion, replyRecordingTime, replyText, submitVoiceReply, handleReplyComment]); }, [replyAudioBlob, replyImageBlob, activeVersion, replyRecordingTime, replyText, submitVoiceReply, handleReplyComment]);
const handleEditComment = useCallback(async (commentId: string) => { const handleEditComment = useCallback(async (commentId: string) => {
if (!editText.trim()) return; if (!editText.trim() && !editAnnotationData) return;
setIsSubmittingEdit(true); setIsSubmittingEdit(true);
isMutatingRef.current = 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 { try {
const body: Record<string, unknown> = { content: editText }; const body: Record<string, unknown> = { content: editText };
if (editTagId !== undefined) body.tagId = editTagId; if (editTagId !== undefined) body.tagId = editTagId;
if (finalAnnotationData !== undefined) body.annotationData = finalAnnotationData;
const res = await fetch(`/api/comments/${commentId}`, { const res = await fetch(`/api/comments/${commentId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -1636,7 +1687,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
? { ? {
...v, ...v,
comments: v.comments.map((c) => { 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 { return {
...c, ...c,
replies: (c.replies || []).map((r) => replies: (c.replies || []).map((r) =>
@@ -1652,6 +1703,16 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setEditingCommentId(null); setEditingCommentId(null);
setEditText(''); setEditText('');
setEditTagId(null); 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) { } catch (err) {
console.error('Failed to edit comment:', err); console.error('Failed to edit comment:', err);
@@ -1659,7 +1720,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setIsSubmittingEdit(false); setIsSubmittingEdit(false);
isMutatingRef.current = false; isMutatingRef.current = false;
} }
}, [editText, editTagId, activeVersionId, availableTags]); }, [editText, editTagId, editAnnotationData, isEditingAnnotation, activeVersionId, availableTags]);
const handleDeleteComment = useCallback(async (commentId: string) => { const handleDeleteComment = useCallback(async (commentId: string) => {
setDeletingCommentId(commentId); setDeletingCommentId(commentId);
@@ -2258,6 +2319,47 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
</div> </div>
</div> </div>
)} )}
{/* Annotation canvas overlay drawing mode */}
{isAnnotating && (
<AnnotationCanvas
ref={annotationCanvasRef}
mode="draw"
onConfirm={(strokes) => {
setAnnotationStrokes(strokes);
setIsAnnotating(false);
}}
onCancel={() => {
setIsAnnotating(false);
setAnnotationStrokes(null);
}}
/>
)}
{/* Annotation canvas overlay viewing mode */}
{viewingAnnotation && !isAnnotating && !isEditingAnnotation && (
<AnnotationCanvas
mode="view"
strokes={viewingAnnotation}
onDismiss={() => setViewingAnnotation(null)}
/>
)}
{/* Annotation canvas overlay edit annotation mode */}
{isEditingAnnotation && (
<AnnotationCanvas
ref={editAnnotationCanvasRef}
mode="draw"
strokes={editAnnotationData ? (() => { 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);
}}
/>
)}
</div> </div>
</div> </div>
@@ -2382,7 +2484,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
key={comment.id} key={comment.id}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); 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" className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full transition-transform hover:scale-150 z-10"
style={{ style={{
@@ -2465,7 +2567,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<button <button
onClick={() => handleSeekToTimestamp(comment.timestamp)} onClick={() => handleSeekToTimestamp(comment.timestamp, comment.annotationData)}
className="flex items-center gap-1 text-xs text-primary hover:underline px-1.5 py-0.5 rounded bg-primary/10 hover:bg-primary/20 transition-colors" className="flex items-center gap-1 text-xs text-primary hover:underline px-1.5 py-0.5 rounded bg-primary/10 hover:bg-primary/20 transition-colors"
title="Jump to this timestamp" title="Jump to this timestamp"
> >
@@ -2545,10 +2647,12 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setEditingCommentId(null); setEditingCommentId(null);
setEditText(''); setEditText('');
setEditTagId(null); setEditTagId(null);
setEditAnnotationData(undefined);
setIsEditingAnnotation(false);
} }
}} }}
/> />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1 flex-wrap">
<Button <Button
size="sm" size="sm"
onClick={() => handleEditComment(comment.id)} onClick={() => handleEditComment(comment.id)}
@@ -2560,11 +2664,24 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => { setEditingCommentId(null); setEditText(''); setEditTagId(null); }} onClick={() => { setEditingCommentId(null); setEditText(''); setEditTagId(null); setEditAnnotationData(undefined); setIsEditingAnnotation(false); }}
className="h-7 text-xs" className="h-7 text-xs"
> >
Cancel Cancel
</Button> </Button>
<Button
size="icon"
variant={comment.annotationData || editAnnotationData ? 'default' : 'outline'}
className={`h-7 w-7 ${comment.annotationData || editAnnotationData ? 'bg-violet-500 hover:bg-violet-600' : ''}`}
onClick={() => {
if (playerRef.current?.pauseVideo) playerRef.current.pauseVideo();
setIsEditingAnnotation(true);
setIsAnnotating(false);
}}
title={comment.annotationData ? 'Redraw annotation' : 'Add annotation'}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{availableTags.length > 0 && ( {availableTags.length > 0 && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -2661,6 +2778,12 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{new Date(comment.createdAt).toLocaleDateString()} {new Date(comment.createdAt).toLocaleDateString()}
</p> </p>
{comment.annotationData && (
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-violet-500 text-white shrink-0 flex items-center gap-1">
<Pencil className="h-2.5 w-2.5" />
Annotated
</span>
)}
{comment.tag && ( {comment.tag && (
<span <span
className="text-[10px] font-medium px-2 py-0.5 rounded-full text-white shrink-0" className="text-[10px] font-medium px-2 py-0.5 rounded-full text-white shrink-0"
@@ -3128,6 +3251,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
</div> </div>
) : ( ) : (
<> <>
{(annotationStrokes || isAnnotating) && (
<div className="flex items-center gap-2 px-2 py-1.5 mb-2 rounded-md bg-violet-500/10 border border-violet-500/30">
<Pencil className="h-3.5 w-3.5 text-violet-500 shrink-0" />
<span className="text-xs text-violet-400 font-medium">Annotation attached</span>
<button
className="ml-auto text-xs text-muted-foreground hover:text-destructive transition-colors"
onClick={() => setAnnotationStrokes(null)}
>
Remove
</button>
</div>
)}
{imageBlob && ( {imageBlob && (
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center max-h-40 mb-2"> <div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center max-h-40 mb-2">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
@@ -3160,7 +3295,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
<Button <Button
size="icon" size="icon"
onClick={() => handleAddComment()} onClick={() => handleAddComment()}
disabled={(!commentText.trim() && !imageBlob) || isSubmittingComment || isUploadingImage} disabled={(!commentText.trim() && !imageBlob && !annotationStrokes) || isSubmittingComment || isUploadingImage}
> >
{isSubmittingComment || isUploadingImage ? ( {isSubmittingComment || isUploadingImage ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@@ -3184,6 +3319,22 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
> >
<ImageIcon className="h-4 w-4" /> <ImageIcon className="h-4 w-4" />
</Button> </Button>
<Button
size="icon"
variant={annotationStrokes ? 'default' : 'outline'}
className={annotationStrokes ? 'bg-violet-500 hover:bg-violet-600' : ''}
onClick={() => {
if (isAnnotating) return;
// Pause video when opening annotation tool
if (playerRef.current?.pauseVideo) {
playerRef.current.pauseVideo();
}
setIsAnnotating(true);
}}
title={annotationStrokes ? 'Annotation added ✓ (click to redraw)' : 'Draw annotation on video'}
>
<Pencil className="h-4 w-4" />
</Button>
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
+3
View File
@@ -266,6 +266,9 @@ model Comment {
// Image attachment (optional) // Image attachment (optional)
imageUrl String? // URL to uploaded image file imageUrl String? // URL to uploaded image file
// Annotation drawing data (JSON string of strokes)
annotationData String? @db.Text
// Threading // Threading
parentId String? parentId String?
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade) parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)