platform/lms-adm-fe release promotion snapshot
This commit is contained in:
@@ -0,0 +1,468 @@
|
||||
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_BASE_URL,
|
||||
createInstructorChatMessage,
|
||||
getInstructorChatThread,
|
||||
listInstructorChatThreads,
|
||||
type InstructorChatMessage,
|
||||
type InstructorChatThread,
|
||||
} from '../lib/chat';
|
||||
import type { AuthSession } from '../types/auth';
|
||||
|
||||
type InstructorChatPanelProps = {
|
||||
session: AuthSession;
|
||||
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, 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)
|
||||
.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();
|
||||
};
|
||||
}, [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_BASE_URL, {
|
||||
auth: { token: session.accessToken },
|
||||
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();
|
||||
};
|
||||
}, [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} accessToken={session.accessToken} thread={thread} onOpen={() => handleOpenThread(thread.id)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="대기 중인 문의가 없습니다." description="수강생이 강사 문의를 남기면 이곳에 표시됩니다." />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadCard({ accessToken, thread, onOpen }: { accessToken: string; 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 accessToken={accessToken} 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({ accessToken, thread }: { accessToken: string; thread: InstructorChatThread }) {
|
||||
const [avatarSource, setAvatarSource] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!thread.studentAvatarUrl) {
|
||||
setAvatarSource(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let revoked = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
fetch(thread.studentAvatarUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
.then((response) => (response.ok ? response.blob() : null))
|
||||
.then((blob) => {
|
||||
if (!blob || revoked) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setAvatarSource(objectUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!revoked) setAvatarSource(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
revoked = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [accessToken, thread.studentAvatarUrl]);
|
||||
|
||||
return (
|
||||
<span className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-full bg-[#eef2ff] text-[#4f46e5] transition group-hover:bg-[#4f46e5] group-hover:text-white">
|
||||
{avatarSource ? (
|
||||
<img className="h-full w-full object-cover" src={avatarSource} alt="" />
|
||||
) : (
|
||||
<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>
|
||||
<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);
|
||||
}
|
||||
Reference in New Issue
Block a user