feat: Add image upload functionality with drag & drop, clipboard paste, and file picker (#46)

- Add drag & drop support for images with visual feedback
- Implement clipboard paste for images (Ctrl+V/Cmd+V)
- Add image upload button in chat interface
- Support multiple formats: PNG, JPG, JPEG, GIF, WebP, SVG
- Max 5 images per message, 5MB per image
- Add image preview with remove functionality
- Display images in chat message bubbles

Technical implementation:
- Frontend: Add react-dropzone for drag & drop
- Frontend: Create ImageAttachment component for previews
- Backend: Add multer for file upload handling
- Backend: Create /api/projects/:projectName/upload-images endpoint
- Backend: Convert images to base64 and save to temp files
- Claude CLI: Pass image paths as arguments
- Add automatic cleanup of temporary files
- Fix JWT auth to use correct token name
- Fix UI overlap with proper padding adjustments
- Remove non-essential console.logs for production

Security:
- Validate file types and sizes
- Sanitize filenames
- User-specific temp directories
- Proper JWT authentication

Infrastructure:
- Add .tmp/ to .gitignore
- Create comprehensive CHANGELOG.md
- Update package.json with new dependencies

Co-authored-by: viper151 <simosmik@gmail.com>
This commit is contained in:
lvalics
2025-07-12 22:30:55 +03:00
committed by GitHub
parent ad0bcba117
commit 7feeebc2ae
5 changed files with 371 additions and 12 deletions

View File

@@ -18,6 +18,7 @@
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
import ReactMarkdown from 'react-markdown';
import { useDropzone } from 'react-dropzone';
import TodoList from './TodoList';
import ClaudeLogo from './ClaudeLogo.jsx';
@@ -72,6 +73,19 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<div className="text-sm whitespace-pre-wrap break-words">
{message.content}
</div>
{message.images && message.images.length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-2">
{message.images.map((img, idx) => (
<img
key={idx}
src={img.data}
alt={img.name}
className="rounded-lg max-w-full h-auto cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => window.open(img.data, '_blank')}
/>
))}
</div>
)}
<div className="text-xs text-blue-100 mt-1 text-right">
{new Date(message.timestamp).toLocaleTimeString()}
</div>
@@ -875,6 +889,43 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
);
});
// ImageAttachment component for displaying image previews
const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => {
const [preview, setPreview] = useState(null);
useEffect(() => {
const url = URL.createObjectURL(file);
setPreview(url);
return () => URL.revokeObjectURL(url);
}, [file]);
return (
<div className="relative group">
<img src={preview} alt={file.name} className="w-20 h-20 object-cover rounded" />
{uploadProgress !== undefined && uploadProgress < 100 && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<div className="text-white text-xs">{uploadProgress}%</div>
</div>
)}
{error && (
<div className="absolute inset-0 bg-red-500/50 flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
)}
<button
onClick={onRemove}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
};
// ChatInterface: Main chat component with Session Protection System integration
//
// Session Protection System prevents automatic project updates from interrupting active conversations:
@@ -904,6 +955,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
const [permissionMode, setPermissionMode] = useState('default');
const [attachedImages, setAttachedImages] = useState([]);
const [uploadingImages, setUploadingImages] = useState(new Map());
const [imageErrors, setImageErrors] = useState(new Map());
const messagesEndRef = useRef(null);
const textareaRef = useRef(null);
const scrollContainerRef = useRef(null);
@@ -1380,6 +1434,11 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
setCurrentSessionId(pendingSessionId);
sessionStorage.removeItem('pendingSessionId');
}
// Clear persisted chat messages after successful completion
if (selectedProject && latestMessage.exitCode === 0) {
localStorage.removeItem(`chat_messages_${selectedProject.name}`);
}
break;
case 'session-aborted':
@@ -1628,13 +1687,105 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}, []);
const handleSubmit = (e) => {
// Handle image files from drag & drop or file picker
const handleImageFiles = useCallback((files) => {
const validFiles = files.filter(file => {
if (!file.type.startsWith('image/')) {
return false;
}
if (file.size > 5 * 1024 * 1024) {
setImageErrors(prev => new Map(prev).set(file.name, 'File too large (max 5MB)'));
return false;
}
return true;
});
if (validFiles.length > 0) {
setAttachedImages(prev => [...prev, ...validFiles].slice(0, 5)); // Max 5 images
}
}, []);
// Handle clipboard paste for images
const handlePaste = useCallback(async (e) => {
const items = Array.from(e.clipboardData.items);
for (const item of items) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) {
handleImageFiles([file]);
}
}
}
// Fallback for some browsers/platforms
if (items.length === 0 && e.clipboardData.files.length > 0) {
const files = Array.from(e.clipboardData.files);
const imageFiles = files.filter(f => f.type.startsWith('image/'));
if (imageFiles.length > 0) {
handleImageFiles(imageFiles);
}
}
}, [handleImageFiles]);
// Setup dropzone
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
accept: {
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']
},
maxSize: 5 * 1024 * 1024, // 5MB
maxFiles: 5,
onDrop: handleImageFiles,
noClick: true, // We'll use our own button
noKeyboard: true
});
const handleSubmit = async (e) => {
e.preventDefault();
if (!input.trim() || isLoading || !selectedProject) return;
// Upload images first if any
let uploadedImages = [];
if (attachedImages.length > 0) {
const formData = new FormData();
attachedImages.forEach(file => {
formData.append('images', file);
});
try {
const token = localStorage.getItem('auth-token');
const headers = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`/api/projects/${selectedProject.name}/upload-images`, {
method: 'POST',
headers: headers,
body: formData
});
if (!response.ok) {
throw new Error('Failed to upload images');
}
const result = await response.json();
uploadedImages = result.images;
} catch (error) {
console.error('Image upload failed:', error);
setChatMessages(prev => [...prev, {
type: 'error',
content: `Failed to upload images: ${error.message}`,
timestamp: new Date()
}]);
return;
}
}
const userMessage = {
type: 'user',
content: input,
images: uploadedImages,
timestamp: new Date()
};
@@ -1681,7 +1832,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const toolsSettings = getToolsSettings();
// Send command to Claude CLI via WebSocket
// Send command to Claude CLI via WebSocket with images
sendMessage({
type: 'claude-command',
command: input,
@@ -1691,14 +1842,20 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
sessionId: currentSessionId,
resume: !!currentSessionId,
toolsSettings: toolsSettings,
permissionMode: permissionMode
permissionMode: permissionMode,
images: uploadedImages // Pass images to backend
}
});
setInput('');
setAttachedImages([]);
setUploadingImages(new Map());
setImageErrors(new Map());
setIsTextareaExpanded(false);
// Reset textarea height to minimal state
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
@@ -1992,13 +2149,46 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</div>
<form onSubmit={handleSubmit} className="relative max-w-4xl mx-auto">
<div className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 ${isTextareaExpanded ? 'chat-input-expanded' : ''}`}>
{/* Drag overlay */}
{isDragActive && (
<div className="absolute inset-0 bg-blue-500/20 border-2 border-dashed border-blue-500 rounded-lg flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-lg">
<svg className="w-8 h-8 text-blue-500 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-sm font-medium">Drop images here</p>
</div>
</div>
)}
{/* Image attachments preview */}
{attachedImages.length > 0 && (
<div className="mb-2 p-2 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex flex-wrap gap-2">
{attachedImages.map((file, index) => (
<ImageAttachment
key={index}
file={file}
onRemove={() => {
setAttachedImages(prev => prev.filter((_, i) => i !== index));
}}
uploadProgress={uploadingImages.get(file.name)}
error={imageErrors.get(file.name)}
/>
))}
</div>
</div>
)}
<div {...getRootProps()} className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 ${isTextareaExpanded ? 'chat-input-expanded' : ''}`}>
<input {...getInputProps()} />
<textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onClick={handleTextareaClick}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
onInput={(e) => {
@@ -2015,7 +2205,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
placeholder="Ask Claude to help with your code... (@ to reference files)"
disabled={isLoading}
rows={1}
className="chat-input-placeholder w-full px-4 sm:px-6 py-3 sm:py-4 pr-28 sm:pr-40 bg-transparent rounded-2xl focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none min-h-[40px] sm:min-h-[56px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-sm sm:text-base transition-all duration-200"
className="chat-input-placeholder w-full pl-12 pr-28 sm:pr-40 py-3 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none min-h-[40px] sm:min-h-[56px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-sm sm:text-base transition-all duration-200"
style={{ height: 'auto' }}
/>
{/* Clear button - shown when there's text */}
@@ -2060,6 +2250,18 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</svg>
</button>
)}
{/* Image upload button */}
<button
type="button"
onClick={open}
className="absolute left-2 bottom-4 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Attach images"
>
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</button>
{/* Mic button - HIDDEN */}
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
<MicButton