mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Merge pull request #63 from yusufipk/feat/multi-image-comment-attachments
feat(comments): carry a batch of screenshots on one comment
This commit is contained in:
@@ -436,14 +436,14 @@ export function VideoPageContent({
|
||||
recordingTime,
|
||||
audioBlob,
|
||||
isUploadingAudio,
|
||||
imageBlob,
|
||||
setImageBlob,
|
||||
imageFiles,
|
||||
commentRangeStart,
|
||||
commentRangeEnd,
|
||||
toggleCommentRangeSelection,
|
||||
clearCommentRangeSelection,
|
||||
isUploadingImage,
|
||||
imageInputRef,
|
||||
removeImageFile,
|
||||
handleAddComment,
|
||||
handleImageSelect,
|
||||
handlePaste,
|
||||
@@ -460,8 +460,7 @@ export function VideoPageContent({
|
||||
isReplyRecording,
|
||||
replyRecordingTime,
|
||||
replyAudioBlob,
|
||||
replyImageBlob,
|
||||
setReplyImageBlob,
|
||||
replyImageFiles,
|
||||
replyRangeStart,
|
||||
replyRangeEnd,
|
||||
toggleReplyRangeSelection,
|
||||
@@ -475,7 +474,6 @@ export function VideoPageContent({
|
||||
cancelReplyRecording,
|
||||
submitReplyWithMedia,
|
||||
editingCommentId,
|
||||
setEditingCommentId,
|
||||
editText,
|
||||
setEditText,
|
||||
editTagId,
|
||||
@@ -484,6 +482,13 @@ export function VideoPageContent({
|
||||
setEditAnnotationData,
|
||||
isEditingAnnotation,
|
||||
setIsEditingAnnotation,
|
||||
editImageUrls,
|
||||
editImageFiles,
|
||||
editImageInputRef,
|
||||
startEditingComment,
|
||||
startEditingReply,
|
||||
cancelEditingComment,
|
||||
removeEditImageUrl,
|
||||
isSubmittingEdit,
|
||||
handleEditComment,
|
||||
handleDeleteComment,
|
||||
@@ -853,13 +858,17 @@ export function VideoPageContent({
|
||||
currentUserId={currentUserId}
|
||||
projectOwnerId={video.project.ownerId}
|
||||
editingCommentId={editingCommentId}
|
||||
setEditingCommentId={setEditingCommentId}
|
||||
startEditingComment={startEditingComment}
|
||||
startEditingReply={startEditingReply}
|
||||
cancelEditingComment={cancelEditingComment}
|
||||
editText={editText}
|
||||
setEditText={setEditText}
|
||||
editTagId={editTagId}
|
||||
setEditTagId={setEditTagId}
|
||||
setEditAnnotationData={setEditAnnotationData}
|
||||
setIsEditingAnnotation={setIsEditingAnnotation}
|
||||
editImageUrls={editImageUrls}
|
||||
editImageFiles={editImageFiles}
|
||||
editImageInputRef={editImageInputRef}
|
||||
removeEditImageUrl={removeEditImageUrl}
|
||||
onStartEditAnnotation={commentsActions.onStartEditAnnotation}
|
||||
isSubmittingEdit={isSubmittingEdit}
|
||||
availableTags={availableTags}
|
||||
@@ -888,9 +897,9 @@ export function VideoPageContent({
|
||||
stopReplyRecording={stopReplyRecording}
|
||||
cancelReplyRecording={cancelReplyRecording}
|
||||
replyAudioBlob={replyAudioBlob}
|
||||
replyImageBlob={replyImageBlob}
|
||||
setReplyImageBlob={setReplyImageBlob}
|
||||
replyImageFiles={replyImageFiles}
|
||||
replyImageInputRef={replyImageInputRef}
|
||||
removeImageFile={removeImageFile}
|
||||
handleImageSelect={handleImageSelect}
|
||||
handlePaste={handlePaste}
|
||||
handleDrop={handleDrop}
|
||||
@@ -931,9 +940,9 @@ export function VideoPageContent({
|
||||
stopRecording={stopRecording}
|
||||
cancelRecording={cancelRecording}
|
||||
audioBlob={audioBlob}
|
||||
imageBlob={imageBlob}
|
||||
imageFiles={imageFiles}
|
||||
imageInputRef={imageInputRef}
|
||||
setImageBlob={setImageBlob}
|
||||
removeImageFile={(index) => removeImageFile(index, 'comment')}
|
||||
commentText={commentText}
|
||||
setCommentText={setCommentText}
|
||||
commentRangeStart={commentRangeStart}
|
||||
|
||||
@@ -38,7 +38,7 @@ import { AssetListSection } from '@/components/video-page/asset-list-section';
|
||||
import type { DirectUploadProvider, VideoAsset } from '@/components/video-page/types';
|
||||
import { uploadAssetVideoToR2 } from '@/lib/client/r2-asset-video-upload';
|
||||
import {
|
||||
extractPastedImageFile,
|
||||
extractPastedImageFiles,
|
||||
validateImageFile,
|
||||
} from '@/components/video-page/image-upload-utils';
|
||||
import { useCommentMedia } from '@/components/video-page/hooks/use-comment-media';
|
||||
@@ -472,10 +472,10 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
|
||||
const handleImagePaste = async (event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
if (uploadTab !== 'image' || !canUploadAssets || isCreatingAsset) return;
|
||||
const pastedImage = extractPastedImageFile(event.clipboardData);
|
||||
if (!pastedImage) return;
|
||||
const pastedImages = extractPastedImageFiles(event.clipboardData);
|
||||
if (pastedImages.length === 0) return;
|
||||
event.preventDefault();
|
||||
await stageImageFiles([pastedImage]);
|
||||
await stageImageFiles(pastedImages);
|
||||
};
|
||||
|
||||
const handleCreateYoutubeAsset = async () => {
|
||||
|
||||
@@ -2,18 +2,7 @@
|
||||
|
||||
import { memo, type RefObject } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
Mic,
|
||||
Pause,
|
||||
Pencil,
|
||||
Play,
|
||||
Send,
|
||||
Tag,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Image as ImageIcon, Loader2, Mic, Pause, Pencil, Play, Send, Tag, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -23,6 +12,8 @@ import {
|
||||
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';
|
||||
|
||||
@@ -32,9 +23,9 @@ interface CommentComposerProps {
|
||||
stopRecording: () => void;
|
||||
cancelRecording: () => void;
|
||||
audioBlob: Blob | null;
|
||||
imageBlob: File | null;
|
||||
imageFiles: File[];
|
||||
imageInputRef: RefObject<HTMLInputElement | null>;
|
||||
setImageBlob: (blob: File | null) => void;
|
||||
removeImageFile: (index: number) => void;
|
||||
commentText: string;
|
||||
setCommentText: (value: string) => void;
|
||||
commentRangeStart: number | null;
|
||||
@@ -58,8 +49,8 @@ interface CommentComposerProps {
|
||||
handleAddComment: () => void;
|
||||
isSubmittingComment: boolean;
|
||||
startRecording: () => void;
|
||||
handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>, isReply?: boolean) => void;
|
||||
handleImageSelect: (e: React.ChangeEvent<HTMLInputElement>, isReply?: boolean) => void;
|
||||
handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>) => void;
|
||||
handleImageSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
availableTags: CommentTag[];
|
||||
selectedTagId: string | null;
|
||||
setSelectedTagId: (value: string | null) => void;
|
||||
@@ -75,9 +66,9 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
stopRecording,
|
||||
cancelRecording,
|
||||
audioBlob,
|
||||
imageBlob,
|
||||
imageFiles,
|
||||
imageInputRef,
|
||||
setImageBlob,
|
||||
removeImageFile,
|
||||
commentText,
|
||||
setCommentText,
|
||||
commentRangeStart,
|
||||
@@ -179,28 +170,7 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{imageBlob && (
|
||||
<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 */}
|
||||
<img
|
||||
src={URL.createObjectURL(imageBlob)}
|
||||
alt="Preview"
|
||||
className="max-h-40 w-auto object-contain"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setImageBlob(null);
|
||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ImageAttachmentStrip files={imageFiles} onRemoveFile={removeImageFile} />
|
||||
|
||||
<MentionTextarea
|
||||
placeholder="Add a note to your voice comment (optional)..."
|
||||
@@ -271,28 +241,7 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{imageBlob && (
|
||||
<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 */}
|
||||
<img
|
||||
src={URL.createObjectURL(imageBlob)}
|
||||
alt="Preview"
|
||||
className="max-h-40 w-auto object-contain"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setImageBlob(null);
|
||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ImageAttachmentStrip files={imageFiles} onRemoveFile={removeImageFile} />
|
||||
<div className="mb-2 flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -332,7 +281,7 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
handleAddComment();
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => handlePaste(e, false)}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 self-end">
|
||||
@@ -340,7 +289,7 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
size="icon"
|
||||
onClick={handleAddComment}
|
||||
disabled={
|
||||
(!commentText.trim() && !imageBlob && !annotationStrokes) ||
|
||||
(!commentText.trim() && imageFiles.length === 0 && !annotationStrokes) ||
|
||||
isSubmittingComment ||
|
||||
isUploadingImage
|
||||
}
|
||||
@@ -363,7 +312,8 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
title="Attach Image"
|
||||
disabled={imageFiles.length >= MAX_COMMENT_IMAGES}
|
||||
title={`Attach images (up to ${MAX_COMMENT_IMAGES})`}
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -387,6 +337,7 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
ref={imageInputRef}
|
||||
onChange={handleImageSelect}
|
||||
@@ -444,7 +395,9 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">Cmd+Enter to submit</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Cmd+Enter to submit · paste or drop up to {MAX_COMMENT_IMAGES} images
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,19 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MentionTextarea } from '@/components/video-page/mention-textarea';
|
||||
import { CommentRichText } from '@/components/video-page/comment-rich-text';
|
||||
import type { Comment, CommentTag, Version, VideoAsset } from '@/components/video-page/types';
|
||||
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;
|
||||
@@ -63,13 +75,17 @@ interface CommentsPaneProps {
|
||||
currentUserId: string | null;
|
||||
projectOwnerId: string;
|
||||
editingCommentId: string | null;
|
||||
setEditingCommentId: (id: string | null) => void;
|
||||
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;
|
||||
setEditAnnotationData: (value: string | null | undefined) => void;
|
||||
setIsEditingAnnotation: (value: boolean) => void;
|
||||
editImageUrls: string[];
|
||||
editImageFiles: File[];
|
||||
editImageInputRef: RefObject<HTMLInputElement | null>;
|
||||
removeEditImageUrl: (url: string) => void;
|
||||
onStartEditAnnotation: () => void;
|
||||
isSubmittingEdit: boolean;
|
||||
availableTags: CommentTag[];
|
||||
@@ -94,7 +110,7 @@ interface CommentsPaneProps {
|
||||
handleReplyComment: (
|
||||
parentId: string,
|
||||
voiceData?: { url: string; duration: number },
|
||||
imageData?: { url: string }
|
||||
imageUrls?: string[]
|
||||
) => void;
|
||||
startReplyRecording: () => void;
|
||||
isReplyRecording: boolean;
|
||||
@@ -102,12 +118,12 @@ interface CommentsPaneProps {
|
||||
stopReplyRecording: () => void;
|
||||
cancelReplyRecording: () => void;
|
||||
replyAudioBlob: Blob | null;
|
||||
replyImageBlob: File | null;
|
||||
setReplyImageBlob: (file: File | null) => void;
|
||||
replyImageFiles: File[];
|
||||
replyImageInputRef: RefObject<HTMLInputElement | null>;
|
||||
handleImageSelect: (e: React.ChangeEvent<HTMLInputElement>, isReply?: boolean) => void;
|
||||
handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>, isReply?: boolean) => void;
|
||||
handleDrop: (e: React.DragEvent<HTMLDivElement>, isReply?: boolean) => void;
|
||||
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;
|
||||
@@ -141,13 +157,17 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
currentUserId,
|
||||
projectOwnerId,
|
||||
editingCommentId,
|
||||
setEditingCommentId,
|
||||
startEditingComment,
|
||||
startEditingReply,
|
||||
cancelEditingComment,
|
||||
editText,
|
||||
setEditText,
|
||||
editTagId,
|
||||
setEditTagId,
|
||||
setEditAnnotationData,
|
||||
setIsEditingAnnotation,
|
||||
editImageUrls,
|
||||
editImageFiles,
|
||||
editImageInputRef,
|
||||
removeEditImageUrl,
|
||||
onStartEditAnnotation,
|
||||
isSubmittingEdit,
|
||||
availableTags,
|
||||
@@ -176,9 +196,9 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
stopReplyRecording,
|
||||
cancelReplyRecording,
|
||||
replyAudioBlob,
|
||||
replyImageBlob,
|
||||
setReplyImageBlob,
|
||||
replyImageFiles,
|
||||
replyImageInputRef,
|
||||
removeImageFile,
|
||||
handleImageSelect,
|
||||
handlePaste,
|
||||
handleDrop,
|
||||
@@ -241,12 +261,15 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
onDrop={(e) => {
|
||||
setIsPaneDraggingOver(false);
|
||||
if (activePane !== 'comments') return;
|
||||
handleDrop(e, replyingTo !== null);
|
||||
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 image to attach</p>
|
||||
<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">
|
||||
@@ -446,13 +469,7 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
Reply
|
||||
</DropdownMenuItem>
|
||||
{canEditComment && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setEditingCommentId(comment.id);
|
||||
setEditText(comment.content || '');
|
||||
setEditTagId(comment.tag?.id || null);
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => startEditingComment(comment)}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
@@ -486,19 +503,28 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
handleEditComment(comment.id);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setEditingCommentId(null);
|
||||
setEditText('');
|
||||
setEditTagId(undefined);
|
||||
setEditAnnotationData(undefined);
|
||||
setIsEditingAnnotation(false);
|
||||
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() || isSubmittingEdit}
|
||||
disabled={
|
||||
(!editText.trim() &&
|
||||
editImageUrls.length === 0 &&
|
||||
editImageFiles.length === 0) ||
|
||||
isSubmittingEdit
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isSubmittingEdit ? (
|
||||
@@ -510,17 +536,31 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setEditingCommentId(null);
|
||||
setEditText('');
|
||||
setEditTagId(undefined);
|
||||
setEditAnnotationData(undefined);
|
||||
setIsEditingAnnotation(false);
|
||||
}}
|
||||
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'}
|
||||
@@ -595,19 +635,11 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
{comment.imageUrl && (
|
||||
<div
|
||||
className="rounded-md overflow-hidden bg-muted mb-2 max-h-60 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => setPreviewImage(comment.imageUrl)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={comment.imageUrl}
|
||||
alt="Attachment"
|
||||
className="max-h-60 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CommentImageGallery
|
||||
images={comment.images}
|
||||
onOpen={setPreviewImage}
|
||||
className="mb-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -722,15 +754,7 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canEditReply && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setEditingCommentId(reply.id);
|
||||
setEditText(reply.content || '');
|
||||
// No tag picker on a reply: undefined keeps
|
||||
// the PATCH from carrying a tagId at all.
|
||||
setEditTagId(undefined);
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => startEditingReply(reply)}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
@@ -762,16 +786,28 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
handleEditComment(reply.id);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setEditingCommentId(null);
|
||||
setEditText('');
|
||||
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() || isSubmittingEdit}
|
||||
disabled={
|
||||
(!editText.trim() &&
|
||||
editImageUrls.length === 0 &&
|
||||
editImageFiles.length === 0) ||
|
||||
isSubmittingEdit
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isSubmittingEdit ? (
|
||||
@@ -783,14 +819,32 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setEditingCommentId(null);
|
||||
setEditText('');
|
||||
}}
|
||||
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>
|
||||
) : (
|
||||
@@ -804,19 +858,12 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
{reply.imageUrl && (
|
||||
<div
|
||||
className="rounded-md overflow-hidden bg-muted mt-2 max-h-40 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => setPreviewImage(reply.imageUrl)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={reply.imageUrl}
|
||||
alt="Attachment"
|
||||
className="max-h-40 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CommentImageGallery
|
||||
images={reply.images}
|
||||
onOpen={setPreviewImage}
|
||||
compact
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{reply.voiceUrl && (
|
||||
@@ -943,30 +990,11 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{replyImageBlob && (
|
||||
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center h-20 mb-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={URL.createObjectURL(replyImageBlob)}
|
||||
alt="Preview"
|
||||
className="h-full object-contain"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
className="h-6 w-6"
|
||||
onClick={() => {
|
||||
setReplyImageBlob(null);
|
||||
if (replyImageInputRef.current)
|
||||
replyImageInputRef.current.value = '';
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ImageAttachmentStrip
|
||||
files={replyImageFiles}
|
||||
onRemoveFile={(index) => removeImageFile(index, 'reply')}
|
||||
compact
|
||||
/>
|
||||
|
||||
<MentionTextarea
|
||||
value={replyText}
|
||||
@@ -1026,30 +1054,11 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{replyImageBlob && (
|
||||
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center h-20 mb-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={URL.createObjectURL(replyImageBlob)}
|
||||
alt="Preview"
|
||||
className="h-full object-contain"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
className="h-6 w-6"
|
||||
onClick={() => {
|
||||
setReplyImageBlob(null);
|
||||
if (replyImageInputRef.current)
|
||||
replyImageInputRef.current.value = '';
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ImageAttachmentStrip
|
||||
files={replyImageFiles}
|
||||
onRemoveFile={(index) => removeImageFile(index, 'reply')}
|
||||
compact
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<MentionTextarea
|
||||
value={replyText}
|
||||
@@ -1069,7 +1078,7 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
setReplyText('');
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => handlePaste(e, true)}
|
||||
onPaste={(e) => handlePaste(e, 'reply')}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -1084,7 +1093,8 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => replyImageInputRef.current?.click()}
|
||||
title="Attach Image"
|
||||
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" />
|
||||
@@ -1092,9 +1102,10 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
ref={replyImageInputRef}
|
||||
onChange={(e) => handleImageSelect(e, true)}
|
||||
onChange={(e) => handleImageSelect(e, 'reply')}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
@@ -1127,7 +1138,7 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
size="sm"
|
||||
onClick={() => handleReplyComment(comment.id)}
|
||||
disabled={
|
||||
(!replyText.trim() && !replyImageBlob) ||
|
||||
(!replyText.trim() && replyImageFiles.length === 0) ||
|
||||
isSubmittingReply ||
|
||||
isUploadingReplyImage
|
||||
}
|
||||
|
||||
@@ -17,15 +17,17 @@ import type { AnnotationCanvasHandle, AnnotationStroke } from '@/components/anno
|
||||
import type {
|
||||
Comment,
|
||||
CommentActionsConfig,
|
||||
CommentImage,
|
||||
CommentReply,
|
||||
CommentTag,
|
||||
Version,
|
||||
VideoData,
|
||||
} from '@/components/video-page/types';
|
||||
import {
|
||||
extractPastedImageFile,
|
||||
extractPastedImageFiles,
|
||||
validateImageFile,
|
||||
} from '@/components/video-page/image-upload-utils';
|
||||
import { MAX_COMMENT_IMAGES } from '@/lib/comment-images';
|
||||
import { validateAnnotationStrokes } from '@/lib/validation';
|
||||
import { withWebmDuration } from '@/lib/webm-duration';
|
||||
import { ApiRequestError, apiRequestError, toastApiError } from '@/lib/client/api-error';
|
||||
@@ -53,6 +55,9 @@ interface UseCommentActionsParams extends CommentActionsConfig {
|
||||
fetchAssets: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Which of the three editors an attachment is being staged for. */
|
||||
export type ImageAttachTarget = 'comment' | 'reply' | 'edit';
|
||||
|
||||
function getAudioUploadFilename(blob: Blob): string {
|
||||
const mime = blob.type.split(';')[0].trim().toLowerCase();
|
||||
if (mime === 'audio/mp4') return 'recording.m4a';
|
||||
@@ -91,7 +96,7 @@ export function useCommentActions({
|
||||
const [recordingTime, setRecordingTime] = useState(0);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [isUploadingAudio, setIsUploadingAudio] = useState(false);
|
||||
const [imageBlob, setImageBlob] = useState<File | null>(null);
|
||||
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||
const [commentRangeStart, setCommentRangeStart] = useState<number | null>(null);
|
||||
const [commentRangeEnd, setCommentRangeEnd] = useState<number | null>(null);
|
||||
@@ -108,7 +113,7 @@ export function useCommentActions({
|
||||
const [replyRecordingTime, setReplyRecordingTime] = useState(0);
|
||||
const [replyAudioBlob, setReplyAudioBlob] = useState<Blob | null>(null);
|
||||
const [isUploadingReplyAudio, setIsUploadingReplyAudio] = useState(false);
|
||||
const [replyImageBlob, setReplyImageBlob] = useState<File | null>(null);
|
||||
const [replyImageFiles, setReplyImageFiles] = useState<File[]>([]);
|
||||
const [isUploadingReplyImage, setIsUploadingReplyImage] = useState(false);
|
||||
const [replyRangeStart, setReplyRangeStart] = useState<number | null>(null);
|
||||
const [replyRangeEnd, setReplyRangeEnd] = useState<number | null>(null);
|
||||
@@ -130,6 +135,10 @@ export function useCommentActions({
|
||||
undefined
|
||||
);
|
||||
const [isEditingAnnotation, setIsEditingAnnotation] = useState(false);
|
||||
// The images the edited comment keeps, and the ones staged to be added to it.
|
||||
const [editImageUrls, setEditImageUrls] = useState<string[]>([]);
|
||||
const [editImageFiles, setEditImageFiles] = useState<File[]>([]);
|
||||
const editImageInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
|
||||
@@ -189,9 +198,98 @@ export function useCommentActions({
|
||||
[isGuest, videoId]
|
||||
);
|
||||
|
||||
/** Upload a batch of staged images and hand back their URLs, in the same order. */
|
||||
const uploadImageFiles = useCallback(
|
||||
async (files: File[]): Promise<string[]> => {
|
||||
if (files.length === 0) return [];
|
||||
|
||||
// One grant covers the whole batch: it is bound to the intent and the
|
||||
// client, not to a single file.
|
||||
const uploadToken = await getGuestUploadToken('image');
|
||||
|
||||
return Promise.all(
|
||||
files.map(async (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
formData.append('videoId', videoId);
|
||||
if (uploadToken) formData.append('uploadToken', uploadToken);
|
||||
|
||||
const response = await fetch('/api/upload/image', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
// The attachments go up before the comment does, so a full account
|
||||
// fails here and never reaches the comment at all. Thrown with the
|
||||
// code attached so the caller can offer the way out.
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
code?: string;
|
||||
} | null;
|
||||
throw apiRequestError(payload, 'Failed to upload image');
|
||||
}
|
||||
const payload = await response.json();
|
||||
return payload.data.url as string;
|
||||
})
|
||||
);
|
||||
},
|
||||
[getGuestUploadToken, videoId]
|
||||
);
|
||||
|
||||
/** Stage validated images on one of the editors, up to the per-comment cap. */
|
||||
const attachImageFiles = useCallback(
|
||||
async (files: File[], target: ImageAttachTarget) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
for (const file of files) {
|
||||
const imageError = await validateImageFile(file);
|
||||
if (imageError) {
|
||||
toast.error(imageError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const staged =
|
||||
target === 'reply' ? replyImageFiles : target === 'edit' ? editImageFiles : imageFiles;
|
||||
// Images the edited comment already has count against the same cap.
|
||||
const alreadyOnComment = target === 'edit' ? editImageUrls.length : 0;
|
||||
const room = MAX_COMMENT_IMAGES - alreadyOnComment - staged.length;
|
||||
if (room <= 0) {
|
||||
toast.error(`A comment can have at most ${MAX_COMMENT_IMAGES} images`);
|
||||
return;
|
||||
}
|
||||
if (files.length > room) {
|
||||
toast.error(
|
||||
room === 1
|
||||
? 'Only 1 more image fits on this comment'
|
||||
: `Only ${room} more images fit on this comment`
|
||||
);
|
||||
}
|
||||
|
||||
const next = [...staged, ...files.slice(0, room)];
|
||||
if (target === 'reply') setReplyImageFiles(next);
|
||||
else if (target === 'edit') setEditImageFiles(next);
|
||||
else setImageFiles(next);
|
||||
},
|
||||
[editImageFiles, editImageUrls, imageFiles, replyImageFiles]
|
||||
);
|
||||
|
||||
const removeImageFile = useCallback((index: number, target: ImageAttachTarget) => {
|
||||
const drop = (files: File[]) => files.filter((_, current) => current !== index);
|
||||
if (target === 'reply') setReplyImageFiles(drop);
|
||||
else if (target === 'edit') setEditImageFiles(drop);
|
||||
else setImageFiles(drop);
|
||||
}, []);
|
||||
|
||||
const handleAddComment = useCallback(
|
||||
async (voiceData?: { url: string; duration: number }) => {
|
||||
if (!voiceData && !imageBlob && !commentText.trim() && !annotationStrokes && !isAnnotating)
|
||||
if (
|
||||
!voiceData &&
|
||||
imageFiles.length === 0 &&
|
||||
!commentText.trim() &&
|
||||
!annotationStrokes &&
|
||||
!isAnnotating
|
||||
)
|
||||
return;
|
||||
if (!activeVersion || !activeVersionId) return;
|
||||
|
||||
@@ -206,14 +304,18 @@ export function useCommentActions({
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
const commentTimestamp = commentRangeStart ?? currentTime;
|
||||
const serializedAnnotation = effectiveStrokes ? JSON.stringify(effectiveStrokes) : null;
|
||||
const hasImages = imageFiles.length > 0;
|
||||
const optimisticComment: Comment = {
|
||||
id: tempId,
|
||||
content: voiceData || imageBlob ? commentText.trim() || null : commentText,
|
||||
content: voiceData || hasImages ? commentText.trim() || null : commentText,
|
||||
timestamp: commentTimestamp,
|
||||
timestampEnd: commentRangeEnd,
|
||||
voiceUrl: voiceData?.url ?? null,
|
||||
voiceDuration: voiceData?.duration ?? null,
|
||||
imageUrl: imageBlob ? URL.createObjectURL(imageBlob) : null,
|
||||
images: imageFiles.map((file, index) => ({
|
||||
id: `${tempId}-image-${index}`,
|
||||
url: URL.createObjectURL(file),
|
||||
})),
|
||||
annotationData: serializedAnnotation,
|
||||
isResolved: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -238,7 +340,7 @@ export function useCommentActions({
|
||||
setCommentText('');
|
||||
setSelectedTagId(availableTags.length > 0 ? availableTags[0].id : null);
|
||||
setAudioBlob(null);
|
||||
setImageBlob(null);
|
||||
setImageFiles([]);
|
||||
setAnnotationStrokes(null);
|
||||
setIsAnnotating(false);
|
||||
clearCommentRangeSelection();
|
||||
@@ -248,44 +350,22 @@ export function useCommentActions({
|
||||
isMutatingRef.current = true;
|
||||
|
||||
try {
|
||||
let imageData: { url: string } | undefined;
|
||||
let uploadedImageUrls: string[] = [];
|
||||
|
||||
if (imageBlob) {
|
||||
if (hasImages) {
|
||||
setIsUploadingImage(true);
|
||||
const imageFormData = new FormData();
|
||||
imageFormData.append('image', imageBlob);
|
||||
imageFormData.append('videoId', videoId);
|
||||
const uploadToken = await getGuestUploadToken('image');
|
||||
if (uploadToken) imageFormData.append('uploadToken', uploadToken);
|
||||
|
||||
const imageRes = await fetch('/api/upload/image', {
|
||||
method: 'POST',
|
||||
body: imageFormData,
|
||||
});
|
||||
|
||||
if (!imageRes.ok) {
|
||||
// The attachment goes up before the comment does, so a full account
|
||||
// fails here and never reaches the comment at all. Thrown with the
|
||||
// code attached so the catch below can offer the way out.
|
||||
const imagePayload = (await imageRes.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
code?: string;
|
||||
} | null;
|
||||
throw apiRequestError(imagePayload, 'Failed to upload image');
|
||||
}
|
||||
const imageDataResponse = await imageRes.json();
|
||||
imageData = { url: imageDataResponse.data.url };
|
||||
uploadedImageUrls = await uploadImageFiles(imageFiles);
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: voiceData || imageBlob ? commentText.trim() || null : commentText,
|
||||
content: voiceData || hasImages ? commentText.trim() || null : commentText,
|
||||
timestamp: commentTimestamp,
|
||||
...(commentRangeEnd !== null && { timestampEnd: commentRangeEnd }),
|
||||
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
||||
...(imageData && { imageUrl: imageData.url }),
|
||||
...(uploadedImageUrls.length > 0 && { imageUrls: uploadedImageUrls }),
|
||||
...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }),
|
||||
...(selectedTagId && { tagId: selectedTagId }),
|
||||
...(effectiveStrokes && { annotationData: effectiveStrokes }),
|
||||
@@ -314,8 +394,8 @@ export function useCommentActions({
|
||||
};
|
||||
});
|
||||
|
||||
// If an image was attached, refresh the assets list
|
||||
if (imageData) {
|
||||
// If images were attached, refresh the assets list
|
||||
if (uploadedImageUrls.length > 0) {
|
||||
void fetchAssets();
|
||||
}
|
||||
} else {
|
||||
@@ -369,11 +449,10 @@ export function useCommentActions({
|
||||
currentUserName,
|
||||
selectedTagId,
|
||||
availableTags,
|
||||
imageBlob,
|
||||
imageFiles,
|
||||
uploadImageFiles,
|
||||
annotationStrokes,
|
||||
isAnnotating,
|
||||
videoId,
|
||||
getGuestUploadToken,
|
||||
annotationCanvasRef,
|
||||
setSelectedTagId,
|
||||
setAnnotationStrokes,
|
||||
@@ -386,63 +465,34 @@ export function useCommentActions({
|
||||
);
|
||||
|
||||
const handleImageSelect = useCallback(
|
||||
async (e: ChangeEvent<HTMLInputElement>, isReply: boolean = false) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const imageError = await validateImageFile(file);
|
||||
if (imageError) {
|
||||
toast.error(imageError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isReply) {
|
||||
setReplyImageBlob(file);
|
||||
} else {
|
||||
setImageBlob(file);
|
||||
}
|
||||
async (e: ChangeEvent<HTMLInputElement>, target: ImageAttachTarget = 'comment') => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
// Clearing the input lets the same file be picked again after it is removed.
|
||||
e.target.value = '';
|
||||
await attachImageFiles(files, target);
|
||||
},
|
||||
[]
|
||||
[attachImageFiles]
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (e: ClipboardEvent<HTMLTextAreaElement>, isReply: boolean = false) => {
|
||||
const file = extractPastedImageFile(e.clipboardData);
|
||||
if (!file) return;
|
||||
async (e: ClipboardEvent<HTMLTextAreaElement>, target: ImageAttachTarget = 'comment') => {
|
||||
const files = extractPastedImageFiles(e.clipboardData);
|
||||
if (files.length === 0) return;
|
||||
e.preventDefault();
|
||||
|
||||
const imageError = await validateImageFile(file);
|
||||
if (imageError) {
|
||||
toast.error(imageError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isReply) {
|
||||
setReplyImageBlob(file);
|
||||
} else {
|
||||
setImageBlob(file);
|
||||
}
|
||||
await attachImageFiles(files, target);
|
||||
},
|
||||
[]
|
||||
[attachImageFiles]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(async (e: DragEvent<HTMLDivElement>, isReply: boolean = false) => {
|
||||
e.preventDefault();
|
||||
const file = extractPastedImageFile(e.dataTransfer);
|
||||
if (!file) return;
|
||||
|
||||
const imageError = await validateImageFile(file);
|
||||
if (imageError) {
|
||||
toast.error(imageError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isReply) {
|
||||
setReplyImageBlob(file);
|
||||
} else {
|
||||
setImageBlob(file);
|
||||
}
|
||||
}, []);
|
||||
const handleDrop = useCallback(
|
||||
async (e: DragEvent<HTMLDivElement>, target: ImageAttachTarget = 'comment') => {
|
||||
e.preventDefault();
|
||||
const files = extractPastedImageFiles(e.dataTransfer);
|
||||
if (files.length === 0) return;
|
||||
await attachImageFiles(files, target);
|
||||
},
|
||||
[attachImageFiles]
|
||||
);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
@@ -543,13 +593,13 @@ export function useCommentActions({
|
||||
const submitCommentWithMedia = useCallback(async () => {
|
||||
if (!activeVersion) return;
|
||||
|
||||
if (audioBlob && !imageBlob && !commentText.trim()) {
|
||||
if (audioBlob && imageFiles.length === 0 && !commentText.trim()) {
|
||||
submitVoiceComment();
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioBlob) setIsUploadingAudio(true);
|
||||
if (imageBlob) setIsUploadingImage(true);
|
||||
if (imageFiles.length > 0) setIsUploadingImage(true);
|
||||
|
||||
try {
|
||||
let voiceData: { url: string; duration: number } | undefined;
|
||||
@@ -569,7 +619,7 @@ export function useCommentActions({
|
||||
|
||||
setAudioBlob(null);
|
||||
setRecordingTime(0);
|
||||
setImageBlob(null);
|
||||
setImageFiles([]);
|
||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||
} catch (err) {
|
||||
console.error('Failed to submit comment with media:', err);
|
||||
@@ -580,7 +630,7 @@ export function useCommentActions({
|
||||
}
|
||||
}, [
|
||||
audioBlob,
|
||||
imageBlob,
|
||||
imageFiles,
|
||||
activeVersion,
|
||||
recordingTime,
|
||||
commentText,
|
||||
@@ -676,21 +726,25 @@ export function useCommentActions({
|
||||
async (
|
||||
parentId: string,
|
||||
voiceData?: { url: string; duration: number },
|
||||
imageData?: { url: string }
|
||||
alreadyUploadedImageUrls?: string[]
|
||||
) => {
|
||||
if (!voiceData && !replyImageBlob && !replyText.trim()) return;
|
||||
if (!voiceData && replyImageFiles.length === 0 && !replyText.trim()) return;
|
||||
if (!activeVersion || !activeVersionId) return;
|
||||
|
||||
const hasReplyImages = replyImageFiles.length > 0;
|
||||
const tempId = `temp-reply-${Date.now()}`;
|
||||
const replyTimestamp = replyRangeStart ?? currentTime;
|
||||
const optimisticReply: CommentReply = {
|
||||
id: tempId,
|
||||
content: voiceData || replyImageBlob ? replyText.trim() || null : replyText,
|
||||
content: voiceData || hasReplyImages ? replyText.trim() || null : replyText,
|
||||
timestamp: replyTimestamp,
|
||||
timestampEnd: replyRangeEnd,
|
||||
voiceUrl: voiceData?.url ?? null,
|
||||
voiceDuration: voiceData?.duration ?? null,
|
||||
imageUrl: replyImageBlob ? URL.createObjectURL(replyImageBlob) : null,
|
||||
images: replyImageFiles.map((file, index) => ({
|
||||
id: `${tempId}-image-${index}`,
|
||||
url: URL.createObjectURL(file),
|
||||
})),
|
||||
annotationData: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
|
||||
@@ -723,43 +777,31 @@ export function useCommentActions({
|
||||
setReplyingTo(null);
|
||||
setReplyAudioBlob(null);
|
||||
setReplyRecordingTime(0);
|
||||
setReplyImageBlob(null);
|
||||
setReplyImageFiles([]);
|
||||
clearReplyRangeSelection();
|
||||
|
||||
setIsSubmittingReply(true);
|
||||
isMutatingRef.current = true;
|
||||
|
||||
try {
|
||||
let submittedImageData: { url: string } | undefined = imageData;
|
||||
let submittedImageUrls: string[] = alreadyUploadedImageUrls ?? [];
|
||||
|
||||
if (replyImageBlob && !imageData) {
|
||||
if (hasReplyImages && submittedImageUrls.length === 0) {
|
||||
setIsUploadingReplyImage(true);
|
||||
const imageFormData = new FormData();
|
||||
imageFormData.append('image', replyImageBlob);
|
||||
imageFormData.append('videoId', videoId);
|
||||
const uploadToken = await getGuestUploadToken('image');
|
||||
if (uploadToken) imageFormData.append('uploadToken', uploadToken);
|
||||
|
||||
const imageRes = await fetch('/api/upload/image', {
|
||||
method: 'POST',
|
||||
body: imageFormData,
|
||||
});
|
||||
|
||||
if (!imageRes.ok) throw new Error('Failed to upload image reply');
|
||||
const imageDataResponse = await imageRes.json();
|
||||
submittedImageData = { url: imageDataResponse.data.url };
|
||||
submittedImageUrls = await uploadImageFiles(replyImageFiles);
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: voiceData || submittedImageData ? replyText.trim() || null : replyText,
|
||||
content:
|
||||
voiceData || submittedImageUrls.length > 0 ? replyText.trim() || null : replyText,
|
||||
timestamp: replyTimestamp,
|
||||
...(replyRangeEnd !== null && { timestampEnd: replyRangeEnd }),
|
||||
parentId,
|
||||
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
||||
...(submittedImageData && { imageUrl: submittedImageData.url }),
|
||||
...(submittedImageUrls.length > 0 && { imageUrls: submittedImageUrls }),
|
||||
...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }),
|
||||
}),
|
||||
});
|
||||
@@ -791,8 +833,8 @@ export function useCommentActions({
|
||||
};
|
||||
});
|
||||
|
||||
// If an image was attached, refresh the assets list
|
||||
if (submittedImageData) {
|
||||
// If images were attached, refresh the assets list
|
||||
if (submittedImageUrls.length > 0) {
|
||||
void fetchAssets();
|
||||
}
|
||||
} else {
|
||||
@@ -852,9 +894,8 @@ export function useCommentActions({
|
||||
isGuest,
|
||||
normalizedGuestName,
|
||||
currentUserName,
|
||||
replyImageBlob,
|
||||
videoId,
|
||||
getGuestUploadToken,
|
||||
replyImageFiles,
|
||||
uploadImageFiles,
|
||||
setVideo,
|
||||
fetchAssets,
|
||||
clearReplyRangeSelection,
|
||||
@@ -949,13 +990,13 @@ export function useCommentActions({
|
||||
async (parentId: string) => {
|
||||
if (!activeVersion) return;
|
||||
|
||||
if (replyAudioBlob && !replyImageBlob && !replyText.trim()) {
|
||||
if (replyAudioBlob && replyImageFiles.length === 0 && !replyText.trim()) {
|
||||
submitVoiceReply(parentId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (replyAudioBlob) setIsUploadingReplyAudio(true);
|
||||
if (replyImageBlob) setIsUploadingReplyImage(true);
|
||||
if (replyImageFiles.length > 0) setIsUploadingReplyImage(true);
|
||||
|
||||
try {
|
||||
let voiceData: { url: string; duration: number } | undefined;
|
||||
@@ -976,7 +1017,7 @@ export function useCommentActions({
|
||||
|
||||
setReplyAudioBlob(null);
|
||||
setReplyRecordingTime(0);
|
||||
setReplyImageBlob(null);
|
||||
setReplyImageFiles([]);
|
||||
if (replyImageInputRef.current) replyImageInputRef.current.value = '';
|
||||
} catch (err) {
|
||||
console.error('Failed to submit reply with media:', err);
|
||||
@@ -988,7 +1029,7 @@ export function useCommentActions({
|
||||
},
|
||||
[
|
||||
replyAudioBlob,
|
||||
replyImageBlob,
|
||||
replyImageFiles,
|
||||
activeVersion,
|
||||
replyRecordingTime,
|
||||
replyText,
|
||||
@@ -999,9 +1040,41 @@ export function useCommentActions({
|
||||
]
|
||||
);
|
||||
|
||||
const startEditingComment = useCallback((comment: Comment) => {
|
||||
setEditingCommentId(comment.id);
|
||||
setEditText(comment.content || '');
|
||||
setEditTagId(comment.tag?.id || null);
|
||||
setEditImageUrls(comment.images.map((image) => image.url));
|
||||
setEditImageFiles([]);
|
||||
}, []);
|
||||
|
||||
const startEditingReply = useCallback((reply: CommentReply) => {
|
||||
setEditingCommentId(reply.id);
|
||||
setEditText(reply.content || '');
|
||||
// No tag picker on a reply: undefined keeps the PATCH from carrying a tagId at all.
|
||||
setEditTagId(undefined);
|
||||
setEditImageUrls(reply.images.map((image) => image.url));
|
||||
setEditImageFiles([]);
|
||||
}, []);
|
||||
|
||||
const cancelEditingComment = useCallback(() => {
|
||||
setEditingCommentId(null);
|
||||
setEditText('');
|
||||
setEditTagId(undefined);
|
||||
setEditAnnotationData(undefined);
|
||||
setIsEditingAnnotation(false);
|
||||
setEditImageUrls([]);
|
||||
setEditImageFiles([]);
|
||||
}, []);
|
||||
|
||||
const removeEditImageUrl = useCallback((url: string) => {
|
||||
setEditImageUrls((prev) => prev.filter((current) => current !== url));
|
||||
}, []);
|
||||
|
||||
const handleEditComment = useCallback(
|
||||
async (commentId: string) => {
|
||||
if (!editText.trim() && !editAnnotationData) return;
|
||||
const keepsImages = editImageUrls.length > 0 || editImageFiles.length > 0;
|
||||
if (!editText.trim() && !editAnnotationData && !keepsImages) return;
|
||||
if (!activeVersionId) return;
|
||||
|
||||
setIsSubmittingEdit(true);
|
||||
@@ -1016,7 +1089,12 @@ export function useCommentActions({
|
||||
}
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = { content: editText };
|
||||
const uploadedImageUrls = await uploadImageFiles(editImageFiles);
|
||||
// The list the comment should end up with: what the editor kept, then
|
||||
// whatever was pasted into it while it was open.
|
||||
const nextImageUrls = [...editImageUrls, ...uploadedImageUrls];
|
||||
|
||||
const body: Record<string, unknown> = { content: editText, imageUrls: nextImageUrls };
|
||||
if (editTagId !== undefined) body.tagId = editTagId;
|
||||
if (finalAnnotationData !== undefined) {
|
||||
body.annotationData =
|
||||
@@ -1028,10 +1106,21 @@ export function useCommentActions({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = (await res.json().catch(() => null)) as {
|
||||
data?: { images?: CommentImage[] };
|
||||
error?: string;
|
||||
code?: string;
|
||||
} | null;
|
||||
|
||||
if (res.ok) {
|
||||
const editedTag = editTagId
|
||||
? availableTags.find((t) => t.id === editTagId) || null
|
||||
: null;
|
||||
// The response carries the saved rows with their real ids; fall back to
|
||||
// the URLs that were sent if it did not come back as JSON.
|
||||
const savedImages: CommentImage[] =
|
||||
payload?.data?.images ??
|
||||
nextImageUrls.map((url, index) => ({ id: `${commentId}-image-${index}`, url }));
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
@@ -1045,6 +1134,7 @@ export function useCommentActions({
|
||||
return {
|
||||
...c,
|
||||
content: editText.trim(),
|
||||
images: savedImages,
|
||||
tag: editTagId !== undefined ? editedTag : c.tag,
|
||||
annotationData:
|
||||
finalAnnotationData !== undefined
|
||||
@@ -1054,7 +1144,9 @@ export function useCommentActions({
|
||||
return {
|
||||
...c,
|
||||
replies: (c.replies || []).map((r) =>
|
||||
r.id === commentId ? { ...r, content: editText.trim() } : r
|
||||
r.id === commentId
|
||||
? { ...r, content: editText.trim(), images: savedImages }
|
||||
: r
|
||||
),
|
||||
};
|
||||
}),
|
||||
@@ -1063,11 +1155,10 @@ export function useCommentActions({
|
||||
),
|
||||
};
|
||||
});
|
||||
setEditingCommentId(null);
|
||||
setEditText('');
|
||||
setEditTagId(undefined);
|
||||
setEditAnnotationData(undefined);
|
||||
setIsEditingAnnotation(false);
|
||||
cancelEditingComment();
|
||||
if (uploadedImageUrls.length > 0) {
|
||||
void fetchAssets();
|
||||
}
|
||||
if (finalAnnotationData !== undefined && finalAnnotationData) {
|
||||
try {
|
||||
const parsed = JSON.parse(finalAnnotationData);
|
||||
@@ -1079,9 +1170,13 @@ export function useCommentActions({
|
||||
} else if (finalAnnotationData === null) {
|
||||
setViewingAnnotation(null);
|
||||
}
|
||||
} else {
|
||||
toastApiError(payload, 'Failed to save changes');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to edit comment:', err);
|
||||
} catch (error) {
|
||||
// An upload can fail on quota before the comment is ever touched, and
|
||||
// that message is worth showing; a network fault falls back.
|
||||
toastApiError(error instanceof ApiRequestError ? error : null, 'Failed to save changes');
|
||||
} finally {
|
||||
setIsSubmittingEdit(false);
|
||||
isMutatingRef.current = false;
|
||||
@@ -1091,12 +1186,17 @@ export function useCommentActions({
|
||||
editText,
|
||||
editTagId,
|
||||
editAnnotationData,
|
||||
editImageFiles,
|
||||
editImageUrls,
|
||||
uploadImageFiles,
|
||||
cancelEditingComment,
|
||||
isEditingAnnotation,
|
||||
activeVersionId,
|
||||
availableTags,
|
||||
isGuest,
|
||||
normalizedGuestName,
|
||||
editAnnotationCanvasRef,
|
||||
fetchAssets,
|
||||
setVideo,
|
||||
setViewingAnnotation,
|
||||
]
|
||||
@@ -1203,14 +1303,15 @@ export function useCommentActions({
|
||||
recordingTime,
|
||||
audioBlob,
|
||||
isUploadingAudio,
|
||||
imageBlob,
|
||||
setImageBlob,
|
||||
imageFiles,
|
||||
setImageFiles,
|
||||
commentRangeStart,
|
||||
commentRangeEnd,
|
||||
toggleCommentRangeSelection,
|
||||
clearCommentRangeSelection,
|
||||
isUploadingImage,
|
||||
imageInputRef,
|
||||
removeImageFile,
|
||||
handleAddComment,
|
||||
handleImageSelect,
|
||||
handlePaste,
|
||||
@@ -1228,8 +1329,8 @@ export function useCommentActions({
|
||||
isReplyRecording,
|
||||
replyRecordingTime,
|
||||
replyAudioBlob,
|
||||
replyImageBlob,
|
||||
setReplyImageBlob,
|
||||
replyImageFiles,
|
||||
setReplyImageFiles,
|
||||
replyRangeStart,
|
||||
replyRangeEnd,
|
||||
toggleReplyRangeSelection,
|
||||
@@ -1253,6 +1354,13 @@ export function useCommentActions({
|
||||
setEditAnnotationData,
|
||||
isEditingAnnotation,
|
||||
setIsEditingAnnotation,
|
||||
editImageUrls,
|
||||
editImageFiles,
|
||||
editImageInputRef,
|
||||
startEditingComment,
|
||||
startEditingReply,
|
||||
cancelEditingComment,
|
||||
removeEditImageUrl,
|
||||
isSubmittingEdit,
|
||||
handleEditComment,
|
||||
handleDeleteComment,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* Blob URLs for a list of staged files, revoked as soon as a file leaves the list.
|
||||
*
|
||||
* Calling `URL.createObjectURL` inline in the markup mints a new URL on every
|
||||
* render and never releases any of them, which a five-screenshot preview grid
|
||||
* turns into a steady leak.
|
||||
*/
|
||||
export function useObjectUrls(files: File[]): string[] {
|
||||
const urls = useMemo(() => files.map((file) => URL.createObjectURL(file)), [files]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [urls]);
|
||||
|
||||
return urls;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useObjectUrls } from '@/components/video-page/hooks/use-object-urls';
|
||||
import type { CommentImage } from '@/components/video-page/types';
|
||||
|
||||
interface ImageAttachmentStripProps {
|
||||
/** Images already saved on the comment being edited, if any. */
|
||||
existingUrls?: string[];
|
||||
onRemoveExisting?: (url: string) => void;
|
||||
/** Files staged in this editor and not uploaded yet. */
|
||||
files: File[];
|
||||
onRemoveFile: (index: number) => void;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The row of thumbnails under an editor, showing what will be sent with it.
|
||||
* Saved images come first, then the ones staged in this session.
|
||||
*/
|
||||
export const ImageAttachmentStrip = memo(function ImageAttachmentStrip({
|
||||
existingUrls = [],
|
||||
onRemoveExisting,
|
||||
files,
|
||||
onRemoveFile,
|
||||
compact = false,
|
||||
className,
|
||||
}: ImageAttachmentStripProps) {
|
||||
const previewUrls = useObjectUrls(files);
|
||||
|
||||
if (existingUrls.length === 0 && previewUrls.length === 0) return null;
|
||||
|
||||
const tileSize = compact ? 'h-14 w-14' : 'h-20 w-20';
|
||||
const buttonSize = compact ? 'h-5 w-5' : 'h-6 w-6';
|
||||
const iconSize = compact ? 'h-2.5 w-2.5' : 'h-3 w-3';
|
||||
|
||||
const tile = (key: string, src: string, alt: string, onRemove: () => void) => (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
'group/attachment relative shrink-0 overflow-hidden rounded-md border bg-muted',
|
||||
tileSize
|
||||
)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={src} alt={alt} className="h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover/attachment:opacity-100">
|
||||
<Button size="icon" variant="destructive" className={buttonSize} onClick={onRemove}>
|
||||
<Trash2 className={iconSize} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('mb-2 flex flex-wrap gap-2', className)}>
|
||||
{existingUrls.map((url, index) =>
|
||||
tile(url, url, `Attachment ${index + 1}`, () => onRemoveExisting?.(url))
|
||||
)}
|
||||
{previewUrls.map((url, index) =>
|
||||
tile(`staged-${index}`, url, `Preview ${index + 1}`, () => onRemoveFile(index))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface CommentImageGalleryProps {
|
||||
images: CommentImage[];
|
||||
onOpen: (url: string) => void;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** The images saved on a comment. One fills the width; several tile into a grid. */
|
||||
export const CommentImageGallery = memo(function CommentImageGallery({
|
||||
images,
|
||||
onOpen,
|
||||
compact = false,
|
||||
className,
|
||||
}: CommentImageGalleryProps) {
|
||||
if (images.length === 0) return null;
|
||||
|
||||
if (images.length === 1) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center justify-center overflow-hidden rounded-md bg-muted transition-opacity hover:opacity-90',
|
||||
compact ? 'max-h-40' : 'max-h-60',
|
||||
className
|
||||
)}
|
||||
onClick={() => onOpen(images[0].url)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={images[0].url}
|
||||
alt="Attachment"
|
||||
className={cn('w-auto object-contain', compact ? 'max-h-40' : 'max-h-60')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('grid grid-cols-2 gap-1.5', className)}>
|
||||
{images.map((image, index) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className={cn(
|
||||
'cursor-pointer overflow-hidden rounded-md bg-muted transition-opacity hover:opacity-90',
|
||||
compact ? 'h-20' : 'h-24'
|
||||
)}
|
||||
onClick={() => onOpen(image.url)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={image.url}
|
||||
alt={`Attachment ${index + 1}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -18,16 +18,22 @@ export async function validateImageFile(file: File): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractPastedImageFile(data: DataTransfer | null | undefined): File | null {
|
||||
/**
|
||||
* Every image on the clipboard or in a drop, in the order the browser lists them.
|
||||
* A screenshot batch arrives as several items in one paste, so taking only the
|
||||
* first would silently drop the rest.
|
||||
*/
|
||||
export function extractPastedImageFiles(data: DataTransfer | null | undefined): File[] {
|
||||
const items = data?.items;
|
||||
if (!items) return null;
|
||||
if (!items) return [];
|
||||
|
||||
const files: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (!item.type.startsWith('image/')) continue;
|
||||
const file = item.getAsFile();
|
||||
if (file) return file;
|
||||
if (file) files.push(file);
|
||||
}
|
||||
|
||||
return null;
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,11 @@ export interface ApprovalRequest {
|
||||
decisions: ApprovalDecision[];
|
||||
}
|
||||
|
||||
export interface CommentImage {
|
||||
id: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface CommentReply {
|
||||
id: string;
|
||||
content: string | null;
|
||||
@@ -82,7 +87,7 @@ export interface CommentReply {
|
||||
timestampEnd: number | null;
|
||||
voiceUrl: string | null;
|
||||
voiceDuration: number | null;
|
||||
imageUrl: string | null;
|
||||
images: CommentImage[];
|
||||
annotationData: string | null;
|
||||
createdAt: string;
|
||||
author: { id: string; name: string | null; image: string | null } | null;
|
||||
@@ -99,7 +104,7 @@ export interface Comment {
|
||||
timestampEnd: number | null;
|
||||
voiceUrl: string | null;
|
||||
voiceDuration: number | null;
|
||||
imageUrl: string | null;
|
||||
images: CommentImage[];
|
||||
annotationData: string | null;
|
||||
isResolved: boolean;
|
||||
createdAt: string;
|
||||
@@ -210,7 +215,7 @@ export interface VideoPageCommentsActions {
|
||||
onReplyComment: (
|
||||
parentId: string,
|
||||
voiceData?: { url: string; duration: number },
|
||||
imageData?: { url: string }
|
||||
imageUrls?: string[]
|
||||
) => void;
|
||||
onSubmitReplyWithMedia: (parentId: string) => void;
|
||||
onStartEditAnnotation: () => void;
|
||||
|
||||
Reference in New Issue
Block a user