Files
OpenFrame/components/video-page/comments-pane.tsx
T
yusufipek b9e2006e34 feat(comments): carry a batch of screenshots on one comment
A comment held one image, and the paste handler took the first item off the
clipboard and dropped the rest. Reviewing a cut usually means several
screenshots about the same moment, which meant one comment per screenshot or
one screenshot and a paragraph describing the others. Editing a comment could
not attach anything at all: the edit box had no paste handler, no file picker
and no way to remove what was already there.

A comment now carries up to five images, in the composer, in a reply and in
the editor. One paste stages every image on the clipboard, the file picker
takes a multiple selection, and a drop lands on whichever editor is open. Over
the cap the extras are refused out loud rather than dropped quietly. A single
image still fills the width; several tile into a grid, and either opens full
screen on click.

The images move into their own table. `comments.imageUrl` stays and follows the
first of them, so a reader that has not been updated keeps working, and the
migration copies the existing attachments across so the new table is complete
from the first read. Every path that resolves a URL back to a comment now asks
the new table: R2 cleanup, the orphan sweep, the storage accounting and the
reference checks that decide whether an object can be deleted. Left on the old
column they would have treated images two through five as unreferenced and
swept them.

Detaching an image while editing only breaks the link. The file stays in R2 and
in the assets pane, which is where it is deleted from and where its bytes are
already billed.
2026-08-20 11:01:33 +03:00

1196 lines
54 KiB
TypeScript

