sel-chat-coach / src /components /Sidebar.tsx
james-d-taboola's picture
feat: add home button in menu
3760b52
'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>
)}
</>
);
}