'use client'; import { memo, useState, type ReactNode, type RefObject } from 'react'; import { ArrowUpRight, CheckCircle2, ChevronDown, Circle, Clock, Download, FileText, FolderOpen, Image as ImageIcon, Loader2, MessageSquare, Mic, MoreVertical, Pause, Pencil, Play, Reply, Tag, Trash2, X, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; import { MentionTextarea } from '@/components/video-page/mention-textarea'; import { CommentRichText } from '@/components/video-page/comment-rich-text'; import { CommentImageGallery, ImageAttachmentStrip, } from '@/components/video-page/image-attachments'; import type { ImageAttachTarget } from '@/components/video-page/hooks/use-comment-actions'; import { MAX_COMMENT_IMAGES } from '@/lib/comment-images'; import type { Comment, CommentReply, CommentTag, Version, VideoAsset, } from '@/components/video-page/types'; interface CommentsPaneProps { isMobileCommentsOpen: boolean; setIsMobileCommentsOpen: (open: boolean) => void; isFullscreenMode: boolean; showComments: boolean; comments: Comment[]; filteredComments: Comment[]; sortedComments: Comment[]; showResolved: boolean; handleToggleShowResolved: () => void; activeVersion: Version | undefined; isGuest: boolean; isExportingCsv: boolean; isExportingPdf: boolean; handleExportComments: (format: 'csv' | 'pdf') => void; canResolveComments: boolean; handleResolveComment: (commentId: string, currentlyResolved: boolean) => void; handleSeekToTimestamp: ( timestamp: number, annotation?: string | null, options?: { pauseAfterSeek?: boolean; timestampEnd?: number | null } ) => void; currentUserId: string | null; projectOwnerId: string; editingCommentId: string | null; startEditingComment: (comment: Comment) => void; startEditingReply: (reply: CommentReply) => void; cancelEditingComment: () => void; editText: string; setEditText: (value: string) => void; editTagId: string | null | undefined; setEditTagId: (value: string | null | undefined) => void; editImageUrls: string[]; editImageFiles: File[]; editImageInputRef: RefObject; removeEditImageUrl: (url: string) => void; onStartEditAnnotation: () => void; isSubmittingEdit: boolean; availableTags: CommentTag[]; handleEditComment: (commentId: string) => void; handleDeleteComment: (commentId: string) => void; playVoice: (commentId: string, voiceUrl: string, knownDuration?: number) => void; downloadVoice: (commentId: string, voiceUrl: string, baseName: string) => void; downloadingVoiceIds: ReadonlySet; /** Same gate as the video and asset downloads: a project or share link with * downloads disabled must not offer to save voice notes either. */ canDownloadVoiceNotes: boolean; playingVoiceId: string | null; voiceProgress: number; voiceCurrentTime: number; voicePlaybackRate: number; toggleVoiceSpeed: () => void; formatTime: (seconds: number) => string; setPreviewImage: (url: string | null) => void; replyingTo: string | null; setReplyingTo: (id: string | null) => void; replyText: string; setReplyText: (value: string) => void; replyRangeStart: number | null; replyRangeEnd: number | null; toggleReplyRangeSelection: () => void; clearReplyRangeSelection: () => void; handleReplyComment: ( parentId: string, voiceData?: { url: string; duration: number }, imageUrls?: string[] ) => void; startReplyRecording: () => void; isReplyRecording: boolean; replyRecordingTime: number; stopReplyRecording: () => void; cancelReplyRecording: () => void; replyAudioBlob: Blob | null; replyImageFiles: File[]; replyImageInputRef: RefObject; removeImageFile: (index: number, target: ImageAttachTarget) => void; handleImageSelect: (e: React.ChangeEvent, target?: ImageAttachTarget) => void; handlePaste: (e: React.ClipboardEvent, target?: ImageAttachTarget) => void; handleDrop: (e: React.DragEvent, target?: ImageAttachTarget) => void; submitReplyWithMedia: (parentId: string) => void; isSubmittingReply: boolean; isUploadingReplyAudio: boolean; isUploadingReplyImage: boolean; composer: ReactNode; assets: VideoAsset[]; onAssetMentionClick: (assetId: string) => void; activePane: 'comments' | 'assets'; setActivePane: (pane: 'comments' | 'assets') => void; assetsPane: ReactNode; } /** * Names the download after the reviewer and the frame they were talking about, * so a folder of voice notes still makes sense next to the cut. */ function voiceNoteFileName( entry: { timestamp: number; author: { name: string | null } | null; guestName: string | null }, formatTime: (seconds: number) => string ): string { const who = entry.author?.name || entry.guestName || 'guest'; return `voice-${who}-${formatTime(entry.timestamp).replace(/:/g, '-')}`; } export const CommentsPane = memo(function CommentsPane({ isMobileCommentsOpen, setIsMobileCommentsOpen, isFullscreenMode, showComments, comments, filteredComments, sortedComments, showResolved, handleToggleShowResolved, activeVersion, isGuest, isExportingCsv, isExportingPdf, handleExportComments, canResolveComments, handleResolveComment, handleSeekToTimestamp, currentUserId, projectOwnerId, editingCommentId, startEditingComment, startEditingReply, cancelEditingComment, editText, setEditText, editTagId, setEditTagId, editImageUrls, editImageFiles, editImageInputRef, removeEditImageUrl, onStartEditAnnotation, isSubmittingEdit, availableTags, handleEditComment, handleDeleteComment, playVoice, downloadVoice, downloadingVoiceIds, canDownloadVoiceNotes, playingVoiceId, voiceProgress, voiceCurrentTime, voicePlaybackRate, toggleVoiceSpeed, formatTime, setPreviewImage, replyingTo, setReplyingTo, replyText, setReplyText, replyRangeStart, replyRangeEnd, toggleReplyRangeSelection, clearReplyRangeSelection, handleReplyComment, startReplyRecording, isReplyRecording, replyRecordingTime, stopReplyRecording, cancelReplyRecording, replyAudioBlob, replyImageFiles, replyImageInputRef, removeImageFile, handleImageSelect, handlePaste, handleDrop, submitReplyWithMedia, isSubmittingReply, isUploadingReplyAudio, isUploadingReplyImage, composer, assets, onAssetMentionClick, activePane, setActivePane, assetsPane, }: CommentsPaneProps) { const [isPaneDraggingOver, setIsPaneDraggingOver] = useState(false); const formatCommentRange = (timestamp: number, timestampEnd: number | null) => { if (timestampEnd === null) return formatTime(timestamp); return `${formatTime(timestamp)} - ${formatTime(timestampEnd)}`; }; const replyRangeButtonLabel = replyRangeStart === null || replyRangeEnd !== null ? 'Set In' : 'Set Out'; const replyRangeLabel = replyRangeStart !== null ? replyRangeEnd !== null ? `${formatTime(replyRangeStart)} - ${formatTime(replyRangeEnd)}` : `In ${formatTime(replyRangeStart)}` : null; return ( <>
setIsMobileCommentsOpen(false)} />
{ if (activePane !== 'comments') return; e.preventDefault(); setIsPaneDraggingOver(true); }} onDragEnter={(e) => { if (activePane !== 'comments') return; e.preventDefault(); setIsPaneDraggingOver(true); }} onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsPaneDraggingOver(false); }} onDrop={(e) => { setIsPaneDraggingOver(false); if (activePane !== 'comments') return; handleDrop( e, editingCommentId !== null ? 'edit' : replyingTo !== null ? 'reply' : 'comment' ); }} > {isPaneDraggingOver && (

Drop images to attach

)}
{activePane === 'comments' && (
{ e.stopPropagation(); handleExportComments('csv'); }} title={ isGuest ? 'CSV export requires an authenticated account' : 'Download comments as CSV' } > Download CSV { e.stopPropagation(); handleExportComments('pdf'); }} title="Download comments as PDF" > Download PDF
)}
{assetsPane}
{filteredComments.length === 0 ? (

No comments yet

Be the first to leave feedback!

) : ( sortedComments.map((comment) => { const authorName = comment.author?.name || comment.guestName || 'Anonymous'; const isEditing = editingCommentId === comment.id; const isReplying = replyingTo === comment.id; const canEditComment = comment.canEdit ?? comment.author?.id === currentUserId; const canDeleteComment = comment.canDelete ?? (comment.author?.id === currentUserId || projectOwnerId === currentUserId); const canManageComment = canEditComment || canDeleteComment; return (
{authorName.charAt(0)} {authorName}
{canResolveComments && ( )} {canManageComment && ( { clearReplyRangeSelection(); setReplyingTo(comment.id); setReplyText(''); }} > Reply {canEditComment && ( startEditingComment(comment)}> Edit )} {canDeleteComment && ( handleDeleteComment(comment.id)} > Delete )} )}
{isEditing ? (
{ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { handleEditComment(comment.id); } if (e.key === 'Escape') { cancelEditingComment(); } }} onPaste={(e) => handlePaste(e, 'edit')} /> removeImageFile(index, 'edit')} compact />
handleImageSelect(e, 'edit')} /> {availableTags.length > 0 && ( setEditTagId(null)} className="gap-2" > No Tag {!editTagId && } {availableTags.map((tag) => ( setEditTagId(tag.id)} className="gap-2" > {tag.name} {editTagId === tag.id && } ))} )}
) : (
{comment.content && (

)}
)} {comment.voiceUrl && (
{playingVoiceId === comment.id ? `${formatTime(voiceCurrentTime)} / ${formatTime(comment.voiceDuration || 0)}` : formatTime(comment.voiceDuration || 0)} {playingVoiceId === comment.id && ( )} {canDownloadVoiceNotes && ( )}
)}

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