'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<HTMLInputElement | null>;
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;
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<HTMLInputElement | null>;
removeImageFile: (index: number, target: ImageAttachTarget) => void;
handleImageSelect: (e: React.ChangeEvent<HTMLInputElement>, target?: ImageAttachTarget) => void;
handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>, target?: ImageAttachTarget) => void;
handleDrop: (e: React.DragEvent<HTMLDivElement>, 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;
}
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,
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 (
<>
<div
className={cn(
'fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden transition-opacity duration-300',
isMobileCommentsOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
onClick={() => setIsMobileCommentsOpen(false)}
/>
<div
className={cn(
'bg-card flex flex-col overflow-hidden z-50 relative',
'fixed inset-y-0 right-0 w-[85%] sm:w-[400px] shadow-2xl transition-transform duration-300 transform',
isMobileCommentsOpen ? 'translate-x-0' : 'translate-x-full',
'lg:static lg:w-80 lg:shrink-0 lg:border-l lg:transition-none lg:translate-x-0 lg:shadow-none lg:z-auto',
isFullscreenMode && !showComments ? 'hidden' : ''
)}
onDragOver={(e) => {
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 && (
<div className="absolute inset-0 z-20 flex items-center justify-center border-2 border-dashed border-primary bg-primary/10 pointer-events-none">
<p className="text-sm font-medium text-primary">Drop images to attach</p>
</div>
)}
<div className="shrink-0 p-4 border-b lg:cursor-default space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1 min-w-0 overflow-x-auto">
<Button
variant={activePane === 'comments' ? 'default' : 'ghost'}
size="sm"
className="h-8 shrink-0"
onClick={() => setActivePane('comments')}
>
<MessageSquare className="h-4 w-4 mr-1" />
Comments
<Badge variant="secondary" className="ml-2">
{comments.length}
</Badge>
</Button>
<Button
variant={activePane === 'assets' ? 'default' : 'ghost'}
size="sm"
className="h-8 shrink-0"
onClick={() => setActivePane('assets')}
>
<FolderOpen className="h-4 w-4 mr-1" />
Assets
<Badge variant="secondary" className="ml-2">
{assets.length}
</Badge>
</Button>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 lg:hidden shrink-0"
onClick={() => setIsMobileCommentsOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
{activePane === 'comments' && (
<div className="flex w-full items-center justify-end gap-2 flex-wrap">
<Button
variant={showResolved ? 'default' : 'outline'}
size="sm"
className="h-8"
onClick={(e) => {
e.stopPropagation();
handleToggleShowResolved();
}}
>
Resolved
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-2"
disabled={!activeVersion || isExportingCsv || isExportingPdf}
aria-label="Download comments"
title="Download comments"
>
{isExportingCsv || isExportingPdf ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
<ChevronDown className="h-4 w-4 ml-0.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
disabled={!activeVersion || isGuest || isExportingCsv || isExportingPdf}
onClick={(e) => {
e.stopPropagation();
handleExportComments('csv');
}}
title={
isGuest
? 'CSV export requires an authenticated account'
: 'Download comments as CSV'
}
>
<Download className="h-4 w-4 mr-2" />
Download CSV
</DropdownMenuItem>
<DropdownMenuItem
disabled={!activeVersion || isExportingCsv || isExportingPdf}
onClick={(e) => {
e.stopPropagation();
handleExportComments('pdf');
}}
title="Download comments as PDF"
>
<FileText className="h-4 w-4 mr-2" />
Download PDF
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto">
<div
className={cn(activePane === 'assets' ? 'block p-4' : 'hidden')}
aria-hidden={activePane !== 'assets'}
>
{assetsPane}
</div>
<div
className={cn(activePane === 'comments' ? 'block p-4 space-y-3' : 'hidden')}
aria-hidden={activePane !== 'comments'}
>
{filteredComments.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No comments yet</p>
<p className="text-sm">Be the first to leave feedback!</p>
</div>
) : (
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 (
<div
key={comment.id}
className={cn(
'group rounded-lg border p-3 transition-colors hover:bg-accent/50',
comment.isResolved && 'opacity-60'
)}
>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-2 min-w-0">
<Avatar className="h-6 w-6 shrink-0">
<AvatarImage src={comment.author?.image ?? undefined} />
<AvatarFallback className="text-xs">
{authorName.charAt(0)}
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium truncate">{authorName}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() =>
handleSeekToTimestamp(comment.timestamp, comment.annotationData, {
pauseAfterSeek: true,
timestampEnd: comment.timestampEnd,
})
}
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"
>
<Clock className="h-3 w-3" />
{formatCommentRange(comment.timestamp, comment.timestampEnd)}
<ArrowUpRight className="h-3 w-3" />
</button>
{canResolveComments && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleResolveComment(comment.id, comment.isResolved)}
>
{comment.isResolved ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<Circle className="h-4 w-4" />
)}
</Button>
)}
{canManageComment && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
clearReplyRangeSelection();
setReplyingTo(comment.id);
setReplyText('');
}}
>
<Reply className="h-4 w-4 mr-2" />
Reply
</DropdownMenuItem>
{canEditComment && (
<DropdownMenuItem onClick={() => startEditingComment(comment)}>
<Pencil className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
)}
{canDeleteComment && (
<DropdownMenuItem
className="text-destructive"
onClick={() => handleDeleteComment(comment.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{isEditing ? (
<div className="mb-2">
<MentionTextarea
value={editText}
onChange={setEditText}
assets={assets}
rows={2}
className="resize-none text-sm mb-1"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleEditComment(comment.id);
}
if (e.key === 'Escape') {
cancelEditingComment();
}
}}
onPaste={(e) => handlePaste(e, 'edit')}
/>
<ImageAttachmentStrip
existingUrls={editImageUrls}
onRemoveExisting={removeEditImageUrl}
files={editImageFiles}
onRemoveFile={(index) => removeImageFile(index, 'edit')}
compact
/>
<div className="flex items-center gap-1 flex-wrap">
<Button
size="sm"
onClick={() => handleEditComment(comment.id)}
disabled={
(!editText.trim() &&
editImageUrls.length === 0 &&
editImageFiles.length === 0) ||
isSubmittingEdit
}
className="h-7 text-xs"
>
{isSubmittingEdit ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
'Save'
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={cancelEditingComment}
className="h-7 text-xs"
>
Cancel
</Button>
<Button
size="icon"
variant="outline"
className="h-7 w-7"
onClick={() => editImageInputRef.current?.click()}
disabled={
editImageUrls.length + editImageFiles.length >= MAX_COMMENT_IMAGES
}
title={`Attach images (up to ${MAX_COMMENT_IMAGES})`}
>
<ImageIcon className="h-3.5 w-3.5" />
</Button>
<input
type="file"
accept="image/*"
multiple
className="hidden"
ref={editImageInputRef}
onChange={(e) => handleImageSelect(e, 'edit')}
/>
<Button
size="icon"
variant={comment.annotationData ? 'default' : 'outline'}
className={`h-7 w-7 ${comment.annotationData ? 'bg-violet-500 hover:bg-violet-600' : ''}`}
onClick={() => {
onStartEditAnnotation();
}}
title={comment.annotationData ? 'Redraw annotation' : 'Add annotation'}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{availableTags.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant={editTagId ? 'default' : 'outline'}
className="h-7 text-xs ml-auto"
style={
editTagId
? {
backgroundColor: availableTags.find(
(t) => t.id === editTagId
)?.color,
}
: undefined
}
>
<Tag className="h-3 w-3 mr-1" />
{editTagId
? availableTags.find((t) => t.id === editTagId)?.name || 'Tag'
: 'Tag'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => setEditTagId(null)}
className="gap-2"
>
<X className="h-3 w-3" />
No Tag
{!editTagId && <span className="ml-auto"></span>}
</DropdownMenuItem>
<DropdownMenuSeparator />
{availableTags.map((tag) => (
<DropdownMenuItem
key={tag.id}
onClick={() => setEditTagId(tag.id)}
className="gap-2"
>
<span
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: tag.color }}
/>
{tag.name}
{editTagId === tag.id && <span className="ml-auto"></span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
) : (
<div className="mb-2">
{comment.content && (
<p className="text-sm mb-2 whitespace-pre-wrap break-words">
<CommentRichText
text={comment.content}
onAssetMentionClick={onAssetMentionClick}
assets={assets}
/>
</p>
)}
<CommentImageGallery
images={comment.images}
onOpen={setPreviewImage}
className="mb-2"
/>
</div>
)}
{comment.voiceUrl && (
<div className="flex items-center gap-2 p-2 bg-muted rounded mb-2">
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() =>
playVoice(comment.id, comment.voiceUrl!, comment.voiceDuration || 0)
}
>
{playingVoiceId === comment.id ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{
width: playingVoiceId === comment.id ? `${voiceProgress}%` : '0%',
}}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{playingVoiceId === comment.id
? `${formatTime(voiceCurrentTime)} / ${formatTime(comment.voiceDuration || 0)}`
: formatTime(comment.voiceDuration || 0)}
</span>
{playingVoiceId === comment.id && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
</div>
)}
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
{new Date(comment.createdAt).toLocaleDateString()}
</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 && (
<span
className="text-[10px] font-medium px-2 py-0.5 rounded-full text-white shrink-0"
style={{ backgroundColor: comment.tag.color }}
>
{comment.tag.name}
</span>
)}
</div>
{comment.replies && comment.replies.length > 0 && (
<div className="mt-3 pl-3 border-l-2 space-y-2">
{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 (
<div key={reply.id} className="group/reply text-sm">
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2">
<Avatar className="h-5 w-5">
<AvatarFallback className="text-xs">
{replyAuthor.charAt(0)}
</AvatarFallback>
</Avatar>
<span className="font-medium text-xs">{replyAuthor}</span>
<button
onClick={() =>
handleSeekToTimestamp(reply.timestamp, reply.annotationData, {
pauseAfterSeek: true,
timestampEnd: reply.timestampEnd,
})
}
className="flex items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary transition-colors hover:bg-primary/20"
title="Jump to this reply"
>
<Clock className="h-2.5 w-2.5" />
{formatCommentRange(reply.timestamp, reply.timestampEnd)}
</button>
<span className="text-xs text-muted-foreground">
{new Date(reply.createdAt).toLocaleDateString()}
</span>
</div>
{canManageReply && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0"
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canEditReply && (
<DropdownMenuItem onClick={() => startEditingReply(reply)}>
<Pencil className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
)}
{canDeleteReply && (
<DropdownMenuItem
className="text-destructive"
onClick={() => handleDeleteComment(reply.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{isEditingReply ? (
<div className="mb-1">
<MentionTextarea
value={editText}
onChange={setEditText}
assets={assets}
rows={2}
className="resize-none text-sm mb-1"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleEditComment(reply.id);
}
if (e.key === 'Escape') {
cancelEditingComment();
}
}}
onPaste={(e) => handlePaste(e, 'edit')}
/>
<ImageAttachmentStrip
existingUrls={editImageUrls}
onRemoveExisting={removeEditImageUrl}
files={editImageFiles}
onRemoveFile={(index) => removeImageFile(index, 'edit')}
compact
/>
<div className="flex gap-1">
<Button
size="sm"
onClick={() => handleEditComment(reply.id)}
disabled={
(!editText.trim() &&
editImageUrls.length === 0 &&
editImageFiles.length === 0) ||
isSubmittingEdit
}
className="h-7 text-xs"
>
{isSubmittingEdit ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
'Save'
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={cancelEditingComment}
className="h-7 text-xs"
>
Cancel
</Button>
<Button
size="icon"
variant="outline"
className="h-7 w-7"
onClick={() => editImageInputRef.current?.click()}
disabled={
editImageUrls.length + editImageFiles.length >=
MAX_COMMENT_IMAGES
}
title={`Attach images (up to ${MAX_COMMENT_IMAGES})`}
>
<ImageIcon className="h-3.5 w-3.5" />
</Button>
<input
type="file"
accept="image/*"
multiple
className="hidden"
ref={editImageInputRef}
onChange={(e) => handleImageSelect(e, 'edit')}
/>
</div>
</div>
) : (
<div className="mb-1">
{reply.content && (
<p className="text-sm whitespace-pre-wrap break-words">
<CommentRichText
text={reply.content}
onAssetMentionClick={onAssetMentionClick}
assets={assets}
/>
</p>
)}
<CommentImageGallery
images={reply.images}
onOpen={setPreviewImage}
compact
className="mt-2"
/>
</div>
)}
{reply.voiceUrl && (
<div className="flex items-center gap-2 p-1.5 bg-muted rounded mt-1">
<Button
size="icon"
variant="ghost"
className="h-6 w-6 shrink-0"
onClick={() =>
playVoice(reply.id, reply.voiceUrl!, reply.voiceDuration || 0)
}
>
{playingVoiceId === reply.id ? (
<Pause className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
<div className="flex-1 h-1.5 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{
width:
playingVoiceId === reply.id ? `${voiceProgress}%` : '0%',
}}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{playingVoiceId === reply.id
? `${formatTime(voiceCurrentTime)} / ${formatTime(reply.voiceDuration || 0)}`
: formatTime(reply.voiceDuration || 0)}
</span>
{playingVoiceId === reply.id && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
</div>
)}
</div>
);
})}
</div>
)}
{isReplying && (
<div className="mt-3 pl-3 border-l-2">
{isReplyRecording ? (
<div className="flex items-center gap-2 p-2 bg-destructive/10 border border-destructive/30 rounded-lg mb-1">
<div className="h-2 w-2 rounded-full bg-destructive animate-pulse" />
<span className="text-xs font-medium text-destructive">
{formatTime(replyRecordingTime)}
</span>
<div className="flex-1" />
<Button
size="sm"
variant="destructive"
onClick={stopReplyRecording}
className="h-6 text-xs"
>
Stop
</Button>
<Button
size="sm"
variant="ghost"
onClick={cancelReplyRecording}
className="h-6 text-xs"
>
<X className="h-3 w-3" />
</Button>
</div>
) : replyAudioBlob ? (
<div className="space-y-1 mb-1">
<div className="flex items-center gap-2 p-2 bg-muted rounded-lg">
<Button
size="icon"
variant="ghost"
className="h-6 w-6"
onClick={() => {
const url = URL.createObjectURL(replyAudioBlob);
playVoice('reply-preview', url, replyRecordingTime);
}}
>
{playingVoiceId === 'reply-preview' ? (
<Pause className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
<div className="flex-1 h-1.5 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{
width:
playingVoiceId === 'reply-preview'
? `${voiceProgress}%`
: '0%',
}}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums">
{playingVoiceId === 'reply-preview'
? `${formatTime(voiceCurrentTime)} / ${formatTime(replyRecordingTime)}`
: formatTime(replyRecordingTime)}
</span>
{playingVoiceId === 'reply-preview' && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
<Button
size="icon"
variant="ghost"
className="h-6 w-6"
onClick={cancelReplyRecording}
>
<X className="h-3 w-3" />
</Button>
</div>
<ImageAttachmentStrip
files={replyImageFiles}
onRemoveFile={(index) => removeImageFile(index, 'reply')}
compact
/>
<MentionTextarea
value={replyText}
onChange={setReplyText}
assets={assets}
placeholder="Add a note (optional)..."
rows={1}
className="resize-none text-sm"
/>
<div className="flex items-center gap-2 flex-wrap">
<Button
size="sm"
variant={replyRangeStart !== null ? 'default' : 'outline'}
className="h-7 text-xs"
onClick={toggleReplyRangeSelection}
>
{replyRangeButtonLabel}
</Button>
{replyRangeLabel && (
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground tabular-nums">
{replyRangeLabel}
</span>
)}
{replyRangeStart !== null && (
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={clearReplyRangeSelection}
>
Clear
</Button>
)}
</div>
<div className="flex gap-1 mt-2">
<Button
size="sm"
onClick={() => submitReplyWithMedia(comment.id)}
disabled={isUploadingReplyAudio || isUploadingReplyImage}
className="h-7 text-xs"
>
{isUploadingReplyAudio || isUploadingReplyImage ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
'Send Reply'
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={cancelReplyRecording}
className="h-7 text-xs"
>
Cancel
</Button>
</div>
</div>
) : (
<>
<ImageAttachmentStrip
files={replyImageFiles}
onRemoveFile={(index) => removeImageFile(index, 'reply')}
compact
/>
<div className="flex gap-1">
<MentionTextarea
value={replyText}
onChange={setReplyText}
assets={assets}
placeholder="Write a reply..."
rows={2}
className="resize-none text-sm flex-1"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleReplyComment(comment.id);
}
if (e.key === 'Escape') {
clearReplyRangeSelection();
setReplyingTo(null);
setReplyText('');
}
}}
onPaste={(e) => handlePaste(e, 'reply')}
/>
<Button
size="icon"
variant="outline"
onClick={startReplyRecording}
title="Record voice reply"
className="h-8 w-8 shrink-0 self-end"
>
<Mic className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="outline"
onClick={() => replyImageInputRef.current?.click()}
disabled={replyImageFiles.length >= MAX_COMMENT_IMAGES}
title={`Attach images (up to ${MAX_COMMENT_IMAGES})`}
className="h-8 w-8 shrink-0 self-end"
>
<ImageIcon className="h-3 w-3" />
</Button>
<input
type="file"
accept="image/*"
multiple
className="hidden"
ref={replyImageInputRef}
onChange={(e) => handleImageSelect(e, 'reply')}
/>
</div>
<div className="mt-2 flex items-center gap-2 flex-wrap">
<Button
size="sm"
variant={replyRangeStart !== null ? 'default' : 'outline'}
className="h-7 text-xs"
onClick={toggleReplyRangeSelection}
>
{replyRangeButtonLabel}
</Button>
{replyRangeLabel && (
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground tabular-nums">
{replyRangeLabel}
</span>
)}
{replyRangeStart !== null && (
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={clearReplyRangeSelection}
>
Clear
</Button>
)}
</div>
<div className="flex gap-1 mt-1">
<Button
size="sm"
onClick={() => handleReplyComment(comment.id)}
disabled={
(!replyText.trim() && replyImageFiles.length === 0) ||
isSubmittingReply ||
isUploadingReplyImage
}
className="h-7 text-xs"
>
{isSubmittingReply || isUploadingReplyImage ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
'Reply'
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
clearReplyRangeSelection();
setReplyingTo(null);
setReplyText('');
}}
className="h-7 text-xs"
>
Cancel
</Button>
</div>
</>
)}
</div>
)}
{!isReplying && !isEditing && (
<button
onClick={() => {
clearReplyRangeSelection();
setReplyingTo(comment.id);
setReplyText('');
}}
className="mt-2 text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<Reply className="h-3 w-3" />
Reply
</button>
)}
</div>
);
})
)}
</div>
</div>
{activePane === 'comments' ? composer : null}
</div>
</>
);
});