Spaces:
Sleeping
Sleeping
| 'use client'; | |
| import { useState, useRef, useCallback, useImperativeHandle, forwardRef } from 'react'; | |
| import { Send, Image as ImageIcon, X, Loader2 } from 'lucide-react'; | |
| import { clsx } from 'clsx'; | |
| interface MessageInputProps { | |
| onSend: (message: string, imageUrl?: string, imageBase64?: string) => void; | |
| isLoading: boolean; | |
| placeholder?: string; | |
| } | |
| export interface MessageInputRef { | |
| setContent: (text: string, imageUrl?: string) => void; | |
| clear: () => void; | |
| } | |
| export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>( | |
| function MessageInput( | |
| { | |
| onSend, | |
| isLoading, | |
| placeholder = 'Ask about quantum computing, Qiskit, or upload a circuit diagram...', | |
| }, | |
| ref | |
| ) { | |
| const [message, setMessage] = useState(''); | |
| const [imageBase64, setImageBase64] = useState<string | null>(null); | |
| const [imageUrl, setImageUrl] = useState<string | null>(null); | |
| const [imagePreview, setImagePreview] = useState<string | null>(null); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const textareaRef = useRef<HTMLTextAreaElement>(null); | |
| useImperativeHandle(ref, () => ({ | |
| setContent: (text: string, url?: string) => { | |
| setMessage(text); | |
| if (url) { | |
| setImageUrl(url); | |
| setImagePreview(url); | |
| setImageBase64(null); | |
| } | |
| if (textareaRef.current) { | |
| textareaRef.current.style.height = 'auto'; | |
| setTimeout(() => { | |
| if (textareaRef.current) { | |
| textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`; | |
| } | |
| }, 0); | |
| } | |
| }, | |
| clear: () => { | |
| setMessage(''); | |
| setImageBase64(null); | |
| setImageUrl(null); | |
| setImagePreview(null); | |
| if (textareaRef.current) { | |
| textareaRef.current.style.height = 'auto'; | |
| } | |
| }, | |
| })); | |
| const handleSubmit = useCallback(() => { | |
| if ((!message.trim() && !imageBase64 && !imageUrl) || isLoading) return; | |
| onSend(message.trim(), imageUrl || undefined, imageBase64 || undefined); | |
| setMessage(''); | |
| setImageBase64(null); | |
| setImageUrl(null); | |
| setImagePreview(null); | |
| if (textareaRef.current) { | |
| textareaRef.current.style.height = 'auto'; | |
| } | |
| }, [message, imageBase64, imageUrl, isLoading, onSend]); | |
| const handleKeyDown = (e: React.KeyboardEvent) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSubmit(); | |
| } | |
| }; | |
| const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = e.target.files?.[0]; | |
| if (!file) return; | |
| if (!file.type.startsWith('image/')) { | |
| alert('Please upload an image file'); | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| reader.onload = (event) => { | |
| const result = event.target?.result as string; | |
| const base64 = result.split(',')[1]; | |
| setImageBase64(base64); | |
| setImageUrl(null); | |
| setImagePreview(result); | |
| }; | |
| reader.readAsDataURL(file); | |
| }; | |
| const removeImage = () => { | |
| setImageBase64(null); | |
| setImageUrl(null); | |
| setImagePreview(null); | |
| if (fileInputRef.current) { | |
| fileInputRef.current.value = ''; | |
| } | |
| }; | |
| const adjustTextareaHeight = (e: React.ChangeEvent<HTMLTextAreaElement>) => { | |
| const textarea = e.target; | |
| textarea.style.height = 'auto'; | |
| textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; | |
| setMessage(textarea.value); | |
| }; | |
| const hasContent = message.trim() || imageBase64 || imageUrl; | |
| return ( | |
| <div className="bg-zinc-800/60 border border-zinc-700/50 rounded-xl p-3"> | |
| {imagePreview && ( | |
| <div className="mb-3 relative inline-block"> | |
| <img | |
| src={imagePreview} | |
| alt="Upload preview" | |
| className="h-24 rounded-lg border border-zinc-700/50 object-contain bg-zinc-900" | |
| /> | |
| <button | |
| onClick={removeImage} | |
| className="absolute -top-2 -right-2 p-1 bg-red-600/80 rounded-full hover:bg-red-600 transition-colors" | |
| > | |
| <X className="w-3 h-3 text-white" /> | |
| </button> | |
| </div> | |
| )} | |
| <div className="flex items-end gap-2"> | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept="image/*" | |
| onChange={handleImageUpload} | |
| className="hidden" | |
| /> | |
| <button | |
| onClick={() => fileInputRef.current?.click()} | |
| disabled={isLoading} | |
| className={clsx( | |
| 'p-3 rounded-lg transition-all duration-200', | |
| 'hover:bg-zinc-700/50 text-zinc-500 hover:text-zinc-300', | |
| isLoading && 'opacity-50 cursor-not-allowed' | |
| )} | |
| title="Upload image" | |
| > | |
| <ImageIcon className="w-5 h-5" /> | |
| </button> | |
| <textarea | |
| ref={textareaRef} | |
| value={message} | |
| onChange={adjustTextareaHeight} | |
| onKeyDown={handleKeyDown} | |
| placeholder={placeholder} | |
| disabled={isLoading} | |
| rows={1} | |
| className={clsx( | |
| 'flex-1 bg-transparent border-none outline-none resize-none', | |
| 'text-zinc-200 placeholder:text-zinc-500', | |
| 'min-h-[44px] max-h-[200px] py-3', | |
| isLoading && 'opacity-50' | |
| )} | |
| /> | |
| <button | |
| onClick={handleSubmit} | |
| disabled={!hasContent || isLoading} | |
| className={clsx( | |
| 'p-3 rounded-lg transition-all duration-200', | |
| hasContent | |
| ? 'bg-teal-700/80 hover:bg-teal-600/80 text-white' | |
| : 'bg-zinc-700/50 text-zinc-500', | |
| isLoading && 'opacity-50 cursor-not-allowed' | |
| )} | |
| > | |
| {isLoading ? ( | |
| <Loader2 className="w-5 h-5 animate-spin" /> | |
| ) : ( | |
| <Send className="w-5 h-5" /> | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| ); | |