Spaces:
Running
Running
| 'use client'; | |
| import { useState, useEffect } from 'react'; | |
| import { useRouter } from 'next/navigation'; | |
| import { useAuth } from '@/contexts/AuthContext'; | |
| import { DEFAULT_COACH_ID } from '@/lib/prompts/coach-prompts'; | |
| import { useAuthFetch } from '@/hooks/useAuthFetch'; | |
| interface ConversationItem { | |
| id: string; | |
| title: string | null; | |
| studentName: string; | |
| studentPromptId: string; | |
| coachName: string; | |
| messageCount: number; | |
| updatedAt: string; | |
| } | |
| interface StudentPersonality { | |
| id: string; | |
| name: string; | |
| description: string; | |
| } | |
| interface SidebarProps { | |
| isOpen: boolean; | |
| onClose: () => void; | |
| } | |
| export default function Sidebar({ isOpen, onClose }: SidebarProps) { | |
| const router = useRouter(); | |
| const { logout } = useAuth(); | |
| const authFetch = useAuthFetch(); | |
| const [conversations, setConversations] = useState<ConversationItem[]>([]); | |
| const [personalities, setPersonalities] = useState<StudentPersonality[]>([]); | |
| const [showStudentModal, setShowStudentModal] = useState(false); | |
| const [loading, setLoading] = useState(false); | |
| const [isClosing, setIsClosing] = useState(false); | |
| const [isModalClosing, setIsModalClosing] = useState(false); | |
| const [editingConvId, setEditingConvId] = useState<string | null>(null); | |
| const [editingTitle, setEditingTitle] = useState(''); | |
| const [deletingConvId, setDeletingConvId] = useState<string | null>(null); | |
| const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); | |
| const [isDeleteConfirmClosing, setIsDeleteConfirmClosing] = useState(false); | |
| const API_URL = process.env.NEXT_PUBLIC_API_URL || ''; | |
| useEffect(() => { | |
| if (isOpen) { | |
| fetchConversations(); | |
| fetchPersonalities(); | |
| } | |
| }, [isOpen]); | |
| const fetchConversations = async () => { | |
| try { | |
| const response = await authFetch(`${API_URL}/api/conversations`); | |
| const data = await response.json(); | |
| // Show all conversations including coach conversations | |
| setConversations(data.conversations || []); | |
| } catch (error) { | |
| console.error('Failed to fetch conversations:', error); | |
| } | |
| }; | |
| const fetchPersonalities = async () => { | |
| try { | |
| const response = await fetch(`${API_URL}/api/personalities`); | |
| const data = await response.json(); | |
| setPersonalities(data.personalities || []); | |
| } catch (error) { | |
| console.error('Failed to fetch personalities:', error); | |
| } | |
| }; | |
| const createNewConversation = async (personalityId: string) => { | |
| setLoading(true); | |
| try { | |
| const response = await authFetch(`${API_URL}/api/conversations/create`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| studentPromptId: personalityId, | |
| coachPromptId: DEFAULT_COACH_ID, | |
| include3ConversationSummary: true, | |
| }), | |
| }); | |
| const data = await response.json(); | |
| if (response.ok) { | |
| setIsModalClosing(true); | |
| setTimeout(() => { | |
| setShowStudentModal(false); | |
| setIsModalClosing(false); | |
| setIsClosing(true); | |
| setTimeout(() => { | |
| setIsClosing(false); | |
| onClose(); | |
| router.push(`/conversation/${data.conversation.id}`); | |
| }, 300); | |
| }, 250); | |
| } else { | |
| alert(data.error || '創建對話失敗'); | |
| } | |
| } catch (error) { | |
| console.error('Failed to create conversation:', error); | |
| alert('創建對話失敗'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const handleConversationClick = (conversationId: string) => { | |
| setIsClosing(true); | |
| setTimeout(() => { | |
| setIsClosing(false); | |
| onClose(); | |
| router.push(`/conversation/${conversationId}`); | |
| }, 300); | |
| }; | |
| const handleCoachChat = async () => { | |
| setLoading(true); | |
| try { | |
| // Always create a new coach conversation | |
| const response = await authFetch(`${API_URL}/api/conversations/create`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| studentPromptId: 'coach_direct', | |
| coachPromptId: DEFAULT_COACH_ID, | |
| include3ConversationSummary: false, | |
| title: `諮詢教練`, | |
| }), | |
| }); | |
| const data = await response.json(); | |
| if (response.ok) { | |
| setIsClosing(true); | |
| setTimeout(() => { | |
| setIsClosing(false); | |
| onClose(); | |
| router.push(`/conversation/${data.conversation.id}`); | |
| }, 300); | |
| } else { | |
| alert(data.error || '創建對話失敗'); | |
| } | |
| } catch (error) { | |
| console.error('Failed to create coach conversation:', error); | |
| alert('創建對話失敗'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const handleLogout = async () => { | |
| setIsClosing(true); | |
| setTimeout(async () => { | |
| setIsClosing(false); | |
| await logout(); | |
| onClose(); | |
| router.push('/login'); | |
| }, 300); | |
| }; | |
| const handleClose = () => { | |
| setIsClosing(true); | |
| setTimeout(() => { | |
| setIsClosing(false); | |
| onClose(); | |
| }, 300); // Match animation duration | |
| }; | |
| // Reset closing state when opening | |
| useEffect(() => { | |
| if (isOpen) { | |
| setIsClosing(false); | |
| } | |
| }, [isOpen]); | |
| // Reset modal closing state when modal opens | |
| useEffect(() => { | |
| if (showStudentModal) { | |
| setIsModalClosing(false); | |
| } | |
| }, [showStudentModal]); | |
| const handleCloseStudentModal = () => { | |
| setIsModalClosing(true); | |
| setTimeout(() => { | |
| setShowStudentModal(false); | |
| setIsModalClosing(false); | |
| }, 250); | |
| }; | |
| const handleStartEdit = (conv: ConversationItem, e: React.MouseEvent) => { | |
| e.stopPropagation(); | |
| setEditingConvId(conv.id); | |
| const isCoachConv = conv.studentPromptId === 'coach_direct'; | |
| setEditingTitle(conv.title || (isCoachConv ? `諮詢 ${conv.coachName}` : `與 ${conv.studentName} 的對話`)); | |
| }; | |
| const handleSaveTitle = async (convId: string) => { | |
| if (!editingTitle.trim()) { | |
| setEditingConvId(null); | |
| return; | |
| } | |
| try { | |
| const response = await authFetch(`${API_URL}/api/conversations/${convId}`, { | |
| method: 'PUT', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| title: editingTitle.trim(), | |
| titleSource: 'user', | |
| }), | |
| }); | |
| if (response.ok) { | |
| const { conversation } = await response.json(); | |
| // Update local state | |
| setConversations(prev => | |
| prev.map(conv => | |
| conv.id === convId | |
| ? { ...conv, title: conversation.title } | |
| : conv | |
| ) | |
| ); | |
| } | |
| } catch (error) { | |
| console.error('Failed to update conversation title:', error); | |
| } finally { | |
| setEditingConvId(null); | |
| } | |
| }; | |
| const handleCancelEdit = () => { | |
| setEditingConvId(null); | |
| setEditingTitle(''); | |
| }; | |
| const handleKeyDown = (e: React.KeyboardEvent, convId: string) => { | |
| if (e.key === 'Enter') { | |
| handleSaveTitle(convId); | |
| } else if (e.key === 'Escape') { | |
| handleCancelEdit(); | |
| } | |
| }; | |
| const handleStartDelete = (conv: ConversationItem, e: React.MouseEvent) => { | |
| e.stopPropagation(); | |
| setDeletingConvId(conv.id); | |
| setShowDeleteConfirm(true); | |
| }; | |
| const handleConfirmDelete = async () => { | |
| if (!deletingConvId) return; | |
| try { | |
| const response = await authFetch(`${API_URL}/api/conversations/${deletingConvId}`, { | |
| method: 'DELETE', | |
| }); | |
| if (response.ok) { | |
| // Remove from local state | |
| setConversations(prev => prev.filter(conv => conv.id !== deletingConvId)); | |
| // Close modal | |
| setIsDeleteConfirmClosing(true); | |
| setTimeout(() => { | |
| setShowDeleteConfirm(false); | |
| setIsDeleteConfirmClosing(false); | |
| setDeletingConvId(null); | |
| }, 250); | |
| } else { | |
| alert('刪除對話失敗'); | |
| } | |
| } catch (error) { | |
| console.error('Failed to delete conversation:', error); | |
| alert('刪除對話失敗'); | |
| } | |
| }; | |
| const handleCancelDelete = () => { | |
| setIsDeleteConfirmClosing(true); | |
| setTimeout(() => { | |
| setShowDeleteConfirm(false); | |
| setIsDeleteConfirmClosing(false); | |
| setDeletingConvId(null); | |
| }, 250); | |
| }; | |
| if (!isOpen && !isClosing) return null; | |
| return ( | |
| <> | |
| {/* Overlay */} | |
| <div | |
| className={`fixed inset-0 bg-black/50 z-40 ${ | |
| isClosing ? 'animate-fade-out' : 'animate-fade-in' | |
| }`} | |
| onClick={handleClose} | |
| /> | |
| {/* Sidebar */} | |
| <div className={`fixed left-0 top-0 bottom-0 w-[80%] max-w-[320px] bg-white z-50 shadow-2xl overflow-y-auto ${ | |
| isClosing ? 'animate-slide-out-left' : 'animate-slide-in-left' | |
| }`}> | |
| <div className="flex flex-col h-full"> | |
| {/* Header */} | |
| <div className="px-4 py-5 border-b border-gray-200"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <h2 className="text-xl font-bold text-gray-900">選單</h2> | |
| <button | |
| onClick={handleClose} | |
| className="p-2 hover:bg-gray-100 rounded-full transition-colors" | |
| > | |
| <svg className="w-5 h-5 text-gray-600" 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> | |
| {/* Action Buttons */} | |
| <div className="space-y-2"> | |
| <button | |
| onClick={() => { | |
| setIsClosing(true); | |
| setTimeout(() => { | |
| setIsClosing(false); | |
| onClose(); | |
| router.push('/dashboard'); | |
| }, 300); | |
| }} | |
| className="w-full py-2.5 bg-gradient-to-r from-green-500 to-emerald-600 text-white rounded-lg font-medium flex items-center justify-center gap-1.5 active:scale-95 transition-transform text-sm" | |
| > | |
| <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> | |
| </svg> | |
| 回到首頁 | |
| </button> | |
| <div className="flex gap-2"> | |
| <button | |
| onClick={() => setShowStudentModal(true)} | |
| className="flex-1 py-2.5 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-lg font-medium flex items-center justify-center gap-1.5 active:scale-95 transition-transform text-sm" | |
| > | |
| <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> | |
| </svg> | |
| 新對話 | |
| </button> | |
| <button | |
| onClick={handleCoachChat} | |
| className="flex-1 py-2.5 bg-gradient-to-r from-purple-500 to-pink-600 text-white rounded-lg font-medium flex items-center justify-center gap-1.5 active:scale-95 transition-transform text-sm" | |
| > | |
| <span className="text-base">👨🏫</span> | |
| 教練 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Conversations List */} | |
| <div className="flex-1 overflow-y-auto"> | |
| <div className="px-4 py-3"> | |
| <h3 className="text-xs font-semibold text-gray-500 uppercase mb-2">對話記錄</h3> | |
| {conversations.length === 0 ? ( | |
| <p className="text-sm text-gray-400 py-4">尚無對話記錄</p> | |
| ) : ( | |
| <div className="space-y-1"> | |
| {conversations.map((conv) => { | |
| const isCoachConv = conv.studentPromptId === 'coach_direct'; | |
| return ( | |
| <div | |
| key={conv.id} | |
| className="relative group" | |
| > | |
| {editingConvId === conv.id ? ( | |
| <div className={`px-3 py-2.5 rounded-lg ${isCoachConv ? 'bg-purple-50' : 'bg-blue-50'}`}> | |
| <div className="flex items-start gap-2"> | |
| <span className="text-lg flex-shrink-0">{isCoachConv ? '👨🏫' : '💬'}</span> | |
| <div className="flex-1 min-w-0"> | |
| <input | |
| type="text" | |
| value={editingTitle} | |
| onChange={(e) => setEditingTitle(e.target.value)} | |
| onBlur={() => handleSaveTitle(conv.id)} | |
| onKeyDown={(e) => handleKeyDown(e, conv.id)} | |
| className={`w-full text-sm font-medium text-gray-900 bg-white border ${isCoachConv ? 'border-purple-300' : 'border-blue-300'} rounded px-2 py-1 focus:outline-none ${isCoachConv ? 'focus:border-purple-500' : 'focus:border-blue-500'}`} | |
| autoFocus | |
| /> | |
| <p className="text-xs text-gray-500 mt-0.5"> | |
| {conv.messageCount} 則訊息 | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div | |
| onClick={() => handleConversationClick(conv.id)} | |
| className={`w-full text-left px-3 py-2.5 rounded-lg ${isCoachConv ? 'hover:bg-purple-50' : 'hover:bg-gray-100'} transition-colors cursor-pointer`} | |
| > | |
| <div className="flex items-start gap-2"> | |
| <span className="text-lg flex-shrink-0">{isCoachConv ? '👨🏫' : '💬'}</span> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-1"> | |
| <p className="text-sm font-medium text-gray-900 truncate flex-1"> | |
| {conv.title || (isCoachConv ? `諮詢 ${conv.coachName}` : `與 ${conv.studentName} 的對話`)} | |
| </p> | |
| <button | |
| onClick={(e) => handleStartEdit(conv, e)} | |
| className="p-1 opacity-0 group-hover:opacity-100 hover:bg-gray-200 rounded transition-opacity" | |
| title="編輯標題" | |
| > | |
| <svg className="w-3.5 h-3.5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /> | |
| </svg> | |
| </button> | |
| <button | |
| onClick={(e) => handleStartDelete(conv, e)} | |
| className="p-1 opacity-0 group-hover:opacity-100 hover:bg-red-100 rounded transition-opacity" | |
| title="刪除對話" | |
| > | |
| <svg className="w-3.5 h-3.5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> | |
| </svg> | |
| </button> | |
| </div> | |
| <p className="text-xs text-gray-500 mt-0.5"> | |
| {conv.messageCount} 則訊息 | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* Footer - Logout */} | |
| <div className="p-4 border-t border-gray-200"> | |
| <button | |
| onClick={handleLogout} | |
| className="w-full py-3 px-4 bg-gray-100 hover:bg-gray-200 rounded-xl font-medium text-gray-700 transition-colors flex items-center justify-center gap-2" | |
| > | |
| <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /> | |
| </svg> | |
| 登出 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Delete Confirmation Modal */} | |
| {(showDeleteConfirm || isDeleteConfirmClosing) && ( | |
| <div | |
| className={`fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4 ${ | |
| isDeleteConfirmClosing ? 'animate-fade-out' : 'animate-fade-in' | |
| }`} | |
| onClick={handleCancelDelete} | |
| > | |
| <div | |
| className={`bg-white w-full max-w-sm rounded-2xl shadow-2xl p-6 ${ | |
| isDeleteConfirmClosing ? 'animate-fan-out' : 'animate-fan-in' | |
| }`} | |
| onClick={(e) => e.stopPropagation()} | |
| > | |
| <div className="text-center mb-6"> | |
| <div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4"> | |
| <svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> | |
| </svg> | |
| </div> | |
| <h3 className="text-xl font-bold text-gray-900 mb-2">確認刪除對話</h3> | |
| <p className="text-sm text-gray-600"> | |
| 確定要刪除此對話嗎?此操作無法復原。 | |
| </p> | |
| </div> | |
| <div className="flex gap-3"> | |
| <button | |
| onClick={handleCancelDelete} | |
| className="flex-1 py-3 px-4 bg-gray-100 hover:bg-gray-200 rounded-xl font-medium text-gray-700 transition-colors" | |
| > | |
| 取消 | |
| </button> | |
| <button | |
| onClick={handleConfirmDelete} | |
| className="flex-1 py-3 px-4 bg-red-600 hover:bg-red-700 rounded-xl font-medium text-white transition-colors" | |
| > | |
| 確認刪除 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Student Selection Modal */} | |
| {(showStudentModal || isModalClosing) && ( | |
| <div | |
| className={`fixed inset-0 bg-black/50 z-[60] flex items-end ${ | |
| isModalClosing ? 'animate-fade-out' : 'animate-fade-in' | |
| }`} | |
| onClick={handleCloseStudentModal} | |
| > | |
| <div | |
| className={`bg-white w-full max-h-[70vh] overflow-y-auto rounded-t-3xl shadow-2xl ${ | |
| isModalClosing ? 'animate-fan-out' : 'animate-fan-in' | |
| }`} | |
| onClick={(e) => e.stopPropagation()} | |
| > | |
| <div className="px-5 pt-4 pb-8"> | |
| <div className="w-12 h-1.5 bg-gray-300 rounded-full mx-auto mb-6"></div> | |
| <h3 className="text-2xl font-bold text-gray-900 mb-2">選擇學生</h3> | |
| <p className="text-sm text-gray-600 mb-6">點選學生即可開始對話練習</p> | |
| <div className="space-y-3"> | |
| {personalities.map((personality, index) => ( | |
| <div | |
| key={personality.id} | |
| onClick={() => { | |
| if (!loading) { | |
| createNewConversation(personality.id); | |
| } | |
| }} | |
| className={`p-4 rounded-xl border-2 cursor-pointer transition-all active:scale-[0.98] hover:border-blue-400 hover:bg-blue-50 ${ | |
| loading ? 'opacity-50 pointer-events-none' : 'border-gray-200 bg-white' | |
| } ${!isModalClosing ? `animate-stagger-in-${Math.min(index + 1, 4)}` : ''}`} | |
| > | |
| <div className="flex items-center gap-3"> | |
| <div className="w-12 h-12 rounded-lg flex items-center justify-center text-2xl bg-gray-100"> | |
| 🎓 | |
| </div> | |
| <div className="flex-1"> | |
| <div className="font-bold text-base text-gray-900">{personality.name}</div> | |
| <div className="text-sm text-gray-600 mt-0.5">{personality.description}</div> | |
| </div> | |
| <div className="text-gray-400"> | |
| <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> | |
| </svg> | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| {loading && ( | |
| <div className="mt-6 text-center text-gray-600"> | |
| 創建對話中... | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </> | |
| ); | |
| } | |