Spaces:
Sleeping
Sleeping
| import React, { useState, useRef, useEffect } from 'react'; | |
| import { Button } from './ui/button'; | |
| import { Textarea } from './ui/textarea'; | |
| import { Send, ArrowDown, AlertCircle, Trash2, Share2 } from 'lucide-react'; | |
| import { Message } from './Message'; | |
| import { FileUploadArea } from './FileUploadArea'; | |
| import { MemoryLine } from './MemoryLine'; | |
| import { Alert, AlertDescription } from './ui/alert'; | |
| import { Badge } from './ui/badge'; | |
| import type { Message as MessageType, LearningMode, UploadedFile, FileType, SpaceType } from '../App'; | |
| import { toast } from 'sonner'; | |
| import { | |
| DropdownMenu, | |
| DropdownMenuContent, | |
| DropdownMenuItem, | |
| DropdownMenuTrigger, | |
| } from './ui/dropdown-menu'; | |
| interface ChatAreaProps { | |
| messages: MessageType[]; | |
| onSendMessage: (content: string) => void; | |
| uploadedFiles: UploadedFile[]; | |
| onFileUpload: (files: File[]) => void; | |
| onRemoveFile: (index: number) => void; | |
| onFileTypeChange: (index: number, type: FileType) => void; | |
| memoryProgress: number; | |
| isLoggedIn: boolean; | |
| learningMode: LearningMode; | |
| onClearConversation: () => void; | |
| onLearningModeChange: (mode: LearningMode) => void; | |
| spaceType: SpaceType; | |
| } | |
| export function ChatArea({ | |
| messages, | |
| onSendMessage, | |
| uploadedFiles, | |
| onFileUpload, | |
| onRemoveFile, | |
| onFileTypeChange, | |
| memoryProgress, | |
| isLoggedIn, | |
| learningMode, | |
| onClearConversation, | |
| onLearningModeChange, | |
| spaceType, | |
| }: ChatAreaProps) { | |
| const [input, setInput] = useState(''); | |
| const [isTyping, setIsTyping] = useState(false); | |
| const [showScrollButton, setShowScrollButton] = useState(false); | |
| const messagesEndRef = useRef<HTMLDivElement>(null); | |
| const scrollContainerRef = useRef<HTMLDivElement>(null); | |
| const scrollToBottom = () => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); | |
| }; | |
| useEffect(() => { | |
| scrollToBottom(); | |
| }, [messages]); | |
| useEffect(() => { | |
| const handleScroll = () => { | |
| if (scrollContainerRef.current) { | |
| const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; | |
| setShowScrollButton(scrollHeight - scrollTop - clientHeight > 100); | |
| } | |
| }; | |
| const container = scrollContainerRef.current; | |
| container?.addEventListener('scroll', handleScroll); | |
| return () => container?.removeEventListener('scroll', handleScroll); | |
| }, []); | |
| const handleSubmit = (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if (!input.trim() || !isLoggedIn) return; | |
| onSendMessage(input); | |
| setInput(''); | |
| setIsTyping(true); | |
| setTimeout(() => setIsTyping(false), 1500); | |
| }; | |
| const handleKeyDown = (e: React.KeyboardEvent) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSubmit(e); | |
| } | |
| }; | |
| const modeLabels: Record<LearningMode, string> = { | |
| concept: 'Concept Explainer', | |
| socratic: 'Socratic Tutor', | |
| exam: 'Exam Prep', | |
| assignment: 'Assignment Helper', | |
| summary: 'Quick Summary', | |
| }; | |
| const handleClearClick = () => { | |
| if (messages.length <= 1) { | |
| toast.info('No conversation to clear'); | |
| return; | |
| } | |
| if (window.confirm('Are you sure you want to clear the conversation? This cannot be undone.')) { | |
| onClearConversation(); | |
| toast.success('Conversation cleared'); | |
| } | |
| }; | |
| const handleShareClick = () => { | |
| if (messages.length <= 1) { | |
| toast.info('No conversation to share'); | |
| return; | |
| } | |
| // Create a shareable text version of the conversation | |
| const conversationText = messages | |
| .map(msg => `${msg.sender === 'user' ? 'You' : 'Clare'}: ${msg.content}`) | |
| .join('\n\n'); | |
| // Copy to clipboard | |
| navigator.clipboard.writeText(conversationText).then(() => { | |
| toast.success('Conversation copied to clipboard!'); | |
| }).catch(() => { | |
| toast.error('Failed to copy conversation'); | |
| }); | |
| }; | |
| return ( | |
| <div className="flex flex-col h-full"> | |
| {/* Chat Area with Floating Input */} | |
| <div className="flex-1 relative border-b-2 border-border"> | |
| {/* Action Buttons - Fixed at top right */} | |
| {messages.length > 1 && ( | |
| <div className="absolute top-4 right-12 z-10 flex gap-2"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={handleShareClick} | |
| disabled={!isLoggedIn} | |
| className="gap-2 bg-background/95 backdrop-blur-sm shadow-sm hover:shadow-md transition-all group" | |
| > | |
| <Share2 className="h-4 w-4" /> | |
| <span className="hidden group-hover:inline">Share</span> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={handleClearClick} | |
| disabled={!isLoggedIn} | |
| className="gap-2 bg-background/95 backdrop-blur-sm shadow-sm hover:shadow-md transition-all group" | |
| > | |
| <Trash2 className="h-4 w-4" /> | |
| <span className="hidden group-hover:inline">Clear</span> | |
| </Button> | |
| </div> | |
| )} | |
| {/* Messages Area */} | |
| <div | |
| ref={scrollContainerRef} | |
| className="h-full max-h-[600px] overflow-y-auto px-4 py-6 pb-36" | |
| > | |
| <div className="max-w-4xl mx-auto space-y-6"> | |
| {messages.map((message) => ( | |
| <Message | |
| key={message.id} | |
| message={message} | |
| showSenderInfo={spaceType === 'group'} | |
| /> | |
| ))} | |
| {isTyping && ( | |
| <div className="flex gap-3"> | |
| <div className="w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-blue-500 flex items-center justify-center flex-shrink-0"> | |
| <span className="text-white text-sm">C</span> | |
| </div> | |
| <div className="bg-muted rounded-2xl px-4 py-3"> | |
| <div className="flex gap-1"> | |
| <div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: '0ms' }} /> | |
| <div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: '150ms' }} /> | |
| <div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: '300ms' }} /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| </div> | |
| {/* Scroll to Bottom Button - Floating above input */} | |
| {showScrollButton && ( | |
| <div className="absolute bottom-24 left-1/2 -translate-x-1/2 z-20"> | |
| <Button | |
| variant="secondary" | |
| size="icon" | |
| className="rounded-full shadow-lg hover:shadow-xl transition-shadow bg-background" | |
| onClick={scrollToBottom} | |
| > | |
| <ArrowDown className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| )} | |
| {/* Floating Input Area */} | |
| <div className="absolute bottom-0 left-0 right-0 bg-background/95 backdrop-blur-sm z-10"> | |
| <div className="max-w-4xl mx-auto px-4 py-4"> | |
| <form onSubmit={handleSubmit}> | |
| <div className="relative"> | |
| {/* Mode Selector - ChatGPT style at bottom left */} | |
| <DropdownMenu> | |
| <DropdownMenuTrigger asChild> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="absolute bottom-3 left-2 gap-1.5 h-8 px-2 text-xs z-10 hover:bg-muted/50" | |
| disabled={!isLoggedIn} | |
| type="button" | |
| > | |
| <span>{modeLabels[learningMode]}</span> | |
| <svg | |
| className="h-3 w-3 opacity-50" | |
| fill="none" | |
| stroke="currentColor" | |
| viewBox="0 0 24 24" | |
| > | |
| <path | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| strokeWidth={2} | |
| d="M19 9l-7 7-7-7" | |
| /> | |
| </svg> | |
| </Button> | |
| </DropdownMenuTrigger> | |
| <DropdownMenuContent align="start" className="w-56"> | |
| <DropdownMenuItem | |
| onClick={() => onLearningModeChange('concept')} | |
| className={learningMode === 'concept' ? 'bg-accent' : ''} | |
| > | |
| <div className="flex flex-col"> | |
| <span className="font-medium">Concept Explainer</span> | |
| <span className="text-xs text-muted-foreground"> | |
| Get detailed explanations of concepts | |
| </span> | |
| </div> | |
| </DropdownMenuItem> | |
| <DropdownMenuItem | |
| onClick={() => onLearningModeChange('socratic')} | |
| className={learningMode === 'socratic' ? 'bg-accent' : ''} | |
| > | |
| <div className="flex flex-col"> | |
| <span className="font-medium">Socratic Tutor</span> | |
| <span className="text-xs text-muted-foreground"> | |
| Learn through guided questions | |
| </span> | |
| </div> | |
| </DropdownMenuItem> | |
| <DropdownMenuItem | |
| onClick={() => onLearningModeChange('exam')} | |
| className={learningMode === 'exam' ? 'bg-accent' : ''} | |
| > | |
| <div className="flex flex-col"> | |
| <span className="font-medium">Exam Prep</span> | |
| <span className="text-xs text-muted-foreground"> | |
| Practice with quiz questions | |
| </span> | |
| </div> | |
| </DropdownMenuItem> | |
| <DropdownMenuItem | |
| onClick={() => onLearningModeChange('assignment')} | |
| className={learningMode === 'assignment' ? 'bg-accent' : ''} | |
| > | |
| <div className="flex flex-col"> | |
| <span className="font-medium">Assignment Helper</span> | |
| <span className="text-xs text-muted-foreground"> | |
| Get help with assignments | |
| </span> | |
| </div> | |
| </DropdownMenuItem> | |
| <DropdownMenuItem | |
| onClick={() => onLearningModeChange('summary')} | |
| className={learningMode === 'summary' ? 'bg-accent' : ''} | |
| > | |
| <div className="flex flex-col"> | |
| <span className="font-medium">Quick Summary</span> | |
| <span className="text-xs text-muted-foreground"> | |
| Get concise summaries | |
| </span> | |
| </div> | |
| </DropdownMenuItem> | |
| </DropdownMenuContent> | |
| </DropdownMenu> | |
| <Textarea | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder={ | |
| isLoggedIn | |
| ? spaceType === 'group' | |
| ? "Type a message... (mention @Clare to get AI assistance)" | |
| : "Ask Clare anything about the course..." | |
| : "Please log in on the right to start chatting..." | |
| } | |
| disabled={!isLoggedIn} | |
| className="min-h-[80px] pl-4 pr-12 resize-none bg-background border-2 border-border" | |
| /> | |
| <Button | |
| type="submit" | |
| size="icon" | |
| disabled={!input.trim() || !isLoggedIn} | |
| className="absolute bottom-2 right-2 rounded-full" | |
| > | |
| <Send className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Course Materials Section */} | |
| <div className="bg-card"> | |
| <div className="max-w-4xl mx-auto px-4 py-4"> | |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> | |
| <FileUploadArea | |
| uploadedFiles={uploadedFiles} | |
| onFileUpload={onFileUpload} | |
| onRemoveFile={onRemoveFile} | |
| onFileTypeChange={onFileTypeChange} | |
| disabled={!isLoggedIn} | |
| /> | |
| <MemoryLine progress={memoryProgress} /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } |