546 lines
24 KiB
TypeScript
546 lines
24 KiB
TypeScript
|
||
|
||
import React, { useState, useRef, useEffect } from 'react';
|
||
import { ChatMessage, Role, MessageType, Language, ChatSession } from '../types';
|
||
import { geminiService } from '../services/geminiService';
|
||
import ChatBubble from '../components/ChatBubble';
|
||
import AudioRecorder from '../components/AudioRecorder';
|
||
import { Send, Image as ImageIcon, BrainCircuit, Loader2, Plus, History, MessageSquare, Trash2, X, Sparkles, PanelRightClose, PanelRightOpen, Share2, Download, FileText, Image as ImageIconLucide, Languages } from 'lucide-react';
|
||
import { translations } from '../utils/localization';
|
||
import html2canvas from 'html2canvas';
|
||
|
||
interface ChatViewProps {
|
||
language: Language;
|
||
sessions: ChatSession[];
|
||
activeSessionId: string;
|
||
onNewSession: () => void;
|
||
onSelectSession: (id: string) => void;
|
||
onDeleteSession: (id: string) => void;
|
||
onClearAllSessions: () => void;
|
||
onUpdateSession: (id: string, messages: ChatMessage[]) => void;
|
||
selectedModel?: string;
|
||
addToast: (type: 'success' | 'error' | 'info', msg: string) => void;
|
||
}
|
||
|
||
const ChatView: React.FC<ChatViewProps> = ({
|
||
language,
|
||
sessions,
|
||
activeSessionId,
|
||
onNewSession,
|
||
onSelectSession,
|
||
onDeleteSession,
|
||
onClearAllSessions,
|
||
onUpdateSession,
|
||
selectedModel,
|
||
addToast
|
||
}) => {
|
||
const t = translations[language].chat;
|
||
const tCommon = translations[language].common;
|
||
|
||
const activeSession = sessions.find(s => s.id === activeSessionId) || sessions[0];
|
||
const messages = activeSession ? activeSession.messages : [];
|
||
|
||
const [inputValue, setInputValue] = useState('');
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [useThinking, setUseThinking] = useState(false);
|
||
const [attachedImage, setAttachedImage] = useState<string | null>(null);
|
||
|
||
// Settings State
|
||
const [aiSpeakingLanguage, setAiSpeakingLanguage] = useState<'ja' | 'native'>('ja');
|
||
const [isShareMenuOpen, setIsShareMenuOpen] = useState(false);
|
||
|
||
// History Sidebar State - Default Closed as requested
|
||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||
|
||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const scrollToBottom = () => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
};
|
||
|
||
useEffect(() => {
|
||
scrollToBottom();
|
||
}, [messages, activeSessionId]);
|
||
|
||
// Close share menu on click outside
|
||
useEffect(() => {
|
||
const handleClick = () => setIsShareMenuOpen(false);
|
||
if (isShareMenuOpen) window.addEventListener('click', handleClick);
|
||
return () => window.removeEventListener('click', handleClick);
|
||
}, [isShareMenuOpen]);
|
||
|
||
const handleUpdateMessage = (updatedMsg: ChatMessage) => {
|
||
const updatedMessages = messages.map(m => m.id === updatedMsg.id ? updatedMsg : m);
|
||
onUpdateSession(activeSessionId, updatedMessages);
|
||
};
|
||
|
||
const handleSendMessage = async () => {
|
||
if ((!inputValue.trim() && !attachedImage) || isLoading) return;
|
||
|
||
const currentText = inputValue;
|
||
const currentImage = attachedImage;
|
||
|
||
setInputValue('');
|
||
setAttachedImage(null);
|
||
setIsLoading(true);
|
||
|
||
// 1. Construct User Message
|
||
const userMsg: ChatMessage = {
|
||
id: Date.now().toString(),
|
||
role: Role.USER,
|
||
type: MessageType.TEXT,
|
||
content: currentText,
|
||
timestamp: Date.now(),
|
||
metadata: { imageUrl: currentImage || undefined }
|
||
};
|
||
|
||
// IMPORTANT: Calculate new history locally to avoid stale closure issues after await
|
||
const messagesWithUser = [...messages, userMsg];
|
||
|
||
// Update UI immediately with user message
|
||
onUpdateSession(activeSessionId, messagesWithUser);
|
||
|
||
try {
|
||
// 2. Get Response
|
||
const result = await geminiService.generateTextResponse(
|
||
currentText || "Describe this image",
|
||
currentImage || undefined,
|
||
useThinking,
|
||
language,
|
||
selectedModel,
|
||
aiSpeakingLanguage
|
||
);
|
||
|
||
// 3. TTS (if short and not thinking)
|
||
let ttsAudio: string | null = null;
|
||
if (!useThinking && result.text.length < 300) {
|
||
try { ttsAudio = await geminiService.generateSpeech(result.text); } catch (e) {}
|
||
}
|
||
|
||
const aiMsg: ChatMessage = {
|
||
id: (Date.now() + 1).toString(),
|
||
role: Role.MODEL,
|
||
type: MessageType.TEXT,
|
||
content: result.text,
|
||
model: result.model,
|
||
timestamp: Date.now(),
|
||
metadata: { isThinking: useThinking, audioUrl: ttsAudio || undefined }
|
||
};
|
||
|
||
// 4. Add AI Message to the LOCALLY calculated history (messagesWithUser)
|
||
// This ensures we don't lose the user message we just added
|
||
onUpdateSession(activeSessionId, [...messagesWithUser, aiMsg]);
|
||
|
||
} catch (error: any) {
|
||
const errorMsg = error?.message || t.error;
|
||
const errorMsgObj: ChatMessage = {
|
||
id: Date.now().toString(),
|
||
role: Role.MODEL,
|
||
type: MessageType.TEXT,
|
||
content: `${t.error}\n(${errorMsg})`,
|
||
timestamp: Date.now()
|
||
};
|
||
onUpdateSession(activeSessionId, [...messagesWithUser, errorMsgObj]);
|
||
} finally {
|
||
setIsLoading(false);
|
||
setUseThinking(false);
|
||
}
|
||
};
|
||
|
||
const handleAudioInput = async (base64Audio: string) => {
|
||
setIsLoading(true);
|
||
try {
|
||
// 1. Transcribe first (async)
|
||
const transcription = await geminiService.transcribeAudio(base64Audio);
|
||
|
||
const userMsg: ChatMessage = {
|
||
id: Date.now().toString(),
|
||
role: Role.USER,
|
||
type: MessageType.AUDIO,
|
||
content: `${t.transcribedPrefix}${transcription}`,
|
||
timestamp: Date.now(),
|
||
metadata: { audioUrl: base64Audio, transcription: transcription }
|
||
};
|
||
|
||
// 2. Update UI with User Message
|
||
const messagesWithUser = [...messages, userMsg];
|
||
onUpdateSession(activeSessionId, messagesWithUser);
|
||
|
||
// 3. Generate AI Response
|
||
const result = await geminiService.generateTextResponse(transcription, undefined, false, language, selectedModel, aiSpeakingLanguage);
|
||
const ttsAudio = await geminiService.generateSpeech(result.text);
|
||
|
||
const aiMsg: ChatMessage = {
|
||
id: (Date.now() + 1).toString(),
|
||
role: Role.MODEL,
|
||
type: MessageType.TEXT,
|
||
content: result.text,
|
||
model: result.model,
|
||
timestamp: Date.now(),
|
||
metadata: { audioUrl: ttsAudio || undefined }
|
||
};
|
||
|
||
// 4. Update UI with AI Message using local history
|
||
onUpdateSession(activeSessionId, [...messagesWithUser, aiMsg]);
|
||
|
||
} catch (e) {
|
||
console.error(e);
|
||
addToast('error', t.error);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) {
|
||
const reader = new FileReader();
|
||
reader.onloadend = () => {
|
||
setAttachedImage(reader.result as string);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
};
|
||
|
||
// Share Handlers
|
||
const shareAsText = () => {
|
||
const text = messages.map(m => `[${new Date(m.timestamp).toLocaleString()}] ${m.role === Role.USER ? 'User' : 'Sakura'}: ${m.content}`).join('\n\n');
|
||
navigator.clipboard.writeText(text);
|
||
addToast('success', tCommon.copied);
|
||
};
|
||
|
||
const shareAsFile = () => {
|
||
const text = messages.map(m => `[${new Date(m.timestamp).toLocaleString()}] ${m.role === Role.USER ? 'User' : 'Sakura'}: ${m.content}`).join('\n\n');
|
||
const blob = new Blob([text], { type: 'text/plain' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `sakura_chat_${Date.now()}.txt`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
const shareAsImage = async () => {
|
||
if (!messagesContainerRef.current) return;
|
||
addToast('info', 'Generating image...');
|
||
|
||
// Clone the element to capture full content
|
||
const original = messagesContainerRef.current;
|
||
const clone = original.cloneNode(true) as HTMLElement;
|
||
|
||
// We need to maintain the width to ensure text wrapping is identical
|
||
const width = original.offsetWidth;
|
||
|
||
clone.style.width = `${width}px`;
|
||
clone.style.height = 'auto';
|
||
clone.style.maxHeight = 'none';
|
||
clone.style.overflow = 'visible';
|
||
clone.style.position = 'absolute';
|
||
clone.style.top = '-9999px';
|
||
clone.style.left = '0';
|
||
clone.style.background = '#f8fafc'; // Match bg-slate-50
|
||
clone.style.zIndex = '-1';
|
||
|
||
document.body.appendChild(clone);
|
||
|
||
try {
|
||
// Small delay to ensure DOM rendering
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
|
||
const canvas = await html2canvas(clone, {
|
||
useCORS: true,
|
||
scale: 2, // Higher res
|
||
backgroundColor: '#f8fafc',
|
||
windowWidth: width,
|
||
height: clone.scrollHeight,
|
||
windowHeight: clone.scrollHeight
|
||
});
|
||
|
||
const url = canvas.toDataURL('image/png');
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `sakura_chat_${Date.now()}.png`;
|
||
a.click();
|
||
} catch (e) {
|
||
console.error(e);
|
||
addToast('error', 'Failed to generate image');
|
||
} finally {
|
||
if (document.body.contains(clone)) {
|
||
document.body.removeChild(clone);
|
||
}
|
||
}
|
||
};
|
||
|
||
// --- Sub-components ---
|
||
|
||
const HistoryContent = () => (
|
||
<div className="flex flex-col h-full bg-white">
|
||
<div className="p-4 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
|
||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||
<History size={18} className="text-indigo-500" /> {t.history}
|
||
</h3>
|
||
<div className="flex items-center gap-3">
|
||
{sessions.length > 0 && (
|
||
<button onClick={onClearAllSessions} className="text-xs text-red-400 hover:text-red-600 hover:underline">
|
||
{translations[language].reading.clear}
|
||
</button>
|
||
)}
|
||
<button onClick={() => setIsHistoryOpen(false)} className="md:hidden text-slate-400 hover:text-slate-600">
|
||
<X size={20} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-4">
|
||
<button
|
||
onClick={() => { onNewSession(); setIsHistoryOpen(false); }}
|
||
className="w-full py-3 bg-indigo-600 text-white hover:bg-indigo-700 rounded-xl font-bold flex items-center justify-center gap-2 transition-all shadow-md hover:shadow-lg active:scale-95"
|
||
>
|
||
<Plus size={18} /> {t.newChat}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto px-4 pb-20 space-y-3">
|
||
{sessions.length === 0 && <div className="text-center text-slate-400 text-xs mt-4">{t.noHistory}</div>}
|
||
{sessions.slice().sort((a,b) => b.updatedAt - a.updatedAt).map(session => (
|
||
<div
|
||
key={session.id}
|
||
className={`group flex items-start gap-3 p-3 rounded-xl border cursor-pointer relative transition-all ${
|
||
session.id === activeSessionId
|
||
? 'bg-indigo-50 border-indigo-200 shadow-sm'
|
||
: 'bg-slate-50 border-slate-100 hover:bg-white hover:shadow-md'
|
||
}`}
|
||
onClick={() => { onSelectSession(session.id); if(window.innerWidth < 768) setIsHistoryOpen(false); }}
|
||
>
|
||
{/* Icon */}
|
||
<div className={`w-10 h-10 rounded-xl flex-shrink-0 flex items-center justify-center ${session.id === activeSessionId ? 'bg-indigo-200 text-indigo-700' : 'bg-white text-slate-300 border border-slate-200'}`}>
|
||
<MessageSquare size={20} />
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex justify-between items-start">
|
||
<h4 className={`font-bold text-sm truncate pr-6 ${session.id === activeSessionId ? 'text-indigo-900' : 'text-slate-700'}`}>
|
||
{session.title || t.untitled}
|
||
</h4>
|
||
</div>
|
||
<div className="flex justify-between items-center mt-1">
|
||
<span className="text-[10px] text-slate-400">
|
||
{new Date(session.updatedAt).toLocaleDateString()} {new Date(session.updatedAt).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
||
</span>
|
||
</div>
|
||
<p className="text-[10px] text-slate-400 line-clamp-1 mt-1">
|
||
{session.messages.length > 1 ? session.messages[session.messages.length-1].content.substring(0, 50) : '...'}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Delete Button */}
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); onDeleteSession(session.id); }}
|
||
className="absolute bottom-2 right-2 p-1.5 text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
title={t.deleteChat}
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="flex h-full bg-slate-50 relative overflow-hidden">
|
||
|
||
{/* MAIN CHAT AREA */}
|
||
<div className="flex-1 flex flex-col h-full min-w-0 bg-slate-50/30 relative">
|
||
|
||
{/* Header / Toolbar */}
|
||
<div className="flex items-center justify-between px-4 py-3 bg-white/80 backdrop-blur border-b border-slate-200 z-20 sticky top-0">
|
||
<div className="flex items-center gap-2 overflow-x-auto scrollbar-hide">
|
||
<div className="flex items-center gap-1.5 text-xs font-bold text-indigo-600 bg-indigo-50 px-2.5 py-1 rounded-lg border border-indigo-100 whitespace-nowrap">
|
||
<Sparkles size={12} />
|
||
{selectedModel ? selectedModel.replace('gemini-', '').replace('-preview', '') : 'AI'}
|
||
</div>
|
||
|
||
{/* AI Language Toggle */}
|
||
<button
|
||
onClick={() => setAiSpeakingLanguage(prev => prev === 'ja' ? 'native' : 'ja')}
|
||
className="flex items-center gap-1.5 text-xs font-bold text-slate-600 bg-white px-2.5 py-1 rounded-lg border border-slate-200 hover:bg-slate-50 whitespace-nowrap"
|
||
title={tCommon.aiLanguage}
|
||
>
|
||
<Languages size={12} />
|
||
{aiSpeakingLanguage === 'ja' ? tCommon.langJa : tCommon.langNative}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{/* Share Button */}
|
||
<div className="relative">
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); setIsShareMenuOpen(!isShareMenuOpen); }}
|
||
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 transition-colors"
|
||
title={tCommon.share}
|
||
>
|
||
<Share2 size={18} />
|
||
</button>
|
||
|
||
{/* Share Dropdown */}
|
||
{isShareMenuOpen && (
|
||
<div className="absolute right-0 top-full mt-2 w-40 bg-white rounded-xl shadow-xl border border-slate-100 overflow-hidden animate-scale-in z-50">
|
||
<button onClick={shareAsImage} className="w-full text-left px-4 py-3 text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2">
|
||
<ImageIconLucide size={16} /> {tCommon.shareImage}
|
||
</button>
|
||
<button onClick={shareAsText} className="w-full text-left px-4 py-3 text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2">
|
||
<FileText size={16} /> {tCommon.shareText}
|
||
</button>
|
||
<button onClick={shareAsFile} className="w-full text-left px-4 py-3 text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2">
|
||
<Download size={16} /> {tCommon.shareFile}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Toggle History Button */}
|
||
<button
|
||
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
|
||
className={`p-2 rounded-lg border transition-colors flex items-center gap-2 text-sm font-medium ${
|
||
isHistoryOpen
|
||
? 'bg-indigo-50 text-indigo-600 border-indigo-200'
|
||
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'
|
||
}`}
|
||
title={t.history}
|
||
>
|
||
<History size={18} />
|
||
<span className="hidden sm:inline">{t.history}</span>
|
||
{isHistoryOpen ? <PanelRightClose size={16} className="opacity-50" /> : <PanelRightOpen size={16} className="opacity-50" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Messages Scroll Area */}
|
||
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6" ref={messagesContainerRef}>
|
||
{messages.length === 0 && (
|
||
<div className="flex flex-col items-center justify-center h-full text-slate-300 animate-fade-in pb-20">
|
||
<div className="w-20 h-20 bg-white rounded-3xl flex items-center justify-center mb-6 shadow-sm border border-slate-100 rotate-3">
|
||
<MessageSquare size={36} className="text-indigo-200" />
|
||
</div>
|
||
<p className="text-sm font-bold text-slate-400">{t.inputPlaceholder}</p>
|
||
</div>
|
||
)}
|
||
{messages.map((msg) => (
|
||
<ChatBubble
|
||
key={msg.id}
|
||
message={msg}
|
||
language={language}
|
||
onUpdateMessage={handleUpdateMessage}
|
||
onError={(errorMsg) => addToast('error', errorMsg)}
|
||
/>
|
||
))}
|
||
{isLoading && (
|
||
<div className="flex items-center gap-2 text-slate-400 text-sm ml-4 animate-pulse">
|
||
<div className="w-8 h-8 bg-white rounded-full flex items-center justify-center shadow-sm border border-slate-100">
|
||
<Loader2 size={14} className="animate-spin text-indigo-500" />
|
||
</div>
|
||
{t.sending}
|
||
</div>
|
||
)}
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
|
||
{/* Input Area */}
|
||
<div className="bg-white border-t border-slate-200 p-3 sm:p-4 z-20 pb-[env(safe-area-inset-bottom)]">
|
||
{attachedImage && (
|
||
<div className="flex items-center gap-2 mb-3 px-1 animate-scale-in">
|
||
<div className="relative group">
|
||
<img src={attachedImage} alt="Preview" className="h-14 w-14 object-cover rounded-lg border border-slate-200 shadow-sm" />
|
||
<button
|
||
onClick={() => setAttachedImage(null)}
|
||
className="absolute -top-2 -right-2 bg-slate-800 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs shadow-md hover:bg-red-50 transition-colors"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<span className="text-xs text-indigo-600 font-bold bg-indigo-50 px-3 py-1 rounded-full">{t.imageAttached}</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="max-w-4xl mx-auto flex flex-col sm:flex-row gap-2 items-end">
|
||
<div className="flex gap-1 items-center justify-between w-full sm:w-auto">
|
||
<div className="flex gap-1">
|
||
<button
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="p-3 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-xl transition-all"
|
||
title="Attach Image"
|
||
>
|
||
<ImageIcon size={20} />
|
||
</button>
|
||
<input type="file" ref={fileInputRef} onChange={handleImageUpload} className="hidden" accept="image/*" />
|
||
|
||
<button
|
||
onClick={() => setUseThinking(!useThinking)}
|
||
className={`p-3 rounded-xl transition-all ${
|
||
useThinking ? 'bg-amber-50 text-amber-600 ring-1 ring-amber-200' : 'text-slate-400 hover:text-amber-600 hover:bg-amber-50'
|
||
}`}
|
||
title={t.thinkingToggle}
|
||
>
|
||
<BrainCircuit size={20} />
|
||
</button>
|
||
|
||
<AudioRecorder onAudioCaptured={handleAudioInput} disabled={isLoading} />
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleSendMessage}
|
||
disabled={!inputValue.trim() && !attachedImage || isLoading}
|
||
className="sm:hidden p-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl disabled:opacity-50 disabled:scale-95 transition-all shadow-md shadow-indigo-200"
|
||
>
|
||
{isLoading ? <Loader2 size={20} className="animate-spin" /> : <Send size={20} />}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex-1 w-full">
|
||
<textarea
|
||
value={inputValue}
|
||
onChange={(e) => setInputValue(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSendMessage(); }}}
|
||
placeholder={useThinking ? t.thinkingPlaceholder : t.inputPlaceholder}
|
||
className="w-full border-2 border-transparent bg-slate-100 focus:bg-white rounded-2xl px-4 py-3 focus:border-indigo-500 focus:ring-0 resize-y min-h-[50px] max-h-[200px] text-sm md:text-base transition-all placeholder:text-slate-400 text-slate-800"
|
||
style={{ minHeight: '50px' }}
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleSendMessage}
|
||
disabled={!inputValue.trim() && !attachedImage || isLoading}
|
||
className="hidden sm:flex p-3.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl disabled:opacity-50 transition-all shadow-lg shadow-indigo-200 hover:scale-105 active:scale-95"
|
||
>
|
||
{isLoading ? <Loader2 size={20} className="animate-spin" /> : <Send size={20} />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* RIGHT SIDEBAR - DESKTOP */}
|
||
<div className={`
|
||
hidden md:block h-full bg-white border-l border-slate-200 transition-all duration-300 ease-in-out overflow-hidden z-30
|
||
${isHistoryOpen ? 'w-80 opacity-100' : 'w-0 opacity-0 border-none'}
|
||
`}>
|
||
<HistoryContent />
|
||
</div>
|
||
|
||
{/* MOBILE DRAWER (Slide over) */}
|
||
{isHistoryOpen && (
|
||
<div className="fixed inset-0 z-50 md:hidden flex justify-end">
|
||
<div className="absolute inset-0 bg-slate-900/30 backdrop-blur-sm transition-opacity" onClick={() => setIsHistoryOpen(false)} />
|
||
<div className="relative w-[85%] max-w-sm bg-white h-full shadow-2xl animate-slide-in-right z-50">
|
||
<HistoryContent />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ChatView; |