447 lines
17 KiB
TypeScript
447 lines
17 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { ArrowLeft, ChevronRight, Loader2, MessageCircle, Send, UserRound, X } from 'lucide-react';
|
|
import { io } from 'socket.io-client';
|
|
import type { Socket } from 'socket.io-client';
|
|
import {
|
|
CHAT_SOCKET_ORIGIN,
|
|
CHAT_SOCKET_PATH,
|
|
createInstructorChatMessage,
|
|
getInstructorChatThread,
|
|
listInstructorChatThreads,
|
|
type InstructorChatMessage,
|
|
type InstructorChatThread,
|
|
} from '../lib/chat';
|
|
import type { AuthSession } from '../types/auth';
|
|
|
|
type InstructorChatPanelProps = {
|
|
session: AuthSession;
|
|
courseId: string;
|
|
open: boolean;
|
|
initialThreadId?: string | null;
|
|
initialThreadRequestId?: number;
|
|
onClose: () => void;
|
|
};
|
|
|
|
type ChatNotificationPayload = {
|
|
type: 'chat.message.created';
|
|
threadId: string;
|
|
messageId: string;
|
|
senderType: 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM';
|
|
preview: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
export function InstructorChatPanel({ session, courseId, open, initialThreadId = null, initialThreadRequestId = 0, onClose }: InstructorChatPanelProps) {
|
|
const [threads, setThreads] = useState<InstructorChatThread[]>([]);
|
|
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [roomLoading, setRoomLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [mounted, setMounted] = useState(open);
|
|
|
|
const selectedThread = useMemo(() => threads.find((thread) => thread.id === selectedThreadId) ?? null, [selectedThreadId, threads]);
|
|
|
|
const refreshThreadList = (showLoading = false) => {
|
|
let cancelled = false;
|
|
if (showLoading) setLoading(true);
|
|
setError(null);
|
|
|
|
const request = listInstructorChatThreads(session.accessToken, courseId)
|
|
.then((items) => {
|
|
if (!cancelled) setThreads(items);
|
|
})
|
|
.catch((reason: Error) => {
|
|
if (!cancelled) {
|
|
setThreads([]);
|
|
setError(reason.message);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled && showLoading) setLoading(false);
|
|
});
|
|
|
|
return {
|
|
cancel: () => {
|
|
cancelled = true;
|
|
},
|
|
request,
|
|
};
|
|
};
|
|
|
|
const refreshSelectedThread = (threadId: string, showLoading = false) => {
|
|
if (showLoading) setRoomLoading(true);
|
|
setError(null);
|
|
|
|
return getInstructorChatThread(session.accessToken, threadId)
|
|
.then((thread) => {
|
|
setThreads((current) => replaceThread(current, thread));
|
|
})
|
|
.catch((reason: Error) => {
|
|
setError(reason.message);
|
|
})
|
|
.finally(() => {
|
|
if (showLoading) setRoomLoading(false);
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
|
|
const refresh = refreshThreadList(true);
|
|
|
|
return () => {
|
|
refresh.cancel();
|
|
};
|
|
}, [courseId, open, session.accessToken]);
|
|
|
|
useEffect(() => {
|
|
if (!open || !initialThreadId) return;
|
|
setSelectedThreadId(initialThreadId);
|
|
void refreshSelectedThread(initialThreadId, true);
|
|
}, [open, initialThreadId, initialThreadRequestId, session.accessToken]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const socket: Socket = io(CHAT_SOCKET_ORIGIN, {
|
|
auth: { token: session.accessToken },
|
|
path: CHAT_SOCKET_PATH,
|
|
transports: ['websocket', 'polling'],
|
|
});
|
|
|
|
const handleMessageCreated = (payload: ChatNotificationPayload) => {
|
|
void refreshThreadList(false).request;
|
|
if (selectedThreadId && payload.threadId === selectedThreadId) {
|
|
void refreshSelectedThread(selectedThreadId, false);
|
|
}
|
|
};
|
|
|
|
socket.on('connect', () => {
|
|
void refreshThreadList(false).request;
|
|
if (selectedThreadId) void refreshSelectedThread(selectedThreadId, false);
|
|
});
|
|
socket.on('chat.message.created', handleMessageCreated);
|
|
|
|
return () => {
|
|
socket.off('chat.message.created', handleMessageCreated);
|
|
socket.disconnect();
|
|
};
|
|
}, [courseId, open, selectedThreadId, session.accessToken]);
|
|
|
|
useEffect(() => {
|
|
if (!open) setSelectedThreadId(null);
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setMounted(true);
|
|
return;
|
|
}
|
|
|
|
const timer = window.setTimeout(() => setMounted(false), 220);
|
|
return () => window.clearTimeout(timer);
|
|
}, [open]);
|
|
|
|
const handleOpenThread = (threadId: string) => {
|
|
setSelectedThreadId(threadId);
|
|
void refreshSelectedThread(threadId, true);
|
|
};
|
|
|
|
const handleSendMessage = async (threadId: string, content: string) => {
|
|
const updatedThread = await createInstructorChatMessage(session.accessToken, threadId, content);
|
|
setThreads((current) => replaceThread(current, updatedThread));
|
|
};
|
|
|
|
if (!mounted) return null;
|
|
|
|
return (
|
|
<aside className={`fixed bottom-0 right-0 top-[60px] z-30 flex w-full max-w-[420px] flex-col border-l border-[#cbd5e1] bg-white/92 shadow-[0_22px_70px_rgba(15,23,42,0.18)] backdrop-blur-md transition-transform duration-200 ease-out ${open ? 'translate-x-0' : 'translate-x-full'}`}>
|
|
{selectedThread ? (
|
|
<ChatRoom thread={selectedThread} loading={roomLoading} onBack={() => setSelectedThreadId(null)} onClose={onClose} onSend={handleSendMessage} />
|
|
) : (
|
|
<>
|
|
<div className="flex h-[68px] items-center justify-between border-b border-[#cbd5e1] px-5">
|
|
<div>
|
|
<p className="text-[15px] font-black text-[#0f172a]">채팅</p>
|
|
<p className="mt-1 text-[12px] font-bold text-[#64748b]">수강생 문의 목록</p>
|
|
</div>
|
|
<button className="grid h-9 w-9 place-items-center rounded-md text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827]" type="button" aria-label="채팅 닫기" title="채팅 닫기" onClick={onClose}>
|
|
<X size={17} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto overscroll-contain px-4 py-4">
|
|
{loading ? (
|
|
<div className="flex h-full items-center justify-center text-[#64748b]">
|
|
<Loader2 className="mr-2 animate-spin" size={18} aria-hidden="true" />
|
|
<span className="text-[13px] font-bold">문의 목록을 불러오는 중입니다.</span>
|
|
</div>
|
|
) : error ? (
|
|
<EmptyState title="채팅 서버 연결이 필요합니다." description={error} />
|
|
) : threads.length > 0 ? (
|
|
<div className="space-y-2.5">
|
|
{threads.map((thread) => (
|
|
<ThreadCard key={thread.id} thread={thread} onOpen={() => handleOpenThread(thread.id)} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<EmptyState title="대기 중인 문의가 없습니다." description="수강생이 강사 문의를 남기면 이곳에 표시됩니다." />
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function ThreadCard({ thread, onOpen }: { thread: InstructorChatThread; onOpen: () => void }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="group w-full cursor-pointer rounded-lg border border-[#c7d2e0] bg-white px-4 py-3 text-left shadow-[0_10px_28px_rgba(15,23,42,0.05)] outline-none transition hover:border-[#818cf8] hover:bg-[#f8faff] hover:shadow-[0_16px_36px_rgba(79,70,229,0.14)] focus-visible:border-[#4f46e5] focus-visible:ring-2 focus-visible:ring-[#c7d2fe] active:bg-[#eef2ff]"
|
|
role="button"
|
|
onClick={onOpen}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<StudentAvatar thread={thread} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<p className="truncate text-[14px] font-black text-[#111827]">{thread.studentName}</p>
|
|
{thread.status === 'WAITING_INSTRUCTOR' ? <StatusBadge status={thread.status} /> : null}
|
|
</div>
|
|
<p className="mt-1 line-clamp-2 text-[12px] font-bold leading-5 text-[#64748b]">{thread.lastMessage}</p>
|
|
<div className="mt-3 flex items-center gap-2 text-[11px] font-bold text-[#94a3b8]">
|
|
{thread.groupName ? <span>{thread.groupName}</span> : null}
|
|
{thread.week ? <span>{thread.week}주차</span> : null}
|
|
{thread.lastMessageAt ? <span>{formatDate(thread.lastMessageAt)}</span> : null}
|
|
</div>
|
|
</div>
|
|
<span className="mt-1 grid h-8 w-8 shrink-0 place-items-center rounded-md text-[#94a3b8] transition group-hover:bg-[#eef2ff] group-hover:text-[#4f46e5]">
|
|
<ChevronRight size={17} aria-hidden="true" />
|
|
</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function StudentAvatar({ thread, size = 'md' }: { thread: InstructorChatThread; size?: 'sm' | 'md' }) {
|
|
const [avatarFailed, setAvatarFailed] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setAvatarFailed(false);
|
|
}, [thread.studentAvatarUrl]);
|
|
|
|
return (
|
|
<span className={`${size === 'sm' ? 'h-9 w-9' : 'h-10 w-10'} grid shrink-0 place-items-center overflow-hidden rounded-full bg-[#eef2ff] text-[#4f46e5] transition group-hover:bg-[#4f46e5] group-hover:text-white`}>
|
|
{thread.studentAvatarUrl && !avatarFailed ? (
|
|
<img className="h-full w-full object-cover" src={thread.studentAvatarUrl} alt="" onError={() => setAvatarFailed(true)} />
|
|
) : (
|
|
<UserRound size={18} aria-hidden="true" />
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function ChatRoom({
|
|
thread,
|
|
loading,
|
|
onBack,
|
|
onClose,
|
|
onSend,
|
|
}: {
|
|
thread: InstructorChatThread;
|
|
loading: boolean;
|
|
onBack: () => void;
|
|
onClose: () => void;
|
|
onSend: (threadId: string, content: string) => Promise<void>;
|
|
}) {
|
|
const messages = thread.messages.length > 0 ? thread.messages : createFallbackMessages(thread);
|
|
const [draft, setDraft] = useState('');
|
|
const [sending, setSending] = useState(false);
|
|
const [sendError, setSendError] = useState<string | null>(null);
|
|
const messageScrollRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
const target = messageScrollRef.current;
|
|
if (!target || loading) return;
|
|
|
|
target.scrollTo({
|
|
top: target.scrollHeight,
|
|
behavior: 'smooth',
|
|
});
|
|
}, [loading, messages.length, thread.id]);
|
|
|
|
const handleSubmit = async () => {
|
|
const content = draft.trim();
|
|
if (!content || sending) return;
|
|
|
|
setSending(true);
|
|
setSendError(null);
|
|
try {
|
|
await onSend(thread.id, content);
|
|
setDraft('');
|
|
} catch (reason) {
|
|
setSendError(reason instanceof Error ? reason.message : '강사 답변을 저장하지 못했습니다.');
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="flex h-[68px] items-center gap-2 border-b border-[#cbd5e1] px-4">
|
|
<button className="grid h-9 w-9 place-items-center rounded-md text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827]" type="button" aria-label="목록으로 돌아가기" title="목록으로 돌아가기" onClick={onBack}>
|
|
<ArrowLeft size={17} aria-hidden="true" />
|
|
</button>
|
|
<StudentAvatar thread={thread} size="sm" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<p className="truncate text-[15px] font-black text-[#0f172a]">{thread.studentName}</p>
|
|
{thread.status === 'WAITING_INSTRUCTOR' ? <StatusBadge status={thread.status} /> : null}
|
|
</div>
|
|
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">
|
|
{[thread.groupName, thread.week ? `${thread.week}주차` : null].filter(Boolean).join(' · ') || '강사 문의'}
|
|
</p>
|
|
</div>
|
|
<button className="grid h-9 w-9 place-items-center rounded-md text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827]" type="button" aria-label="채팅 닫기" title="채팅 닫기" onClick={onClose}>
|
|
<X size={17} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
|
|
<div ref={messageScrollRef} className="flex-1 overflow-y-auto overscroll-contain px-5 py-5">
|
|
{loading ? (
|
|
<div className="flex h-full items-center justify-center text-[#64748b]">
|
|
<Loader2 className="mr-2 animate-spin" size={18} aria-hidden="true" />
|
|
<span className="text-[13px] font-bold">채팅방을 불러오는 중입니다.</span>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{messages.map((message) => (
|
|
<MessageBubble key={message.id} message={message} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-[#cbd5e1] bg-white/86 p-4">
|
|
<div>
|
|
<div className="flex overflow-hidden rounded-lg border border-[#dbe3ef] bg-[#f8fafc]">
|
|
<textarea
|
|
className="h-[82px] min-w-0 flex-1 resize-none bg-transparent px-3 py-2 text-[13px] font-bold leading-5 text-[#111827] outline-none placeholder:text-[#94a3b8]"
|
|
value={draft}
|
|
placeholder="수강생에게 답변을 입력하세요."
|
|
onChange={(event) => setDraft(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
event.preventDefault();
|
|
void handleSubmit();
|
|
}
|
|
}}
|
|
/>
|
|
<button
|
|
className="grid w-12 shrink-0 place-items-center bg-[#4f46e5] text-white hover:bg-[#4338ca] disabled:bg-[#cbd5e1]"
|
|
type="button"
|
|
disabled={!draft.trim() || sending}
|
|
onClick={handleSubmit}
|
|
aria-label={sending ? '전송 중' : '전송'}
|
|
title={sending ? '전송 중' : '전송'}
|
|
>
|
|
{sending ? <Loader2 className="animate-spin" size={16} aria-hidden="true" /> : <Send size={16} aria-hidden="true" />}
|
|
</button>
|
|
</div>
|
|
{sendError ? <p className="mt-1 text-[11px] font-bold text-[#e11d48]">{sendError}</p> : null}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function MessageBubble({ message }: { message: InstructorChatMessage }) {
|
|
const isInstructor = message.senderType === 'INSTRUCTOR';
|
|
const isStudent = message.senderType === 'STUDENT';
|
|
const senderLabel = message.senderName?.trim() || getSenderLabel(message.senderType);
|
|
|
|
return (
|
|
<div className={`flex ${isInstructor ? 'justify-end' : 'justify-start'}`}>
|
|
<div
|
|
className={`max-w-[82%] rounded-lg px-3.5 py-2.5 ${
|
|
isInstructor
|
|
? 'bg-[#4f46e5] text-white'
|
|
: isStudent
|
|
? 'border border-[#c7d2e0] bg-white text-[#111827]'
|
|
: 'border border-[#e0e7ff] bg-[#eef2ff] text-[#3730a3]'
|
|
}`}
|
|
>
|
|
<div className="mb-1 flex items-center gap-2">
|
|
<span className={`text-[10px] font-black ${isInstructor ? 'text-white/78' : 'text-[#64748b]'}`}>{senderLabel}</span>
|
|
{message.createdAt ? <span className={`text-[10px] font-bold ${isInstructor ? 'text-white/58' : 'text-[#94a3b8]'}`}>{formatDate(message.createdAt)}</span> : null}
|
|
</div>
|
|
<p className="whitespace-pre-wrap text-[13px] font-bold leading-5">{message.content}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EmptyState({ title, description }: { title: string; description: string }) {
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center rounded-lg border border-dashed border-[#cbd5e1] bg-white/58 px-6 text-center">
|
|
<span className="mb-3 grid h-11 w-11 place-items-center rounded-full bg-[#eef2ff] text-[#4f46e5]">
|
|
<MessageCircle size={20} aria-hidden="true" />
|
|
</span>
|
|
<p className="text-[14px] font-black text-[#111827]">{title}</p>
|
|
<p className="mt-2 text-[12px] font-bold leading-5 text-[#64748b]">{description}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
const label = getStatusLabel(status);
|
|
const tone = status === 'WAITING_INSTRUCTOR' ? 'bg-[#fff1f2] text-[#be123c] border-[#fecdd3]' : 'bg-[#eef2ff] text-[#4f46e5] border-[#c7d2fe]';
|
|
|
|
return <span className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-black ${tone}`}>{label}</span>;
|
|
}
|
|
|
|
function getStatusLabel(status: string): string {
|
|
if (status === 'WAITING_INSTRUCTOR') return '답변 대기';
|
|
if (status === 'OPEN') return '진행 중';
|
|
if (status === 'RESOLVED') return '완료';
|
|
if (status === 'ARCHIVED') return '보관';
|
|
return status;
|
|
}
|
|
|
|
function getSenderLabel(senderType: string): string {
|
|
if (senderType === 'STUDENT') return '수강생';
|
|
if (senderType === 'INSTRUCTOR') return '강사';
|
|
if (senderType === 'AI') return 'AI';
|
|
return '시스템';
|
|
}
|
|
|
|
function createFallbackMessages(thread: InstructorChatThread): InstructorChatMessage[] {
|
|
return [
|
|
{
|
|
id: `${thread.id}-last`,
|
|
senderType: 'STUDENT',
|
|
content: thread.lastMessage,
|
|
createdAt: thread.lastMessageAt,
|
|
},
|
|
];
|
|
}
|
|
|
|
function replaceThread(threads: InstructorChatThread[], thread: InstructorChatThread): InstructorChatThread[] {
|
|
const exists = threads.some((item) => item.id === thread.id);
|
|
if (!exists) return [thread, ...threads];
|
|
|
|
return threads.map((item) => (item.id === thread.id ? thread : item));
|
|
}
|
|
|
|
function formatDate(value: string): string {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
|
|
return new Intl.DateTimeFormat('ko-KR', {
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
}).format(date);
|
|
}
|