mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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.
This commit is contained in:
@@ -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