Files
OpenFrame/components/video-page/comment-composer.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

406 lines
14 KiB
TypeScript

'use client';
import { memo, type RefObject } from 'react';
import Link from 'next/link';
import { Image as ImageIcon, Loader2, Mic, Pause, Pencil, Play, Send, Tag, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { AnnotationStroke } from '@/components/annotation-canvas';
import { ImageAttachmentStrip } from '@/components/video-page/image-attachments';
import { MAX_COMMENT_IMAGES } from '@/lib/comment-images';
import { MentionTextarea } from '@/components/video-page/mention-textarea';
import type { CommentTag, VideoAsset } from '@/components/video-page/types';
interface CommentComposerProps {
isRecording: boolean;
recordingTime: number;
stopRecording: () => void;
cancelRecording: () => void;
audioBlob: Blob | null;
imageFiles: File[];
imageInputRef: RefObject<HTMLInputElement | null>;
removeImageFile: (index: number) => void;
commentText: string;
setCommentText: (value: string) => void;
commentRangeStart: number | null;
commentRangeEnd: number | null;
toggleCommentRangeSelection: () => void;
clearCommentRangeSelection: () => void;
playVoice: (commentId: string, voiceUrl: string, knownDuration?: number) => void;
playingVoiceId: string | null;
voiceProgress: number;
voiceCurrentTime: number;
formatTime: (value: number) => string;
toggleVoiceSpeed: () => void;
voicePlaybackRate: number;
submitCommentWithMedia: () => void;
isUploadingAudio: boolean;
isUploadingImage: boolean;
annotationStrokes: AnnotationStroke[] | null;
isAnnotating: boolean;
setAnnotationStrokes: (strokes: AnnotationStroke[] | null) => void;
setIsAnnotating: (value: boolean) => void;
handleAddComment: () => void;
isSubmittingComment: boolean;
startRecording: () => void;
handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>) => void;
handleImageSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
availableTags: CommentTag[];
selectedTagId: string | null;
setSelectedTagId: (value: string | null) => void;
canManageTags: boolean;
projectId?: string;
pauseVideoForAnnotation: () => void;
assets: VideoAsset[];
}
export const CommentComposer = memo(function CommentComposer({
isRecording,
recordingTime,
stopRecording,
cancelRecording,
audioBlob,
imageFiles,
imageInputRef,
removeImageFile,
commentText,
setCommentText,
commentRangeStart,
commentRangeEnd,
toggleCommentRangeSelection,
clearCommentRangeSelection,
playVoice,
playingVoiceId,
voiceProgress,
voiceCurrentTime,
formatTime,
toggleVoiceSpeed,
voicePlaybackRate,
submitCommentWithMedia,
isUploadingAudio,
isUploadingImage,
annotationStrokes,
isAnnotating,
setAnnotationStrokes,
setIsAnnotating,
handleAddComment,
isSubmittingComment,
startRecording,
handlePaste,
handleImageSelect,
availableTags,
selectedTagId,
setSelectedTagId,
canManageTags,
projectId,
pauseVideoForAnnotation,
assets,
}: CommentComposerProps) {
const rangeButtonLabel =
commentRangeStart === null || commentRangeEnd !== null ? 'Set In' : 'Set Out';
const hasCommentRange = commentRangeStart !== null;
const commentRangeLabel =
commentRangeStart !== null
? commentRangeEnd !== null
? `${formatTime(commentRangeStart)} - ${formatTime(commentRangeEnd)}`
: `In ${formatTime(commentRangeStart)}`
: null;
return (
<div className="shrink-0 p-4 border-t bg-background">
{isRecording ? (
<div className="flex items-center gap-3 p-3 bg-destructive/10 border border-destructive/30 rounded-lg">
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
<span className="text-sm font-medium text-destructive">
Recording {formatTime(recordingTime)}
</span>
<div className="flex-1" />
<Button size="sm" variant="destructive" onClick={stopRecording}>
Stop
</Button>
<Button size="sm" variant="ghost" onClick={cancelRecording}>
<X className="h-4 w-4" />
</Button>
</div>
) : audioBlob ? (
<div className="space-y-2">
<div className="flex items-center gap-3 p-3 bg-muted rounded-lg">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => {
const url = URL.createObjectURL(audioBlob);
playVoice('preview', url, recordingTime);
}}
>
{playingVoiceId === 'preview' ? (
<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 === 'preview' ? `${voiceProgress}%` : '0%' }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums">
{playingVoiceId === 'preview'
? `${formatTime(voiceCurrentTime)} / ${formatTime(recordingTime)}`
: formatTime(recordingTime)}
</span>
{playingVoiceId === '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-8 w-8" onClick={cancelRecording}>
<X className="h-4 w-4" />
</Button>
</div>
<ImageAttachmentStrip files={imageFiles} onRemoveFile={removeImageFile} />
<MentionTextarea
placeholder="Add a note to your voice comment (optional)..."
value={commentText}
onChange={setCommentText}
assets={assets}
rows={1}
className="resize-none text-sm"
/>
<div className="flex items-center gap-2 flex-wrap">
<Button
size="sm"
variant={hasCommentRange ? 'default' : 'outline'}
className="h-7 text-xs"
onClick={toggleCommentRangeSelection}
>
{rangeButtonLabel}
</Button>
{commentRangeLabel && (
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground tabular-nums">
{commentRangeLabel}
</span>
)}
{hasCommentRange && (
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={clearCommentRangeSelection}
>
Clear
</Button>
)}
</div>
<Button
size="sm"
onClick={submitCommentWithMedia}
disabled={isUploadingAudio || isUploadingImage}
className="w-full"
>
{isUploadingAudio || isUploadingImage ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Uploading Media...
</>
) : (
<>
<Send className="h-4 w-4 mr-2" />
Send Voice Comment
</>
)}
</Button>
</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);
setIsAnnotating(false);
}}
>
Remove
</button>
</div>
)}
<ImageAttachmentStrip files={imageFiles} onRemoveFile={removeImageFile} />
<div className="mb-2 flex items-center gap-2 flex-wrap">
<Button
size="sm"
variant={hasCommentRange ? 'default' : 'outline'}
className="h-7 text-xs"
onClick={toggleCommentRangeSelection}
>
{rangeButtonLabel}
</Button>
{commentRangeLabel && (
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground tabular-nums">
{commentRangeLabel}
</span>
)}
{hasCommentRange && (
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={clearCommentRangeSelection}
>
Clear
</Button>
)}
</div>
<div className="flex gap-2 items-stretch">
<div className="flex-1 min-w-0">
<MentionTextarea
placeholder="Add a comment..."
value={commentText}
onChange={setCommentText}
assets={assets}
rows={6}
className="resize-none text-sm min-h-[180px] w-full"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleAddComment();
}
}}
onPaste={handlePaste}
/>
</div>
<div className="flex flex-col gap-1 self-end">
<Button
size="icon"
onClick={handleAddComment}
disabled={
(!commentText.trim() && imageFiles.length === 0 && !annotationStrokes) ||
isSubmittingComment ||
isUploadingImage
}
>
{isSubmittingComment || isUploadingImage ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
<Button
size="icon"
variant="outline"
onClick={startRecording}
title="Record voice comment"
>
<Mic className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="outline"
onClick={() => imageInputRef.current?.click()}
disabled={imageFiles.length >= MAX_COMMENT_IMAGES}
title={`Attach images (up to ${MAX_COMMENT_IMAGES})`}
>
<ImageIcon className="h-4 w-4" />
</Button>
<Button
size="icon"
variant={annotationStrokes ? 'default' : 'outline'}
className={annotationStrokes ? 'bg-violet-500 hover:bg-violet-600' : ''}
onClick={() => {
if (isAnnotating) return;
pauseVideoForAnnotation();
setIsAnnotating(true);
}}
title={
annotationStrokes
? 'Annotation added ✓ (click to redraw)'
: 'Draw annotation on video'
}
>
<Pencil className="h-4 w-4" />
</Button>
<input
type="file"
accept="image/*"
multiple
className="hidden"
ref={imageInputRef}
onChange={handleImageSelect}
/>
{availableTags.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant={selectedTagId ? 'default' : 'outline'}
title="Select tag"
style={
selectedTagId
? {
backgroundColor: availableTags.find((t) => t.id === selectedTagId)
?.color,
}
: undefined
}
>
<Tag className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{availableTags.map((tag) => (
<DropdownMenuItem
key={tag.id}
onClick={() => setSelectedTagId(tag.id)}
className="gap-2"
>
<span
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: tag.color }}
/>
{tag.name}
{selectedTagId === tag.id && <span className="ml-auto"></span>}
</DropdownMenuItem>
))}
{canManageTags && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={`/projects/${projectId}/settings#comment-tags`}
className="gap-2 text-muted-foreground"
>
<Tag className="h-3 w-3" />
Manage Tags
</Link>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
Cmd+Enter to submit &middot; paste or drop up to {MAX_COMMENT_IMAGES} images
</p>
</>
)}
</div>
);
});