mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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:
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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/*"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user