805 lines
34 KiB
TypeScript
805 lines
34 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Bell,
|
|
BookOpenText,
|
|
Building2,
|
|
Camera,
|
|
CheckCheck,
|
|
ChevronDown,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
ExternalLink,
|
|
Loader2,
|
|
LogOut,
|
|
MessageCircle,
|
|
Search,
|
|
Settings,
|
|
ShieldCheck,
|
|
Trash2,
|
|
X,
|
|
type LucideIcon,
|
|
} from 'lucide-react';
|
|
import { io } from 'socket.io-client';
|
|
import type { Socket } from 'socket.io-client';
|
|
import { ConfirmDialog } from '../components/ConfirmDialog';
|
|
import { InstructorChatPanel } from '../components/InstructorChatPanel';
|
|
import { InstructorLearningGuideModal } from '../components/InstructorLearningGuideModal';
|
|
import { useToast } from '../components/ToastProvider';
|
|
import autonomyLogo from '../assets/icon.png';
|
|
import { useProfileAvatar } from '../hooks/useProfileAvatar';
|
|
import { CHAT_SOCKET_ORIGIN, CHAT_SOCKET_PATH } from '../lib/chat';
|
|
import type { InstructorCourse } from '../lib/courses';
|
|
import { deleteProfileAvatar, notifyProfileAvatarDeleted, notifyProfileAvatarUpdated, resizeImageToWebp, uploadProfileAvatar } from '../lib/profileAvatar';
|
|
import { navItems, type AppRoute } from '../routes';
|
|
import type { AuthSession } from '../types/auth';
|
|
|
|
type InstructorLayoutProps = {
|
|
session: AuthSession;
|
|
route: AppRoute;
|
|
onNavigate: (route: AppRoute) => void;
|
|
onLogout: () => void;
|
|
courses: InstructorCourse[];
|
|
selectedCourseId: string;
|
|
onSelectCourse: (courseId: string) => void;
|
|
chatThreadId?: string | null;
|
|
chatThreadRequestId?: number;
|
|
children: React.ReactNode;
|
|
};
|
|
|
|
type NotificationMessage = {
|
|
id: string;
|
|
title: string;
|
|
body: string;
|
|
category: string;
|
|
categoryLabel?: string;
|
|
audience?: string;
|
|
createdAt: string;
|
|
date: string;
|
|
read: boolean;
|
|
};
|
|
|
|
type ChatNotificationPayload = {
|
|
type: 'chat.message.created';
|
|
threadId: string;
|
|
messageId: string;
|
|
senderType: 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM';
|
|
preview: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
const NOTIFICATION_API_BASE_URL = (import.meta.env.VITE_LAUNCHER_API_BASE_URL ?? 'https://portal-api.autonomystudio.net/api').replace(/\/+$/, '');
|
|
const NOTIFICATION_DROPDOWN_LIMIT = 10;
|
|
const NOTIFICATION_DRAWER_PAGE_SIZE = 8;
|
|
|
|
export function InstructorLayout({ session, route, onNavigate, onLogout, courses, selectedCourseId, onSelectCourse, chatThreadId = null, chatThreadRequestId = 0, children }: InstructorLayoutProps) {
|
|
const [profileOpen, setProfileOpen] = useState(false);
|
|
const [notificationOpen, setNotificationOpen] = useState(false);
|
|
const [notificationDrawerOpen, setNotificationDrawerOpen] = useState(false);
|
|
const [notificationQuery, setNotificationQuery] = useState('');
|
|
const [notificationTab, setNotificationTab] = useState<'unread' | 'read'>('unread');
|
|
const [notificationPage, setNotificationPage] = useState(1);
|
|
const [chatOpen, setChatOpen] = useState(false);
|
|
const [guideOpen, setGuideOpen] = useState(false);
|
|
const [hasUnreadChat, setHasUnreadChat] = useState(false);
|
|
const [notifications, setNotifications] = useState<NotificationMessage[]>([]);
|
|
const profileRef = useRef<HTMLDivElement | null>(null);
|
|
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
|
const { showToast } = useToast();
|
|
const [avatarUploading, setAvatarUploading] = useState(false);
|
|
const [avatarDeleting, setAvatarDeleting] = useState(false);
|
|
const [avatarDeleteConfirmOpen, setAvatarDeleteConfirmOpen] = useState(false);
|
|
const displayName = getDisplayName(session);
|
|
const affiliation = getAffiliation(session);
|
|
const roleLabel = getRoleLabel(session.user.role);
|
|
const { avatarObjectUrl, avatarMissing, avatarLoading, refreshAvatar, setAvatarMissing } = useProfileAvatar(session.accessToken);
|
|
const hasAvatar = !avatarMissing && Boolean(avatarObjectUrl);
|
|
const avatarBusy = avatarLoading || avatarUploading || avatarDeleting;
|
|
const unreadCount = notifications.filter((notification) => !notification.read).length;
|
|
const hasUnreadNotifications = unreadCount > 0;
|
|
const dropdownNotifications = notifications.slice(0, NOTIFICATION_DROPDOWN_LIMIT);
|
|
const filteredDrawerNotifications = useMemo(() => {
|
|
const query = notificationQuery.trim().toLowerCase();
|
|
return notifications.filter((notification) => {
|
|
if (notificationTab === 'unread' && notification.read) return false;
|
|
if (notificationTab === 'read' && !notification.read) return false;
|
|
if (!query) return true;
|
|
return [notification.title, notification.body, notification.categoryLabel ?? notification.category]
|
|
.join(' ')
|
|
.toLowerCase()
|
|
.includes(query);
|
|
});
|
|
}, [notificationQuery, notificationTab, notifications]);
|
|
const notificationTotalPages = Math.max(1, Math.ceil(filteredDrawerNotifications.length / NOTIFICATION_DRAWER_PAGE_SIZE));
|
|
const drawerNotifications = filteredDrawerNotifications.slice(
|
|
(notificationPage - 1) * NOTIFICATION_DRAWER_PAGE_SIZE,
|
|
notificationPage * NOTIFICATION_DRAWER_PAGE_SIZE,
|
|
);
|
|
|
|
useEffect(() => {
|
|
const handlePointerDown = (event: PointerEvent) => {
|
|
if (!profileRef.current?.contains(event.target as Node)) {
|
|
setProfileOpen(false);
|
|
setNotificationOpen(false);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('pointerdown', handlePointerDown);
|
|
return () => window.removeEventListener('pointerdown', handlePointerDown);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
fetch(`${NOTIFICATION_API_BASE_URL}/notifications?appKey=LMS&audience=instructor&page=1&pageSize=100`, {
|
|
headers: {
|
|
Authorization: `Bearer ${session.accessToken}`,
|
|
},
|
|
})
|
|
.then((response) => (response.ok ? response.json() : null))
|
|
.then((payload: { notifications?: NotificationMessage[] } | null) => {
|
|
if (!cancelled) {
|
|
setNotifications((payload?.notifications ?? []).map((notification) => ({ ...notification, read: Boolean(notification.read) })));
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setNotifications([]);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [session.accessToken]);
|
|
|
|
useEffect(() => {
|
|
setNotificationPage(1);
|
|
}, [notificationQuery, notificationTab]);
|
|
|
|
useEffect(() => {
|
|
if (notificationPage > notificationTotalPages) setNotificationPage(notificationTotalPages);
|
|
}, [notificationPage, notificationTotalPages]);
|
|
|
|
useEffect(() => {
|
|
const socket: Socket = io(CHAT_SOCKET_ORIGIN, {
|
|
auth: { token: session.accessToken },
|
|
path: CHAT_SOCKET_PATH,
|
|
transports: ['websocket', 'polling'],
|
|
});
|
|
|
|
const handleMessageCreated = (payload: ChatNotificationPayload) => {
|
|
if (payload.senderType === 'STUDENT' && !chatOpen) setHasUnreadChat(true);
|
|
};
|
|
|
|
socket.on('chat.message.created', handleMessageCreated);
|
|
|
|
return () => {
|
|
socket.off('chat.message.created', handleMessageCreated);
|
|
socket.disconnect();
|
|
};
|
|
}, [chatOpen, session.accessToken]);
|
|
|
|
useEffect(() => {
|
|
if (chatOpen) setHasUnreadChat(false);
|
|
}, [chatOpen]);
|
|
|
|
const handleNotificationClick = () => {
|
|
setNotificationOpen((current) => !current);
|
|
setProfileOpen(false);
|
|
setChatOpen(false);
|
|
setNotificationDrawerOpen(false);
|
|
};
|
|
|
|
const handleChatClick = () => {
|
|
setChatOpen((current) => !current);
|
|
setHasUnreadChat(false);
|
|
setNotificationOpen(false);
|
|
setNotificationDrawerOpen(false);
|
|
setProfileOpen(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!chatThreadId) return;
|
|
setChatOpen(true);
|
|
setHasUnreadChat(false);
|
|
setNotificationOpen(false);
|
|
setNotificationDrawerOpen(false);
|
|
setProfileOpen(false);
|
|
}, [chatThreadId, chatThreadRequestId]);
|
|
|
|
const handleOpenNotificationDrawer = () => {
|
|
setNotificationDrawerOpen(true);
|
|
setNotificationOpen(false);
|
|
setChatOpen(false);
|
|
setProfileOpen(false);
|
|
};
|
|
|
|
const handleMarkAllNotificationsRead = async () => {
|
|
if (!session.accessToken || unreadCount === 0) return;
|
|
|
|
try {
|
|
const response = await fetch(`${NOTIFICATION_API_BASE_URL}/notifications/read-all`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${session.accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
appKey: 'LMS',
|
|
audience: 'instructor',
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) throw new Error('mark_read_failed');
|
|
setNotifications((current) => current.map((notification) => ({ ...notification, read: true })));
|
|
} catch {
|
|
// 다음 조회에서 서버 상태와 다시 동기화된다.
|
|
}
|
|
};
|
|
|
|
const handleMarkNotificationRead = async (notificationId: string) => {
|
|
const target = notifications.find((notification) => notification.id === notificationId);
|
|
if (!session.accessToken || !target || target.read) return;
|
|
|
|
setNotifications((current) =>
|
|
current.map((notification) => (notification.id === notificationId ? { ...notification, read: true } : notification)),
|
|
);
|
|
|
|
try {
|
|
const response = await fetch(`${NOTIFICATION_API_BASE_URL}/notifications/${notificationId}/read`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${session.accessToken}`,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) throw new Error('mark_read_failed');
|
|
} catch {
|
|
setNotifications((current) =>
|
|
current.map((notification) => (notification.id === notificationId ? { ...notification, read: false } : notification)),
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleAvatarSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0];
|
|
event.target.value = '';
|
|
if (!file) return;
|
|
|
|
setAvatarUploading(true);
|
|
|
|
try {
|
|
const imageBlob = await resizeImageToWebp(file, 128);
|
|
await uploadProfileAvatar(session.accessToken, imageBlob);
|
|
refreshAvatar();
|
|
notifyProfileAvatarUpdated();
|
|
showToast({ title: '프로필 이미지가 변경되었습니다.', variant: 'success' });
|
|
} catch (error) {
|
|
showToast({
|
|
title: '프로필 이미지 변경 실패',
|
|
message: error instanceof Error ? error.message : '이미지를 다시 확인해주세요.',
|
|
variant: 'error',
|
|
});
|
|
} finally {
|
|
setAvatarUploading(false);
|
|
}
|
|
};
|
|
|
|
const handleAvatarDelete = async () => {
|
|
setAvatarDeleting(true);
|
|
|
|
try {
|
|
await deleteProfileAvatar(session.accessToken);
|
|
setAvatarMissing(true);
|
|
notifyProfileAvatarDeleted();
|
|
setAvatarDeleteConfirmOpen(false);
|
|
showToast({ title: '프로필 이미지가 삭제되었습니다.', variant: 'success' });
|
|
} catch {
|
|
showToast({ title: '프로필 이미지 삭제 실패', message: '잠시 후 다시 시도해주세요.', variant: 'error' });
|
|
} finally {
|
|
setAvatarDeleting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="relative min-h-screen overflow-hidden bg-[#f7f9ff] text-[#111827]">
|
|
<div className="pointer-events-none fixed inset-x-0 top-0 h-[260px] bg-[radial-gradient(circle_at_42%_36%,rgba(124,58,237,0.13),transparent_22%),radial-gradient(circle_at_80%_28%,rgba(37,99,235,0.12),transparent_24%),linear-gradient(180deg,rgba(239,246,255,0.94)_0%,rgba(247,249,255,0)_100%)] lg:left-[252px]" />
|
|
<div className="pointer-events-none fixed inset-x-0 bottom-0 h-[420px] bg-[linear-gradient(0deg,rgba(124,58,237,0.14)_0%,rgba(167,139,250,0.07)_44%,rgba(247,249,255,0)_100%)] lg:left-[252px]" />
|
|
<div className="pointer-events-none fixed right-[8%] top-[78px] h-2 w-2 rounded-full bg-[#93c5fd]/70" />
|
|
<div className="pointer-events-none fixed left-[53%] top-[92px] h-3 w-3 rounded-full bg-[#c4b5fd]/70" />
|
|
|
|
<header className="fixed right-0 top-0 z-40 flex h-[60px] items-center border-b border-[#94a3b8]/55 bg-white/[0.04] shadow-[inset_0_1px_0_rgba(255,255,255,0.28)] backdrop-blur-[4px] lg:left-[252px]">
|
|
<div className="min-w-0 flex-1" />
|
|
|
|
<div className="relative flex h-full items-center gap-2 px-6" ref={profileRef}>
|
|
<button
|
|
className={`icon-ripple-button relative grid h-9 w-9 cursor-pointer place-items-center rounded-md ${
|
|
notificationOpen ? 'text-[#4f46e5]' : 'text-[#64748b] hover:text-[#111827]'
|
|
}`}
|
|
type="button"
|
|
aria-label="알림"
|
|
aria-expanded={notificationOpen}
|
|
onClick={handleNotificationClick}
|
|
>
|
|
<Bell
|
|
size={17}
|
|
aria-hidden="true"
|
|
className={notificationOpen ? 'fill-[#4f46e5] stroke-[#4f46e5]' : ''}
|
|
/>
|
|
{hasUnreadNotifications ? <span className="absolute right-2 top-2 h-1.5 w-1.5 rounded-full bg-[#e11d48]" /> : null}
|
|
</button>
|
|
|
|
{notificationOpen ? (
|
|
<div className="absolute right-[176px] top-[48px] w-[360px] overflow-hidden rounded-lg border border-[#dfe5ee] bg-white shadow-[0_18px_50px_rgba(15,23,42,0.14)]">
|
|
<div className="border-b border-[#cbd5e1] px-4 py-3">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-[13px] font-black text-[#111827]">알림</p>
|
|
<span className="text-[11px] font-bold text-[#64748b]">안읽음 {unreadCount}건</span>
|
|
</div>
|
|
<div className="mt-3 flex items-center justify-end gap-2">
|
|
<button
|
|
className="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-[#dbe3ef] px-2.5 text-[11px] font-bold text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827] disabled:cursor-default disabled:opacity-40"
|
|
type="button"
|
|
disabled={unreadCount === 0}
|
|
onClick={() => void handleMarkAllNotificationsRead()}
|
|
>
|
|
<CheckCheck size={13} aria-hidden="true" />
|
|
모두 읽음
|
|
</button>
|
|
<button
|
|
className="h-7 cursor-pointer rounded-md border border-[#c7d2fe] bg-[#eef2ff] px-2.5 text-[11px] font-black text-[#4f46e5] hover:bg-[#e0e7ff]"
|
|
type="button"
|
|
onClick={handleOpenNotificationDrawer}
|
|
>
|
|
전체 보기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="max-h-[320px] overflow-y-auto pb-3">
|
|
{dropdownNotifications.length > 0 ? (
|
|
dropdownNotifications.map((notification, index) => (
|
|
<NotificationRow
|
|
key={notification.id}
|
|
notification={notification}
|
|
bordered={index < dropdownNotifications.length - 1}
|
|
onClick={() => void handleMarkNotificationRead(notification.id)}
|
|
/>
|
|
))
|
|
) : (
|
|
<div className="px-4 py-8 text-center">
|
|
<p className="text-[13px] font-bold text-[#64748b]">표시할 알림이 없습니다.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<button
|
|
className={`icon-ripple-button relative grid h-9 w-9 cursor-pointer place-items-center rounded-md ${
|
|
chatOpen || hasUnreadChat ? 'text-[#2563eb]' : 'text-[#64748b] hover:text-[#111827]'
|
|
}`}
|
|
type="button"
|
|
aria-label="강사 채팅"
|
|
aria-expanded={chatOpen}
|
|
onClick={handleChatClick}
|
|
>
|
|
<MessageCircle
|
|
size={17}
|
|
aria-hidden="true"
|
|
className={[
|
|
chatOpen || hasUnreadChat ? 'fill-[#2563eb] stroke-[#2563eb] text-white' : '',
|
|
hasUnreadChat && !chatOpen ? 'icon-chat-blink' : ''
|
|
].join(' ')}
|
|
/>
|
|
</button>
|
|
|
|
<button
|
|
className="flex h-10 items-center gap-1.5 rounded-md px-2 text-left hover:bg-[#f8fafc] cursor-pointer"
|
|
type="button"
|
|
aria-expanded={profileOpen}
|
|
onClick={() => setProfileOpen((current) => !current)}
|
|
>
|
|
<div className="hidden min-w-0 text-right sm:block">
|
|
<p className="truncate text-[13px] font-extrabold text-[#1f2937]">
|
|
{displayName} <span className="font-semibold text-[#64748b]">님</span>
|
|
</p>
|
|
</div>
|
|
<ChevronDown className={`text-[#94a3b8] transition-transform ${profileOpen ? 'rotate-180' : ''}`} size={14} aria-hidden="true" />
|
|
</button>
|
|
|
|
{profileOpen ? (
|
|
<div className="absolute right-6 top-[48px] w-[292px] overflow-hidden rounded-lg border border-[#dfe5ee] bg-white shadow-[0_18px_50px_rgba(15,23,42,0.14)]">
|
|
<div className="border-b border-[#cbd5e1] px-4 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative h-12 w-12 shrink-0">
|
|
<span className="grid h-full w-full place-items-center overflow-hidden rounded-full border border-[#dbe3ef] bg-[#2563eb] text-[15px] font-extrabold text-white">
|
|
{hasAvatar ? (
|
|
<img
|
|
src={avatarObjectUrl ?? undefined}
|
|
alt=""
|
|
className="h-full w-full object-cover"
|
|
onError={() => setAvatarMissing(true)}
|
|
/>
|
|
) : (
|
|
displayName.slice(0, 1)
|
|
)}
|
|
</span>
|
|
{avatarBusy ? (
|
|
<span className="absolute inset-0 grid place-items-center rounded-full bg-white/65 text-[#2563eb] backdrop-blur-[2px]">
|
|
<Loader2 size={16} className="animate-spin" aria-hidden="true" />
|
|
</span>
|
|
) : null}
|
|
<input ref={avatarInputRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarSelect} />
|
|
<button
|
|
type="button"
|
|
className="absolute bottom-0 right-0 z-10 grid h-6 w-6 translate-x-1 translate-y-1 cursor-pointer place-items-center rounded-full border border-white bg-[#2563eb] text-white shadow-[0_8px_18px_rgba(37,99,235,0.28)] transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60"
|
|
disabled={avatarBusy}
|
|
onClick={() => avatarInputRef.current?.click()}
|
|
aria-label="프로필 이미지 등록"
|
|
>
|
|
<Camera size={12} aria-hidden="true" />
|
|
</button>
|
|
{hasAvatar ? (
|
|
<button
|
|
type="button"
|
|
className="absolute right-0 top-0 z-10 grid h-6 w-6 translate-x-1 -translate-y-1 cursor-pointer place-items-center rounded-full border border-white bg-[#fff1f2] text-[#e11d48] shadow-[0_8px_18px_rgba(225,29,72,0.18)] transition hover:bg-[#ffe4e6] disabled:cursor-not-allowed disabled:opacity-60"
|
|
disabled={avatarBusy}
|
|
onClick={() => setAvatarDeleteConfirmOpen(true)}
|
|
aria-label="프로필 이미지 삭제"
|
|
>
|
|
<Trash2 size={12} aria-hidden="true" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-[14px] font-black text-[#111827]">{displayName}</p>
|
|
<p className="mt-1 break-all text-[11px] leading-4 text-[#64748b]">{session.user.email}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-2 text-[12px] font-bold text-[#64748b]">
|
|
<ProfileMeta icon={Building2} label={affiliation} />
|
|
<ProfileMeta icon={ShieldCheck} label={roleLabel} />
|
|
</div>
|
|
<label className="mt-4 block">
|
|
<span className="text-[11px] font-black text-[#64748b]">강좌 선택</span>
|
|
<select
|
|
className="mt-1 h-9 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[12px] font-bold text-[#111827] outline-none transition focus:border-[#2563eb]"
|
|
value={selectedCourseId}
|
|
onChange={(event) => onSelectCourse(event.target.value)}
|
|
>
|
|
{courses.length ? courses.map((course) => (
|
|
<option key={course.id} value={course.id}>
|
|
{course.title}
|
|
</option>
|
|
)) : (
|
|
<option value="">등록된 강좌 없음</option>
|
|
)}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="p-2">
|
|
<ProfileAction
|
|
icon={Settings}
|
|
label="설정"
|
|
onClick={() => {
|
|
setProfileOpen(false);
|
|
onNavigate('settings');
|
|
}}
|
|
/>
|
|
<ProfileAction icon={LogOut} label="로그아웃" danger onClick={onLogout} />
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
</div>
|
|
</header>
|
|
|
|
<ConfirmDialog
|
|
open={avatarDeleteConfirmOpen}
|
|
title="프로필 이미지 삭제"
|
|
message="등록된 썸네일 이미지를 삭제하시겠습니까?"
|
|
confirmLabel="삭제"
|
|
loading={avatarDeleting}
|
|
danger
|
|
icon={Trash2}
|
|
onCancel={() => setAvatarDeleteConfirmOpen(false)}
|
|
onConfirm={() => void handleAvatarDelete()}
|
|
/>
|
|
|
|
<InstructorLearningGuideModal open={guideOpen} onClose={() => setGuideOpen(false)} />
|
|
|
|
<InstructorChatPanel
|
|
session={session}
|
|
courseId={selectedCourseId}
|
|
open={chatOpen}
|
|
initialThreadId={chatThreadId}
|
|
initialThreadRequestId={chatThreadRequestId}
|
|
onClose={() => setChatOpen(false)}
|
|
/>
|
|
<NotificationDrawer
|
|
notifications={drawerNotifications}
|
|
filteredCount={filteredDrawerNotifications.length}
|
|
notificationPage={notificationPage}
|
|
notificationTab={notificationTab}
|
|
notificationTotalPages={notificationTotalPages}
|
|
notificationQuery={notificationQuery}
|
|
open={notificationDrawerOpen}
|
|
unreadCount={unreadCount}
|
|
readCount={notifications.length - unreadCount}
|
|
onClose={() => setNotificationDrawerOpen(false)}
|
|
onMarkAllRead={() => void handleMarkAllNotificationsRead()}
|
|
onMarkRead={(notificationId) => void handleMarkNotificationRead(notificationId)}
|
|
onPageChange={setNotificationPage}
|
|
onQueryChange={setNotificationQuery}
|
|
onTabChange={setNotificationTab}
|
|
/>
|
|
|
|
<aside className="fixed bottom-0 left-0 top-0 z-50 hidden w-[252px] overflow-hidden bg-[linear-gradient(180deg,#071126_0%,#0b1630_56%,#101a3a_100%)] text-white lg:flex lg:flex-col">
|
|
<div className="flex h-[82px] items-center gap-3 px-5">
|
|
<img className="h-8 w-8 rounded-md object-contain" src={autonomyLogo} alt="" />
|
|
<div className="min-w-0">
|
|
<p className="truncate text-[16px] font-black tracking-[0] text-white">Autonomy <span className="text-[#67e8f9]">Studio</span></p>
|
|
<p className="text-[11px] font-semibold leading-tight text-white/68">Instructor Portal</p>
|
|
</div>
|
|
</div>
|
|
|
|
<nav className="flex-1 space-y-2 px-5 py-3">
|
|
{navItems.map((item) => {
|
|
const Icon = item.icon;
|
|
const active = item.id === route;
|
|
return (
|
|
<div key={item.id}>
|
|
<button
|
|
className={`flex h-11 w-full items-center rounded-lg px-4 text-left text-[13px] transition ${
|
|
active
|
|
? 'bg-[linear-gradient(135deg,#6366f1_0%,#7c3aed_100%)] font-black text-white shadow-[0_12px_28px_rgba(79,70,229,0.32)]'
|
|
: 'font-bold text-white/72 hover:bg-white/8 hover:text-white'
|
|
}`}
|
|
type="button"
|
|
onClick={() => onNavigate(item.id)}
|
|
>
|
|
<span className="flex min-w-0 items-center gap-3">
|
|
<Icon className={active ? 'text-white' : 'text-white/72'} size={16} aria-hidden="true" />
|
|
{item.label}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
<div className="px-5 pb-5">
|
|
<div className="rounded-lg border border-white/10 bg-white/8 p-3 shadow-[0_18px_46px_rgba(0,0,0,0.16)] backdrop-blur">
|
|
<div className="mb-3 flex items-start gap-2.5">
|
|
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-md bg-white/12 text-[#8b5cf6]">
|
|
<BookOpenText size={15} aria-hidden="true" />
|
|
</span>
|
|
<div className="min-w-0">
|
|
<p className="text-[12px] font-black text-white">도움이 필요하신가요?</p>
|
|
<p className="mt-1 text-[11px] font-bold leading-5 text-white/62">가이드를 확인하거나 문의해 보세요.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<button onClick={() => setGuideOpen(true)} className="flex h-8 w-full items-center justify-center gap-2 rounded-md bg-white/10 text-[12px] font-bold text-[#a78bfa] hover:bg-white/14" type="button">
|
|
가이드 보기
|
|
<ExternalLink size={13} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<main className="relative min-w-0 lg:pl-[252px]">
|
|
<div className="relative px-6 pb-24 pt-[100px] lg:px-12 lg:pb-32">{children}</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getDisplayName(session: AuthSession): string {
|
|
const name = session.user.name?.trim();
|
|
if (name && name !== 'ATNMY Studio Admin') return name;
|
|
return session.user.email.split('@')[0] || '강사';
|
|
}
|
|
|
|
function getAffiliation(session: AuthSession): string {
|
|
const affiliation = session.user.primaryAffiliation;
|
|
if (!affiliation) return '소속 미지정';
|
|
|
|
return [affiliation.organizationName, affiliation.department, affiliation.position].filter(Boolean).join(' · ');
|
|
}
|
|
|
|
function getRoleLabel(role: string): string {
|
|
const normalized = role.toUpperCase();
|
|
if (normalized === 'LMS_ADMIN') return '관리자';
|
|
if (normalized === 'INSTRUCTOR') return '강사';
|
|
return role;
|
|
}
|
|
|
|
function NotificationDrawer({
|
|
filteredCount,
|
|
notificationPage,
|
|
notificationQuery,
|
|
notificationTab,
|
|
notificationTotalPages,
|
|
notifications,
|
|
open,
|
|
readCount,
|
|
unreadCount,
|
|
onClose,
|
|
onMarkAllRead,
|
|
onMarkRead,
|
|
onPageChange,
|
|
onQueryChange,
|
|
onTabChange,
|
|
}: {
|
|
filteredCount: number;
|
|
notificationPage: number;
|
|
notificationQuery: string;
|
|
notificationTab: 'unread' | 'read';
|
|
notificationTotalPages: number;
|
|
notifications: NotificationMessage[];
|
|
open: boolean;
|
|
readCount: number;
|
|
unreadCount: number;
|
|
onClose: () => void;
|
|
onMarkAllRead: () => void;
|
|
onMarkRead: (notificationId: string) => void;
|
|
onPageChange: React.Dispatch<React.SetStateAction<number>>;
|
|
onQueryChange: (query: string) => void;
|
|
onTabChange: (tab: 'unread' | 'read') => void;
|
|
}) {
|
|
if (!open) 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/94 shadow-[0_22px_70px_rgba(15,23,42,0.18)] backdrop-blur-md">
|
|
<header className="flex h-[72px] shrink-0 items-center justify-between border-b border-[#cbd5e1] px-5">
|
|
<div>
|
|
<h2 className="text-[16px] font-black text-[#0f172a]">전체 알림</h2>
|
|
<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>
|
|
</header>
|
|
|
|
<div className="shrink-0 border-b border-[#cbd5e1] px-5 py-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative min-w-0 flex-1">
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[#94a3b8]" size={15} aria-hidden="true" />
|
|
<input
|
|
className="h-9 w-full rounded-md border border-[#dbe3ef] bg-white pl-9 pr-3 text-[13px] font-bold text-[#111827] outline-none placeholder:text-[#94a3b8] focus:border-[#a5b4fc]"
|
|
value={notificationQuery}
|
|
placeholder="알림 검색"
|
|
onChange={(event) => onQueryChange(event.target.value)}
|
|
/>
|
|
</div>
|
|
<button
|
|
className="inline-flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-[#dbe3ef] px-3 text-[12px] font-bold text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827] disabled:cursor-default disabled:opacity-40"
|
|
type="button"
|
|
disabled={unreadCount === 0}
|
|
onClick={onMarkAllRead}
|
|
>
|
|
<CheckCheck size={14} aria-hidden="true" />
|
|
모두 읽음
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mt-3 flex items-center justify-between">
|
|
<div className="inline-flex rounded-md border border-[#dbe3ef] bg-[#f8fafc] p-1">
|
|
{(['unread', 'read'] as const).map((tab) => (
|
|
<button
|
|
key={tab}
|
|
className={`h-8 cursor-pointer rounded px-3 text-[12px] font-black transition ${
|
|
notificationTab === tab ? 'bg-white text-[#4f46e5] shadow-[0_6px_16px_rgba(15,23,42,0.08)]' : 'text-[#64748b] hover:text-[#111827]'
|
|
}`}
|
|
type="button"
|
|
onClick={() => onTabChange(tab)}
|
|
>
|
|
{tab === 'unread' ? `안 읽음 ${unreadCount}` : `읽음 ${readCount}`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<span className="text-[11px] font-bold text-[#94a3b8]">{filteredCount}건</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6 pt-4">
|
|
{notifications.length > 0 ? (
|
|
<div className="overflow-hidden rounded-lg border border-[#c7d2e0] bg-white shadow-[0_10px_28px_rgba(15,23,42,0.05)]">
|
|
{notifications.map((notification, index) => (
|
|
<NotificationRow
|
|
key={notification.id}
|
|
notification={notification}
|
|
bordered={index < notifications.length - 1}
|
|
onClick={() => onMarkRead(notification.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full items-center justify-center rounded-lg border border-dashed border-[#cbd5e1] bg-white/58 px-6 text-center">
|
|
<p className="text-[13px] font-bold text-[#64748b]">표시할 알림이 없습니다.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<footer className="flex h-[54px] shrink-0 items-center justify-between border-t border-[#cbd5e1] px-5">
|
|
<span className="text-[12px] font-bold text-[#94a3b8]">
|
|
{notificationPage}/{notificationTotalPages}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
className="grid h-8 w-8 cursor-pointer place-items-center rounded-md border border-[#dbe3ef] text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827] disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent"
|
|
type="button"
|
|
aria-label="이전 페이지"
|
|
disabled={notificationPage <= 1}
|
|
onClick={() => onPageChange((page) => Math.max(1, page - 1))}
|
|
>
|
|
<ChevronLeft size={16} aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
className="grid h-8 w-8 cursor-pointer place-items-center rounded-md border border-[#dbe3ef] text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827] disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent"
|
|
type="button"
|
|
aria-label="다음 페이지"
|
|
disabled={notificationPage >= notificationTotalPages}
|
|
onClick={() => onPageChange((page) => Math.min(notificationTotalPages, page + 1))}
|
|
>
|
|
<ChevronRight size={16} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function NotificationRow({ notification, bordered, onClick }: { notification: NotificationMessage; bordered: boolean; onClick: () => void }) {
|
|
return (
|
|
<button
|
|
className={`block w-full cursor-pointer px-4 py-3 text-left transition hover:bg-[#f8fafc] ${bordered ? 'border-b border-[#cbd5e1]' : ''} ${
|
|
notification.read ? 'opacity-70' : ''
|
|
}`}
|
|
type="button"
|
|
onClick={onClick}
|
|
>
|
|
<div className="mb-1.5 flex items-center justify-between gap-3">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
{!notification.read ? <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[#4f46e5]" /> : null}
|
|
<span
|
|
className={`rounded-full px-2 py-0.5 text-[10px] font-black uppercase tracking-[0.02em] ${
|
|
notification.read ? 'bg-[#f1f5f9] text-[#94a3b8]' : 'bg-[#eef2ff] text-[#4f46e5]'
|
|
}`}
|
|
>
|
|
{notification.categoryLabel ?? notification.category}
|
|
</span>
|
|
</div>
|
|
<span className="shrink-0 text-[11px] font-bold text-[#94a3b8]">{notification.date}</span>
|
|
</div>
|
|
<p className={`text-[13px] leading-5 ${notification.read ? 'font-bold text-[#64748b]' : 'font-black text-[#111827]'}`}>{notification.title}</p>
|
|
<p className={`mt-1 text-[12px] font-semibold leading-5 ${notification.read ? 'text-[#94a3b8]' : 'text-[#64748b]'}`}>{notification.body}</p>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ProfileMeta({ icon: Icon, label }: { icon: LucideIcon; label: string }) {
|
|
return (
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<Icon className="shrink-0 text-[#94a3b8]" size={14} aria-hidden="true" />
|
|
<span className="truncate">{label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProfileAction({ icon: Icon, label, danger = false, onClick }: { icon: LucideIcon; label: string; danger?: boolean; onClick: () => void }) {
|
|
return (
|
|
<button
|
|
className={`flex h-9 w-full items-center gap-3 rounded-md px-3 text-left text-[13px] font-bold ${
|
|
danger ? 'text-[#e11d48] hover:bg-[#fff1f2]' : 'text-[#111827] hover:bg-[#f8fafc]'
|
|
}`}
|
|
type="button"
|
|
onClick={onClick}
|
|
>
|
|
<Icon size={15} aria-hidden="true" />
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|