platform/lms-adm-fe release promotion snapshot
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import autonomyLogo from '../assets/icon.png';
|
||||
|
||||
export function BrandMark({ dark = false }: { dark?: boolean }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${dark ? 'text-[#111827]' : 'text-white'}`}>
|
||||
<span className="grid h-8 w-8 place-items-center overflow-hidden rounded-lg bg-white ring-1 ring-white/25">
|
||||
<img className="h-7 w-7 object-contain" src={autonomyLogo} alt="" />
|
||||
</span>
|
||||
<span className="text-[24px] font-black tracking-[0] sm:text-[24px]">
|
||||
Autonomy <span className={dark ? 'text-[#4f46e5]' : 'text-[#67e8f9]'}>Studio</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Loader2, type LucideIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
detail?: ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
loading?: boolean;
|
||||
danger?: boolean;
|
||||
icon?: LucideIcon;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
detail,
|
||||
confirmLabel = '확인',
|
||||
cancelLabel = '취소',
|
||||
loading = false,
|
||||
danger = false,
|
||||
icon: Icon,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: ConfirmDialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const confirmClass = danger
|
||||
? 'bg-[#e11d48] text-white hover:bg-[#be123c]'
|
||||
: 'bg-[#2563eb] text-white hover:bg-[#1d4ed8]';
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[1000] grid place-items-center bg-[#0f172a]/45 px-4 backdrop-blur-[2px]">
|
||||
<section className="w-full max-w-[420px] overflow-hidden rounded-xl border border-[#cbd5e1] bg-white shadow-[0_28px_80px_rgba(15,23,42,0.30)]">
|
||||
<header className="border-b border-[#cbd5e1] px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-[18px] font-black leading-snug text-[#111827]">{title}</h2>
|
||||
</div>
|
||||
</header>
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-[13px] font-semibold leading-relaxed text-[#64748b]">{message}</p>
|
||||
{detail ? <div className="mt-4">{detail}</div> : null}
|
||||
</div>
|
||||
<footer className="flex justify-end gap-2 border-t border-[#cbd5e1] bg-[#f8fafc] px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="h-9 cursor-pointer rounded-md border border-[#cbd5e1] bg-white px-4 text-[13px] font-black text-[#475569] transition hover:bg-[#f1f5f9] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className={`inline-flex h-9 cursor-pointer items-center gap-2 rounded-md px-4 text-[13px] font-black transition disabled:cursor-not-allowed disabled:opacity-60 ${confirmClass}`}
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" size={14} aria-hidden="true" /> : Icon ? <Icon size={14} aria-hidden="true" /> : null}
|
||||
{loading ? `${confirmLabel} 중` : confirmLabel}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { AppRoute } from '../routes';
|
||||
import { pageMeta } from '../pageMeta';
|
||||
|
||||
type PageHeaderProps = {
|
||||
route: AppRoute;
|
||||
action?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function PageHeader({ route, action }: PageHeaderProps) {
|
||||
const meta = pageMeta[route];
|
||||
|
||||
return (
|
||||
<div className={`mb-7 ${action ? 'flex flex-wrap items-end justify-between gap-4' : ''}`}>
|
||||
<div>
|
||||
<h1 className="text-[24px] font-black tracking-[0] text-[#0f172a]">{meta.title}</h1>
|
||||
<p className="mt-2 text-[13px] font-bold text-[#64748b]">{meta.description}</p>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
type PaginationBarProps = {
|
||||
totalLabel: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (pageSize: number) => void;
|
||||
};
|
||||
|
||||
const pageSizeOptions = [10, 20, 50];
|
||||
|
||||
export function PaginationBar({ totalLabel, page, pageSize, totalItems, onPageChange, onPageSizeChange }: PaginationBarProps) {
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||
const pages = Array.from({ length: Math.min(totalPages, 5) }, (_, index) => {
|
||||
if (totalPages <= 5) return index + 1;
|
||||
const start = Math.min(Math.max(page - 2, 1), totalPages - 4);
|
||||
return start + index;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-[#cbd5e1] px-4 pb-3 pt-6">
|
||||
<p className="text-[12px] font-bold text-[#64748b]">{totalLabel}</p>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
className="grid h-8 w-8 place-items-center rounded-md border border-[#d1d9e6] bg-white text-[#94a3b8] hover:bg-[#f8fafc] disabled:cursor-default disabled:opacity-35"
|
||||
type="button"
|
||||
aria-label="이전 페이지"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
>
|
||||
<ChevronLeft size={15} aria-hidden="true" />
|
||||
</button>
|
||||
{pages.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
className={`grid h-8 w-8 place-items-center rounded-md text-[12px] font-bold ${
|
||||
item === page ? 'bg-[#4f46e5] text-white' : 'border border-[#d1d9e6] bg-white text-[#64748b] hover:bg-[#f8fafc]'
|
||||
}`}
|
||||
type="button"
|
||||
aria-current={item === page ? 'page' : undefined}
|
||||
onClick={() => onPageChange(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="grid h-8 w-8 place-items-center rounded-md border border-[#d1d9e6] bg-white text-[#94a3b8] hover:bg-[#f8fafc] disabled:cursor-default disabled:opacity-35"
|
||||
type="button"
|
||||
aria-label="다음 페이지"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
||||
>
|
||||
<ChevronRight size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select
|
||||
aria-label="페이지당 표시 개수"
|
||||
className="h-8 rounded-md border border-[#d1d9e6] bg-white px-2 text-[12px] font-bold text-[#64748b] outline-none hover:bg-[#f8fafc] focus:border-[#4f46e5] focus:ring-2 focus:ring-[#4f46e5]/10"
|
||||
value={pageSize}
|
||||
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
||||
>
|
||||
{pageSizeOptions.map((option) => (
|
||||
<option key={option} value={option}>{option}개씩 보기</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { CheckCircle2, CircleAlert, X } from 'lucide-react';
|
||||
|
||||
type Toast = {
|
||||
id: number;
|
||||
title: string;
|
||||
message?: string;
|
||||
variant?: 'success' | 'error';
|
||||
entering?: boolean;
|
||||
closing?: boolean;
|
||||
};
|
||||
|
||||
type ToastContextValue = {
|
||||
showToast: (toast: Omit<Toast, 'id'>) => void;
|
||||
};
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
const TOAST_DURATION_MS = 2800;
|
||||
const TOAST_EXIT_MS = 180;
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
setToasts((current) => current.map((toast) => (toast.id === id ? { ...toast, closing: true } : toast)));
|
||||
window.setTimeout(() => {
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||
}, TOAST_EXIT_MS);
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback((toast: Omit<Toast, 'id'>) => {
|
||||
const id = Date.now() + Math.floor(Math.random() * 1000);
|
||||
setToasts((current) => [...current, { ...toast, id, entering: true }]);
|
||||
window.setTimeout(() => {
|
||||
setToasts((current) => current.map((item) => (item.id === id ? { ...item, entering: false } : item)));
|
||||
}, 20);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ showToast }), [showToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
{createPortal(
|
||||
<div className="fixed bottom-5 right-5 z-[80] flex w-[calc(100vw-40px)] max-w-[360px] flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) throw new Error('useToast must be used within ToastProvider');
|
||||
return context;
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(onClose, TOAST_DURATION_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [onClose]);
|
||||
|
||||
const Icon = toast.variant === 'error' ? CircleAlert : CheckCircle2;
|
||||
const iconTone = toast.variant === 'error' ? 'bg-[#e11d48] text-white' : 'bg-[#4f46e5] text-white';
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border border-[#1e293b] bg-[#111827] px-4 py-3 shadow-[0_22px_54px_rgba(15,23,42,0.32)] transition duration-180 ease-out ${toast.closing || toast.entering ? 'translate-y-2 opacity-0' : 'translate-y-0 opacity-100'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-md ${iconTone}`}>
|
||||
<Icon size={17} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[13px] font-black text-white">{toast.title}</p>
|
||||
{toast.message ? <p className="mt-1 truncate text-[12px] font-bold text-[#cbd5e1]">{toast.message}</p> : null}
|
||||
</div>
|
||||
<button className="grid h-7 w-7 shrink-0 place-items-center rounded-md text-[#cbd5e1] hover:bg-white/10 hover:text-white" type="button" onClick={onClose} aria-label="토스트 닫기" title="토스트 닫기">
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user