mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(comments): add drag-and-drop support for image attachments and enhance image validation
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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(
|
||||||
'fixed inset-y-0 right-0 w-[85%] sm:w-[400px] shadow-2xl transition-transform duration-300 transform',
|
'bg-card flex flex-col overflow-hidden z-50 relative',
|
||||||
isMobileCommentsOpen ? 'translate-x-0' : 'translate-x-full',
|
'fixed inset-y-0 right-0 w-[85%] sm:w-[400px] shadow-2xl transition-transform duration-300 transform',
|
||||||
'lg:static lg:w-80 lg:shrink-0 lg:border-l lg:transition-none lg:translate-x-0 lg:shadow-none lg:z-auto',
|
isMobileCommentsOpen ? 'translate-x-0' : 'translate-x-full',
|
||||||
isFullscreenMode && !showComments ? 'hidden' : ''
|
'lg:static lg:w-80 lg:shrink-0 lg:border-l lg:transition-none lg:translate-x-0 lg:shadow-none lg:z-auto',
|
||||||
)}>
|
isFullscreenMode && !showComments ? 'hidden' : ''
|
||||||
|
)}
|
||||||
|
onDragOver={(e) => { if (activePane !== 'comments') return; e.preventDefault(); setIsPaneDraggingOver(true); }}
|
||||||
|
onDragEnter={(e) => { if (activePane !== 'comments') return; e.preventDefault(); setIsPaneDraggingOver(true); }}
|
||||||
|
onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsPaneDraggingOver(false); }}
|
||||||
|
onDrop={(e) => { setIsPaneDraggingOver(false); if (activePane !== 'comments') return; handleDrop(e, 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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user