← back to Dear Bubbe Nextjs
components/BubbeChatModal.tsx
276 lines
'use client'
import { useState, useEffect, useRef } from 'react'
import Image from 'next/image'
interface BubbeChatModalProps {
isOpen: boolean
onClose: () => void
onSendMessage: (message: string) => void
messages: Array<{
role: 'user' | 'assistant' | 'system'
content: string
timestamp: Date
}>
isListening: boolean
isBubbeSpeaking: boolean
transcript: string
onStartListening: () => void
onStopListening: () => void
voiceEnabled: boolean
onToggleVoice: () => void
}
export default function BubbeChatModal({
isOpen,
onClose,
onSendMessage,
messages,
isListening,
isBubbeSpeaking,
transcript,
onStartListening,
onStopListening,
voiceEnabled,
onToggleVoice
}: BubbeChatModalProps) {
const [input, setInput] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Focus input when modal opens
useEffect(() => {
if (isOpen) {
setTimeout(() => {
inputRef.current?.focus()
}, 100)
}
}, [isOpen])
// Listen for voice messages from speech recognition
useEffect(() => {
const handleChatModalSendMessage = (event: CustomEvent) => {
const { message } = event.detail
if (message && message.trim()) {
console.log('[CHAT_MODAL] Received voice message:', message)
onSendMessage(message.trim())
}
}
if (isOpen) {
window.addEventListener('chatModalSendMessage', handleChatModalSendMessage as EventListener)
}
return () => {
window.removeEventListener('chatModalSendMessage', handleChatModalSendMessage as EventListener)
}
}, [isOpen, onSendMessage])
const handleSend = () => {
if (input.trim()) {
onSendMessage(input.trim())
setInput('')
}
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 md:p-4 sm:p-2">
<div className="bg-white rounded-2xl max-w-4xl w-full max-h-[85vh] flex flex-col shadow-2xl md:rounded-2xl sm:rounded-lg md:max-h-[85vh] sm:max-h-[95vh] sm:h-full">
{/* Header with Logo */}
<div className="bg-gradient-to-r from-amber-500 to-orange-600 p-4 rounded-t-2xl md:rounded-t-2xl sm:rounded-t-lg md:p-4 sm:p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 md:gap-3 sm:gap-2">
{/* Bubbe Logo/Avatar */}
<div className="w-16 h-16 md:w-16 md:h-16 sm:w-12 sm:h-12 bg-white rounded-full flex items-center justify-center shadow-lg">
<span className="text-3xl md:text-3xl sm:text-2xl">👵</span>
</div>
<div>
<h2 className="text-2xl md:text-2xl sm:text-xl font-bold text-white">Bubbe Chat</h2>
<p className="text-white/90 text-sm md:text-sm sm:text-xs">Your AI Jewish Grandmother</p>
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-2 md:gap-2 sm:gap-1">
{/* Voice Toggle */}
<button
onClick={onToggleVoice}
className={`px-3 py-2 md:px-3 md:py-2 sm:px-2 sm:py-2 min-w-[44px] min-h-[44px] rounded-lg transition-colors touch-manipulation ${
voiceEnabled
? 'bg-green-500 text-white hover:bg-green-600 active:bg-green-700'
: 'bg-gray-300 text-gray-700 hover:bg-gray-400 active:bg-gray-500'
}`}
title={voiceEnabled ? 'Voice On' : 'Voice Off'}
>
<span className="text-lg md:text-base sm:text-sm">{voiceEnabled ? '🔊' : '🔇'}</span>
</button>
{/* Close Button */}
<button
onClick={onClose}
className="text-white hover:text-gray-200 active:text-gray-300 transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center touch-manipulation"
aria-label="Close"
>
<svg className="w-6 h-6 md:w-6 md:h-6 sm:w-5 sm:h-5" 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>
</div>
</div>
{/* Status Bar */}
{(isListening || isBubbeSpeaking) && (
<div className="bg-amber-100 px-4 py-2 border-b border-amber-200">
<div className="flex items-center gap-2">
{isBubbeSpeaking && (
<div className="flex items-center gap-2">
<div className="flex gap-1">
<div className="w-2 h-2 bg-amber-600 rounded-full animate-pulse"></div>
<div className="w-2 h-2 bg-amber-600 rounded-full animate-pulse delay-75"></div>
<div className="w-2 h-2 bg-amber-600 rounded-full animate-pulse delay-150"></div>
</div>
<span className="text-sm text-amber-800">Bubbe is speaking...</span>
</div>
)}
{isListening && !isBubbeSpeaking && (
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
<span className="text-sm text-red-800">Listening...</span>
{transcript && (
<span className="text-sm text-gray-600 italic">{transcript}</span>
)}
</div>
)}
</div>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 md:p-4 sm:p-3 space-y-3 md:space-y-3 sm:space-y-2">
{messages.map((message, index) => (
<div
key={index}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
{message.role === 'assistant' && (
<div className="w-10 h-10 md:w-10 md:h-10 sm:w-8 sm:h-8 bg-amber-200 rounded-full flex items-center justify-center mr-2 flex-shrink-0">
<span className="text-xl md:text-xl sm:text-lg">👵</span>
</div>
)}
<div
className={`max-w-[70%] md:max-w-[70%] sm:max-w-[80%] p-3 md:p-3 sm:p-2 rounded-lg ${
message.role === 'user'
? 'bg-blue-500 text-white'
: message.role === 'system'
? 'bg-gray-200 text-gray-700 italic'
: 'bg-gray-100 text-gray-800'
}`}
>
<p className="whitespace-pre-wrap">{message.content}</p>
<p className="text-xs opacity-70 mt-1">
{new Date(message.timestamp).toLocaleTimeString()}
</p>
</div>
{message.role === 'user' && (
<div className="w-10 h-10 md:w-10 md:h-10 sm:w-8 sm:h-8 bg-blue-200 rounded-full flex items-center justify-center ml-2 flex-shrink-0">
<span className="text-xl md:text-xl sm:text-lg">👤</span>
</div>
)}
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="border-t border-gray-200 p-4 md:p-4 sm:p-3 bg-gray-50 rounded-b-2xl md:rounded-b-2xl sm:rounded-b-lg">
<div className="flex gap-2 md:gap-2 sm:gap-1">
{/* Voice Input Button */}
{voiceEnabled && (
<button
onClick={isListening ? onStopListening : onStartListening}
className={`px-4 py-2 md:px-4 md:py-2 sm:px-3 sm:py-2 min-w-[44px] min-h-[44px] rounded-lg transition-colors touch-manipulation ${
isListening
? 'bg-red-500 text-white hover:bg-red-600 active:bg-red-700'
: 'bg-amber-500 text-white hover:bg-amber-600 active:bg-amber-700'
}`}
disabled={isBubbeSpeaking}
>
<span className="text-sm md:text-sm sm:text-xs">{isListening ? '🛑 Stop' : '🎤 Talk'}</span>
</button>
)}
{/* Text Input */}
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="Type your message to Bubbe..."
className="flex-1 px-4 py-2 md:px-4 md:py-2 sm:px-3 sm:py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-500 resize-none text-base"
style={{ fontSize: '16px', WebkitAppearance: 'none' }}
rows={1}
disabled={isListening || isBubbeSpeaking}
autoComplete="off"
autoCorrect="on"
autoCapitalize="sentences"
spellCheck="true"
enterKeyHint="send"
/>
{/* Send Button */}
<button
onClick={handleSend}
disabled={!input.trim() || isListening || isBubbeSpeaking}
className="px-4 py-2 md:px-4 md:py-2 sm:px-3 sm:py-2 min-w-[44px] min-h-[44px] bg-amber-500 text-white rounded-lg hover:bg-amber-600 active:bg-amber-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors touch-manipulation"
>
<span className="text-sm md:text-sm sm:text-xs">Send</span>
</button>
</div>
{/* Quick Actions */}
<div className="mt-2 flex gap-2 md:gap-2 sm:gap-1 flex-wrap">
<button
onClick={() => onSendMessage("Tell me about the weather")}
className="text-xs md:text-xs sm:text-xs px-3 py-1 md:px-3 md:py-1 sm:px-2 sm:py-1 min-h-[36px] bg-gray-200 text-gray-700 rounded-full hover:bg-gray-300 active:bg-gray-400 transition-colors touch-manipulation"
>
Weather
</button>
<button
onClick={() => onSendMessage("Give me some news")}
className="text-xs md:text-xs sm:text-xs px-3 py-1 md:px-3 md:py-1 sm:px-2 sm:py-1 min-h-[36px] bg-gray-200 text-gray-700 rounded-full hover:bg-gray-300 active:bg-gray-400 transition-colors touch-manipulation"
>
News
</button>
<button
onClick={() => onSendMessage("I need advice")}
className="text-xs md:text-xs sm:text-xs px-3 py-1 md:px-3 md:py-1 sm:px-2 sm:py-1 min-h-[36px] bg-gray-200 text-gray-700 rounded-full hover:bg-gray-300 active:bg-gray-400 transition-colors touch-manipulation"
>
Advice
</button>
<button
onClick={() => onSendMessage("Tell me a joke")}
className="text-xs md:text-xs sm:text-xs px-3 py-1 md:px-3 md:py-1 sm:px-2 sm:py-1 min-h-[36px] bg-gray-200 text-gray-700 rounded-full hover:bg-gray-300 active:bg-gray-400 transition-colors touch-manipulation"
>
Joke
</button>
</div>
</div>
</div>
</div>
)
}