{comment.annotationData && ( Annotated )} {comment.tag && ( {comment.tag.name} )}
{comment.replies && comment.replies.length > 0 && (
{comment.replies.map((reply) => { const replyAuthor = reply.author?.name || reply.guestName || 'Anonymous'; const isEditingReply = editingCommentId === reply.id; const canEditReply = reply.canEdit ?? reply.author?.id === currentUserId; const canDeleteReply = reply.canDelete ?? (reply.author?.id === currentUserId || projectOwnerId === currentUserId); const canManageReply = canEditReply || canDeleteReply; return (
{replyAuthor.charAt(0)} {replyAuthor} {new Date(reply.createdAt).toLocaleDateString()}
{canManageReply && ( {canEditReply && ( startEditingReply(reply)}> Edit )} {canDeleteReply && ( handleDeleteComment(reply.id)} > Delete )} )}
{isEditingReply ? (
{ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { handleEditComment(reply.id); } if (e.key === 'Escape') { cancelEditingComment(); } }} onPaste={(e) => handlePaste(e, 'edit')} /> removeImageFile(index, 'edit')} compact />
handleImageSelect(e, 'edit')} />
) : (
{reply.content && (

)}
)} {reply.voiceUrl && (
{playingVoiceId === reply.id ? `${formatTime(voiceCurrentTime)} / ${formatTime(reply.voiceDuration || 0)}` : formatTime(reply.voiceDuration || 0)} {playingVoiceId === reply.id && ( )} {canDownloadVoiceNotes && ( )}
)}
); })}
)} {isReplying && (
{isReplyRecording ? (
{formatTime(replyRecordingTime)}
) : replyAudioBlob ? (
{playingVoiceId === 'reply-preview' ? `${formatTime(voiceCurrentTime)} / ${formatTime(replyRecordingTime)}` : formatTime(replyRecordingTime)} {playingVoiceId === 'reply-preview' && ( )}
removeImageFile(index, 'reply')} compact />
{replyRangeLabel && ( {replyRangeLabel} )} {replyRangeStart !== null && ( )}
) : ( <> removeImageFile(index, 'reply')} compact />
{ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { handleReplyComment(comment.id); } if (e.key === 'Escape') { clearReplyRangeSelection(); setReplyingTo(null); setReplyText(''); } }} onPaste={(e) => handlePaste(e, 'reply')} /> handleImageSelect(e, 'reply')} />
{replyRangeLabel && ( {replyRangeLabel} )} {replyRangeStart !== null && ( )}
)}
)} {!isReplying && !isEditing && ( )}
); }) )}
{activePane === 'comments' ? composer : null}
); });