feat(comments): add drag-and-drop support for image attachments and enhance image validation

This commit is contained in:
Yusuf İpek
2026-03-01 18:53:54 +03:00
parent 147e5520a3
commit 75535e8cda
5 changed files with 64 additions and 23 deletions
+2
View File
@@ -414,6 +414,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
handleAddComment, handleAddComment,
handleImageSelect, handleImageSelect,
handlePaste, handlePaste,
handleDrop,
startRecording, startRecording,
stopRecording, stopRecording,
cancelRecording, cancelRecording,
@@ -814,6 +815,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
replyImageInputRef={replyImageInputRef} replyImageInputRef={replyImageInputRef}
handleImageSelect={handleImageSelect} handleImageSelect={handleImageSelect}
handlePaste={handlePaste} handlePaste={handlePaste}
handleDrop={handleDrop}
submitReplyWithMedia={commentsActions.onSubmitReplyWithMedia} submitReplyWithMedia={commentsActions.onSubmitReplyWithMedia}
isSubmittingReply={isSubmittingReply} isSubmittingReply={isSubmittingReply}
isUploadingReplyAudio={isUploadingReplyAudio} isUploadingReplyAudio={isUploadingReplyAudio}
+6 -6
View File
@@ -256,7 +256,7 @@ export const AssetsPane = memo(function AssetsPane({
const handleImageUpload = useCallback(async (file: File) => { const handleImageUpload = useCallback(async (file: File) => {
if (!file) return; if (!file) return;
const imageError = validateImageFile(file); const imageError = await validateImageFile(file);
if (imageError) { if (imageError) {
toast.error(imageError); toast.error(imageError);
return; return;
@@ -297,7 +297,7 @@ export const AssetsPane = memo(function AssetsPane({
const handleImageFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleImageFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
const imageError = validateImageFile(file); const imageError = await validateImageFile(file);
if (imageError) { if (imageError) {
toast.error(imageError); toast.error(imageError);
return; return;
@@ -306,16 +306,16 @@ export const AssetsPane = memo(function AssetsPane({
toast.success('Image attached. Click Upload Image to send.'); toast.success('Image attached. Click Upload Image to send.');
}; };
const handleImagePaste = (event: React.ClipboardEvent<HTMLDivElement>) => { const handleImagePaste = async (event: React.ClipboardEvent<HTMLDivElement>) => {
if (uploadTab !== 'image' || !canUploadAssets || isCreatingAsset) return; if (uploadTab !== 'image' || !canUploadAssets || isCreatingAsset) return;
const pastedImage = extractPastedImageFile(event.clipboardData); const pastedImage = extractPastedImageFile(event.clipboardData);
if (!pastedImage) return; if (!pastedImage) return;
const imageError = validateImageFile(pastedImage); event.preventDefault();
const imageError = await validateImageFile(pastedImage);
if (imageError) { if (imageError) {
toast.error(imageError); toast.error(imageError);
return; return;
} }
event.preventDefault();
setPendingImageFile(pastedImage); setPendingImageFile(pastedImage);
toast.success('Image attached from clipboard. Click Upload Image to send.'); toast.success('Image attached from clipboard. Click Upload Image to send.');
}; };
@@ -582,7 +582,7 @@ export const AssetsPane = memo(function AssetsPane({
if (!file) return; if (!file) return;
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
const imageError = validateImageFile(file); const imageError = await validateImageFile(file);
if (imageError) { toast.error(imageError); return; } if (imageError) { toast.error(imageError); return; }
// Stage the file so the user can optionally set a name before uploading // Stage the file so the user can optionally set a name before uploading
setUploadTab('image'); setUploadTab('image');
+19 -4
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { memo, type ReactNode, type RefObject } from 'react'; import { memo, useState, type ReactNode, type RefObject } from 'react';
import { ArrowUpRight, CheckCircle2, ChevronDown, Circle, Clock, Download, FileText, FolderOpen, Image as ImageIcon, Loader2, MessageSquare, Mic, MoreVertical, Pause, Pencil, Play, Reply, Tag, Trash2, X } from 'lucide-react'; import { ArrowUpRight, CheckCircle2, ChevronDown, Circle, Clock, Download, FileText, FolderOpen, Image as ImageIcon, Loader2, MessageSquare, Mic, MoreVertical, Pause, Pencil, Play, Reply, Tag, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -78,6 +78,7 @@ interface CommentsPaneProps {
replyImageInputRef: RefObject<HTMLInputElement | null>; replyImageInputRef: RefObject<HTMLInputElement | null>;
handleImageSelect: (e: React.ChangeEvent<HTMLInputElement>, isReply?: boolean) => void; handleImageSelect: (e: React.ChangeEvent<HTMLInputElement>, isReply?: boolean) => void;
handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>, isReply?: boolean) => void; handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>, isReply?: boolean) => void;
handleDrop: (e: React.DragEvent<HTMLDivElement>, isReply?: boolean) => void;
submitReplyWithMedia: (parentId: string) => void; submitReplyWithMedia: (parentId: string) => void;
isSubmittingReply: boolean; isSubmittingReply: boolean;
isUploadingReplyAudio: boolean; isUploadingReplyAudio: boolean;
@@ -147,6 +148,7 @@ export const CommentsPane = memo(function CommentsPane({
replyImageInputRef, replyImageInputRef,
handleImageSelect, handleImageSelect,
handlePaste, handlePaste,
handleDrop,
submitReplyWithMedia, submitReplyWithMedia,
isSubmittingReply, isSubmittingReply,
isUploadingReplyAudio, isUploadingReplyAudio,
@@ -158,6 +160,8 @@ export const CommentsPane = memo(function CommentsPane({
setActivePane, setActivePane,
assetsPane, assetsPane,
}: CommentsPaneProps) { }: CommentsPaneProps) {
const [isPaneDraggingOver, setIsPaneDraggingOver] = useState(false);
return ( return (
<> <>
<div <div
@@ -168,13 +172,24 @@ export const CommentsPane = memo(function CommentsPane({
onClick={() => setIsMobileCommentsOpen(false)} onClick={() => setIsMobileCommentsOpen(false)}
/> />
<div className={cn( <div
'bg-card flex flex-col overflow-hidden z-50', className={cn(
'bg-card flex flex-col overflow-hidden z-50 relative',
'fixed inset-y-0 right-0 w-[85%] sm:w-[400px] shadow-2xl transition-transform duration-300 transform', 'fixed inset-y-0 right-0 w-[85%] sm:w-[400px] shadow-2xl transition-transform duration-300 transform',
isMobileCommentsOpen ? 'translate-x-0' : 'translate-x-full', isMobileCommentsOpen ? 'translate-x-0' : 'translate-x-full',
'lg:static lg:w-80 lg:shrink-0 lg:border-l lg:transition-none lg:translate-x-0 lg:shadow-none lg:z-auto', 'lg:static lg:w-80 lg:shrink-0 lg:border-l lg:transition-none lg:translate-x-0 lg:shadow-none lg:z-auto',
isFullscreenMode && !showComments ? 'hidden' : '' isFullscreenMode && !showComments ? 'hidden' : ''
)}> )}
onDragOver={(e) => { if (activePane !== 'comments') return; e.preventDefault(); setIsPaneDraggingOver(true); }}
onDragEnter={(e) => { if (activePane !== 'comments') return; e.preventDefault(); setIsPaneDraggingOver(true); }}
onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsPaneDraggingOver(false); }}
onDrop={(e) => { setIsPaneDraggingOver(false); if (activePane !== 'comments') return; handleDrop(e, replyingTo !== null); }}
>
{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>
</div>
)}
<div className="shrink-0 p-4 border-b lg:cursor-default space-y-2"> <div className="shrink-0 p-4 border-b lg:cursor-default space-y-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1 min-w-0 overflow-x-auto"> <div className="flex items-center gap-1 min-w-0 overflow-x-auto">
@@ -7,6 +7,7 @@ import {
useState, useState,
type ChangeEvent, type ChangeEvent,
type ClipboardEvent, type ClipboardEvent,
type DragEvent,
type Dispatch, type Dispatch,
type RefObject, type RefObject,
type SetStateAction, type SetStateAction,
@@ -284,11 +285,11 @@ export function useCommentActions({
fetchAssets, fetchAssets,
]); ]);
const handleImageSelect = useCallback((e: ChangeEvent<HTMLInputElement>, isReply: boolean = false) => { const handleImageSelect = useCallback(async (e: ChangeEvent<HTMLInputElement>, isReply: boolean = false) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
const imageError = validateImageFile(file); const imageError = await validateImageFile(file);
if (imageError) { if (imageError) {
toast.error(imageError); toast.error(imageError);
return; return;
@@ -301,11 +302,12 @@ export function useCommentActions({
} }
}, []); }, []);
const handlePaste = useCallback((e: ClipboardEvent<HTMLTextAreaElement>, isReply: boolean = false) => { const handlePaste = useCallback(async (e: ClipboardEvent<HTMLTextAreaElement>, isReply: boolean = false) => {
const file = extractPastedImageFile(e.clipboardData); const file = extractPastedImageFile(e.clipboardData);
if (!file) return; if (!file) return;
e.preventDefault();
const imageError = validateImageFile(file); const imageError = await validateImageFile(file);
if (imageError) { if (imageError) {
toast.error(imageError); toast.error(imageError);
return; return;
@@ -316,7 +318,24 @@ export function useCommentActions({
} else { } else {
setImageBlob(file); setImageBlob(file);
} }
}, []);
const handleDrop = useCallback(async (e: DragEvent<HTMLDivElement>, isReply: boolean = false) => {
e.preventDefault(); 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 startRecording = useCallback(async () => { const startRecording = useCallback(async () => {
@@ -961,6 +980,7 @@ export function useCommentActions({
handleAddComment, handleAddComment,
handleImageSelect, handleImageSelect,
handlePaste, handlePaste,
handleDrop,
startRecording, startRecording,
stopRecording, stopRecording,
cancelRecording, cancelRecording,
+9 -5
View File
@@ -1,16 +1,20 @@
'use client'; 'use client';
import { detectImageMime } from '@/lib/image-upload-validation';
export const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024; export const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024;
export function validateImageFile(file: File): string | null { export async function validateImageFile(file: File): Promise<string | null> {
if (!file.type.startsWith('image/')) {
return 'Please select an image file';
}
if (file.size > MAX_IMAGE_UPLOAD_BYTES) { if (file.size > MAX_IMAGE_UPLOAD_BYTES) {
return 'Image must be less than 10MB'; return 'Image must be less than 10MB';
} }
const header = await file.slice(0, 12).arrayBuffer();
const detected = detectImageMime(new Uint8Array(header));
if (!detected) {
return 'Unsupported image format. Allowed: JPEG, PNG, GIF, WEBP';
}
return null; return null;
} }