|
|
|
import React, { useState, useEffect, useRef, useCallback } from 'react'; |
|
import type { ChatSession, ChatMessage, UploadedFile } from '../types'; |
|
import { ChatMessageItem } from './ChatMessageItem'; |
|
import { SuggestionButton } from './SuggestionButton'; |
|
import { SendIcon, FilmIcon, PaperclipIcon, XCircleIcon as XCircleIconFile, DocumentTextIcon, StopIcon } from './icons'; |
|
import { SUGGESTION_PROMPTS, REELBOT_IMAGE_URL } from '../constants'; |
|
|
|
const MAX_FILE_SIZE_MB = 5; |
|
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; |
|
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; |
|
const ALLOWED_DOC_TYPES = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']; |
|
const ALLOWED_FILE_TYPES = [...ALLOWED_IMAGE_TYPES, ...ALLOWED_DOC_TYPES]; |
|
|
|
|
|
interface ChatViewProps { |
|
activeChatSession: ChatSession | null; |
|
onSendMessage: (message: string, file?: UploadedFile, isSuggestion?: boolean) => Promise<void>; |
|
isLoading: boolean; |
|
error: string | null; |
|
onStopGeneration: () => void; |
|
} |
|
|
|
export const ChatView: React.FC<ChatViewProps> = ({ activeChatSession, onSendMessage, isLoading, error, onStopGeneration }) => { |
|
const [userInput, setUserInput] = useState(''); |
|
const [selectedFile, setSelectedFile] = useState<UploadedFile | null>(null); |
|
const [fileError, setFileError] = useState<string | null>(null); |
|
|
|
const messagesEndRef = useRef<HTMLDivElement>(null); |
|
const inputRef = useRef<HTMLInputElement>(null); |
|
const fileInputRef = useRef<HTMLInputElement>(null); |
|
|
|
const scrollToBottom = () => { |
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); |
|
}; |
|
|
|
useEffect(scrollToBottom, [activeChatSession?.messages]); |
|
|
|
useEffect(() => { |
|
if (!isLoading && inputRef.current) { |
|
inputRef.current.focus(); |
|
} |
|
}, [isLoading, activeChatSession]); |
|
|
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { |
|
const file = event.target.files?.[0]; |
|
setFileError(null); |
|
setSelectedFile(null); |
|
|
|
if (file) { |
|
if (file.size > MAX_FILE_SIZE_BYTES) { |
|
setFileError(`El archivo es demasiado grande (máx. ${MAX_FILE_SIZE_MB}MB).`); |
|
return; |
|
} |
|
if (!ALLOWED_FILE_TYPES.includes(file.type)) { |
|
setFileError('Tipo de archivo no admitido.'); |
|
return; |
|
} |
|
|
|
const fileInfo: UploadedFile = { |
|
name: file.name, |
|
type: file.type, |
|
size: file.size, |
|
}; |
|
|
|
if (ALLOWED_IMAGE_TYPES.includes(file.type)) { |
|
const reader = new FileReader(); |
|
reader.onloadend = () => { |
|
fileInfo.dataUrl = reader.result as string; |
|
setSelectedFile(fileInfo); |
|
}; |
|
reader.onerror = () => { |
|
setFileError('Error al leer el archivo de imagen.'); |
|
} |
|
reader.readAsDataURL(file); |
|
} else if (ALLOWED_DOC_TYPES.includes(file.type)) { |
|
setSelectedFile(fileInfo); |
|
} |
|
} |
|
if (fileInputRef.current) { |
|
fileInputRef.current.value = ""; |
|
} |
|
}; |
|
|
|
const clearSelectedFile = () => { |
|
setSelectedFile(null); |
|
setFileError(null); |
|
if (fileInputRef.current) { |
|
fileInputRef.current.value = ""; |
|
} |
|
}; |
|
|
|
const handleSubmit = (e?: React.FormEvent) => { |
|
e?.preventDefault(); |
|
if ((userInput.trim() || selectedFile) && !isLoading) { |
|
onSendMessage(userInput.trim(), selectedFile || undefined); |
|
setUserInput(''); |
|
clearSelectedFile(); |
|
} |
|
}; |
|
|
|
const handleSuggestionClick = (promptText: string) => { |
|
if (!isLoading) { |
|
onSendMessage(promptText, undefined, true); |
|
setUserInput(''); |
|
clearSelectedFile(); |
|
} |
|
}; |
|
|
|
const lastMessage = activeChatSession?.messages?.[activeChatSession.messages.length - 1]; |
|
const modelIsCurrentlyStreaming = isLoading && lastMessage?.sender === 'model' && lastMessage?.isStreaming; |
|
const modelIsThinking = isLoading && !modelIsCurrentlyStreaming; |
|
|
|
let placeholderText = "Describe tu audiencia y el objetivo de tu Reel..."; |
|
if (modelIsThinking) { |
|
placeholderText = "ReelBot está pensando..."; |
|
} else if (modelIsCurrentlyStreaming) { |
|
placeholderText = "ReelBot está escribiendo..."; |
|
} else if (selectedFile) { |
|
placeholderText = "Añade un comentario sobre el archivo..."; |
|
} |
|
|
|
|
|
return ( |
|
<div className="flex-1 flex flex-col h-full bg-slate-900 overflow-hidden"> {/* Added overflow-hidden */} |
|
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-4"> {/* Adjusted padding for consistency */} |
|
{!activeChatSession || activeChatSession.messages.length === 0 ? ( |
|
<div className="flex flex-col items-center justify-center h-full text-center p-4 md:-translate-y-[30px]"> |
|
<img |
|
src={REELBOT_IMAGE_URL} |
|
alt="ReelBot Logo" |
|
className="w-full max-w-[200px] sm:max-w-[300px] md:max-w-[400px] lg:max-w-[500px] h-auto mb-4 md:mb-6 shadow-lg object-contain" |
|
/> |
|
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-bold text-cyan-400 mb-2 -mt-2.5"> |
|
Reel Creator AI |
|
</h2> |
|
<p className="text-slate-400 mb-3 text-base sm:text-lg lg:text-xl">By Jesús Cabrera</p> |
|
<p className="text-slate-300 mb-6 md:mb-8 max-w-md sm:max-w-lg md:max-w-xl lg:max-w-2xl text-lg sm:text-xl lg:text-2xl flex items-center justify-center"> |
|
<FilmIcon className="w-5 h-5 mr-2 flex-shrink-0" /> |
|
Experto en crear Reels virales que convierten visualizaciones en clientes |
|
</p> |
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full max-w-2xl"> |
|
{SUGGESTION_PROMPTS.map((prompt) => ( |
|
<SuggestionButton |
|
key={prompt.text} |
|
text={prompt.text} |
|
// emoji prop removed |
|
onClick={() => handleSuggestionClick(prompt.text)} |
|
disabled={isLoading} |
|
/> |
|
))} |
|
</div> |
|
</div> |
|
) : ( |
|
activeChatSession.messages.map((msg) => ( |
|
<ChatMessageItem key={msg.id} message={msg} /> |
|
)) |
|
)} |
|
<div ref={messagesEndRef} /> |
|
{error && <div className="text-red-400 p-2 bg-red-900/50 rounded-md text-sm">{error}</div>} |
|
</div> |
|
|
|
<div className="p-4 border-t border-slate-700 bg-slate-900"> {/* Removed sticky and z-index, handled by App.tsx structure */} |
|
{selectedFile && ( |
|
<div className="mb-2 p-2 bg-slate-800 rounded-md flex items-center justify-between"> |
|
<div className="flex items-center space-x-2 overflow-hidden"> |
|
{selectedFile.dataUrl && ALLOWED_IMAGE_TYPES.includes(selectedFile.type) ? ( |
|
<img src={selectedFile.dataUrl} alt="Preview" className="w-10 h-10 rounded object-cover" /> |
|
) : ( |
|
<DocumentTextIcon className="w-8 h-8 text-slate-400 flex-shrink-0" /> |
|
)} |
|
<span className="text-xs text-slate-300 truncate" title={selectedFile.name}> |
|
{selectedFile.name} |
|
</span> |
|
</div> |
|
<button onClick={clearSelectedFile} className="text-slate-400 hover:text-slate-200" disabled={isLoading}> |
|
<XCircleIconFile className="w-5 h-5" /> |
|
</button> |
|
</div> |
|
)} |
|
{fileError && <p className="text-red-400 text-xs mb-2">{fileError}</p>} |
|
|
|
<form onSubmit={handleSubmit} className="flex items-center space-x-3"> |
|
<input |
|
type="file" |
|
ref={fileInputRef} |
|
onChange={handleFileChange} |
|
className="hidden" |
|
accept={ALLOWED_FILE_TYPES.join(',')} |
|
disabled={isLoading} |
|
/> |
|
<button |
|
type="button" |
|
onClick={() => fileInputRef.current?.click()} |
|
disabled={isLoading} |
|
className="p-3 bg-slate-700 hover:bg-slate-600 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg text-slate-300 hover:text-slate-100 transition-colors duration-150 focus:ring-2 focus:ring-cyan-500 focus:outline-none" |
|
aria-label="Attach file" |
|
> |
|
<PaperclipIcon className="w-5 h-5" /> |
|
</button> |
|
<input |
|
ref={inputRef} |
|
type="text" |
|
value={userInput} |
|
onChange={(e) => setUserInput(e.target.value)} |
|
placeholder={placeholderText} |
|
className="flex-1 p-3 bg-slate-800 border border-slate-700 rounded-lg text-slate-100 placeholder-slate-500 focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500 outline-none transition-shadow duration-150 disabled:opacity-70" |
|
disabled={isLoading} |
|
/> |
|
{isLoading ? ( |
|
<button |
|
type="button" |
|
onClick={onStopGeneration} |
|
className="p-3 bg-red-600 hover:bg-red-500 rounded-lg text-white transition-colors duration-150 focus:ring-2 focus:ring-red-400 focus:outline-none" |
|
aria-label="Stop generation" |
|
> |
|
<StopIcon className="w-5 h-5" /> |
|
</button> |
|
) : ( |
|
<button |
|
type="submit" |
|
disabled={(!userInput.trim() && !selectedFile) || isLoading} |
|
className="p-3 bg-cyan-600 hover:bg-cyan-500 disabled:bg-slate-600 disabled:cursor-not-allowed rounded-lg text-white transition-colors duration-150 focus:ring-2 focus:ring-cyan-400 focus:outline-none" |
|
aria-label="Send message" |
|
> |
|
<SendIcon className="w-5 h-5" /> |
|
</button> |
|
)} |
|
</form> |
|
</div> |
|
</div> |
|
); |
|
}; |
|
|