PROJECTS / src /components /Chat.tsx
Adeen
Initial Deployment
bb17288
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { StopCircle, CornerDownRight, Settings2, Menu } from 'lucide-react';
import { ChatMessage, MessageProps } from './ChatMessage';
import { Sidebar } from './Sidebar';
import { AnimatePresence } from 'framer-motion';
import { ApiService } from '@/lib/api';
export default function Chat() {
const [messages, setMessages] = useState<MessageProps[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const [isEnhanced, setIsEnhanced] = useState(true);
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [sessionHistory, setSessionHistory] = useState<Array<{ id: string, preview: string, timestamp: number }>>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages, isLoading]);
// Load session history from localstorage
useEffect(() => {
const stored = localStorage.getItem('nebula_history');
if (stored) {
try {
setSessionHistory(JSON.parse(stored));
} catch {
console.error("Could not parse history");
}
}
}, []);
const saveSessionToHistory = (id: string, preview: string) => {
setSessionHistory(prev => {
const filtered = prev.filter(s => s.id !== id);
const newList = [{ id, preview, timestamp: Date.now() }, ...filtered];
localStorage.setItem('nebula_history', JSON.stringify(newList));
return newList;
});
};
const loadSession = async (id: string) => {
setIsLoading(true);
setSessionId(id);
localStorage.setItem('nebula_session_id', id);
try {
const history = await ApiService.getHistory(id);
const formattedHistory: MessageProps[] = [];
history.forEach((h: { user_message: string; ai_response: string }, i: number) => {
formattedHistory.push({ id: `user-${i}`, role: 'user', content: h.user_message });
formattedHistory.push({ id: `bot-${i}`, role: 'bot', content: h.ai_response });
});
if (formattedHistory.length === 0) {
formattedHistory.push({ id: 'welcome', role: 'bot', content: 'Hello! I am Nexus AI. How can I help you today?' });
}
setMessages(formattedHistory);
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
if (window.innerWidth < 768) setIsSidebarOpen(false); // Close sidebar on mobile after select
}
};
const handleNewChat = async () => {
setIsLoading(true);
try {
const newId = await ApiService.createSession();
setSessionId(newId);
localStorage.setItem('nebula_session_id', newId as string);
setMessages([{ id: 'welcome', role: 'bot', content: 'Hello! I am Nexus AI. How can I help you today?', isNew: true }]);
if (window.innerWidth < 768) setIsSidebarOpen(false);
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
const initSession = async () => {
let storedSessionId = localStorage.getItem('nebula_session_id');
if (!storedSessionId) {
try {
storedSessionId = await ApiService.createSession() as string;
localStorage.setItem('nebula_session_id', storedSessionId);
} catch {
console.error("Failed to init session.");
return;
}
}
loadSession(storedSessionId as string);
};
initSession();
}, []);
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`;
}
};
const handleSend = async () => {
if (!input.trim() || isLoading || !sessionId) return;
const userMsg: MessageProps = {
id: Date.now().toString(),
role: 'user',
content: input.trim(),
};
const currentInputText = input.trim();
saveSessionToHistory(sessionId, currentInputText.substring(0, 40));
setMessages((prev) => [...prev, userMsg]);
setInput('');
setIsLoading(true);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
try {
const reply = await ApiService.sendMessage(currentInputText, sessionId, isEnhanced);
const botMsg: MessageProps = {
id: (Date.now() + 1).toString(),
role: 'bot',
content: reply,
isNew: true,
};
setMessages((prev) => [...prev, botMsg]);
} catch (error) {
console.error('Error sending message:', error);
const errorMsg: MessageProps = { id: (Date.now() + 1).toString(), role: 'bot', content: 'Error sending message. Please try again.', isNew: true };
setMessages((prev) => [...prev, errorMsg]);
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="flex h-screen w-full bg-background text-foreground overflow-hidden font-sans">
<Sidebar
isOpen={isSidebarOpen}
setIsOpen={setIsSidebarOpen}
onNewChat={handleNewChat}
sessions={sessionHistory}
currentSessionId={sessionId}
onSelectSession={loadSession}
/>
<div className="flex-1 flex flex-col h-full relative min-w-0 transition-all">
{/* Header Navbar */}
<header className="absolute top-0 w-full z-10 px-4 py-3 flex items-center justify-between pointer-events-none anti-gravity-header">
<div className="flex items-center gap-2 pointer-events-auto">
{!isSidebarOpen && (
<button
onClick={() => setIsSidebarOpen(true)}
className="p-2.5 hover:bg-white/10 rounded-full transition-transform active:scale-95 text-zinc-400"
>
<Menu size={20} />
</button>
)}
<h1 className="text-xl font-medium tracking-tight ml-2 text-slate-200">Nexus AI</h1>
</div>
<button
onClick={() => setIsEnhanced(!isEnhanced)}
className={`pointer-events-auto flex items-center gap-2 text-sm px-4 py-2 rounded-full transition-all active:scale-95 border ${isEnhanced ? 'bg-primary/20 border-primary text-white shadow-[0_0_15px_rgba(139,92,246,0.3)]' : 'bg-transparent border-transparent text-gray-400 hover:bg-white/5'}`}
>
<Settings2 size={16} />
<span className="hidden sm:inline font-medium">{isEnhanced ? "Enhanced" : "Basic"}</span>
</button>
</header>
{/* Main Scrollable Chat Area */}
<div className="flex-1 overflow-y-auto scroll-smooth pt-[80px] pb-[160px] custom-scrollbar">
<div className="max-w-3xl mx-auto w-full px-4 sm:px-6">
{messages.length === 1 && (
<div className="flex flex-col items-center justify-center mt-32 mb-16 text-center animate-fade-in">
<div className="w-20 h-20 mb-6 bg-white/5 rounded-full flex items-center justify-center backdrop-blur-sm border border-white/10 shadow-[0_0_30px_rgba(139,92,246,0.15)]">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" className="text-primary drop-shadow-[0_0_15px_rgba(139,92,246,0.5)]">
<path d="M12 2L14.809 9.19098L22 12L14.809 14.809L12 22L9.19098 14.809L2 12L9.19098 9.19098L12 2Z" fill="currentColor" />
</svg>
</div>
<h1 className="text-4xl sm:text-5xl font-semibold bg-clip-text text-transparent bg-gradient-to-r from-slate-200 via-primary to-slate-200 mb-4 tracking-tight">
Hello, how can I help?
</h1>
</div>
)}
<AnimatePresence initial={false}>
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
</AnimatePresence>
{isLoading && (
<div className="flex w-full mb-8 justify-start">
<div className="flex gap-4 flex-row items-start">
<div className="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center mt-1 bg-transparent border border-white/5">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" className="text-primary animate-spin-slow">
<path d="M12 2L14.809 9.19098L22 12L14.809 14.809L12 22L9.19098 14.809L2 12L9.19098 9.19098L12 2Z" fill="currentColor" />
</svg>
</div>
<div className="anti-gravity-panel px-5 py-3 rounded-[24px] rounded-bl-sm flex items-center h-[48px] mt-1">
<div className="flex gap-1.5">
<span className="w-1.5 h-1.5 bg-primary/80 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1.5 h-1.5 bg-primary/80 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1.5 h-1.5 bg-primary/80 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} className="h-4" />
</div>
</div>
{/* Bottom Floating Input Area */}
<div className="absolute bottom-0 left-0 right-0 p-4 sm:p-6 pb-8 bg-gradient-to-t from-background via-background/90 to-transparent pointer-events-none">
<div className="max-w-3xl mx-auto w-full relative pointer-events-auto">
<div className="relative anti-gravity-panel rounded-[32px] transition-all flex items-end focus-within:ring-1 focus-within:ring-primary/50">
<textarea
ref={textareaRef}
value={input}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder="Enter a prompt here..."
className="w-full bg-transparent text-slate-200 placeholder:text-slate-500 px-6 py-4 resize-none outline-none text-[16px] leading-[1.65] max-h-[200px]"
rows={1}
/>
<div className="p-2 sm:p-2.5 shrink-0 flex items-center pr-2.5 mb-1">
{isLoading ? (
<button disabled className="p-2 text-slate-500">
<StopCircle size={22} className="animate-pulse" />
</button>
) : (
<button
onClick={handleSend}
disabled={!input.trim()}
className={`p-2.5 rounded-full transition-all active:scale-90 flex items-center justify-center
${!input.trim()
? 'bg-transparent text-slate-600 cursor-not-allowed'
: 'bg-primary text-white shadow-[0_0_15px_rgba(139,92,246,0.4)] hover:shadow-[0_0_20px_rgba(139,92,246,0.6)] hover:bg-primary-hover'
}`}
>
<CornerDownRight size={20} className="stroke-[2.5]" />
</button>
)}
</div>
</div>
<p className="text-center text-[11px] text-slate-400/60 mt-4 font-medium tracking-wide w-full px-4">
Nexus AI answers can be inaccurate. Please double-check information.
</p>
</div>
</div>
</div>
</div>
);
}