ReelBot / components /ChatView.tsx
JeCabrera's picture
Upload 19 files
2accaeb verified
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>
);
};