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([]); const profileRef = useRef(null); const avatarInputRef = useRef(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) => { 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 (
{notificationOpen ? (

알림

안읽음 {unreadCount}건
{dropdownNotifications.length > 0 ? ( dropdownNotifications.map((notification, index) => ( void handleMarkNotificationRead(notification.id)} /> )) ) : (

표시할 알림이 없습니다.

)}
) : null} {profileOpen ? (
{hasAvatar ? ( setAvatarMissing(true)} /> ) : ( displayName.slice(0, 1) )} {avatarBusy ? ( ) : null} {hasAvatar ? ( ) : null}

{displayName}

{session.user.email}

{ setProfileOpen(false); onNavigate('settings'); }} />
) : null}
setAvatarDeleteConfirmOpen(false)} onConfirm={() => void handleAvatarDelete()} /> setGuideOpen(false)} /> setChatOpen(false)} /> setNotificationDrawerOpen(false)} onMarkAllRead={() => void handleMarkAllNotificationsRead()} onMarkRead={(notificationId) => void handleMarkNotificationRead(notificationId)} onPageChange={setNotificationPage} onQueryChange={setNotificationQuery} onTabChange={setNotificationTab} />
{children}
); } 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>; onQueryChange: (query: string) => void; onTabChange: (tab: 'unread' | 'read') => void; }) { if (!open) return null; return ( ); } function NotificationRow({ notification, bordered, onClick }: { notification: NotificationMessage; bordered: boolean; onClick: () => void }) { return ( ); } function ProfileMeta({ icon: Icon, label }: { icon: LucideIcon; label: string }) { return (
); } function ProfileAction({ icon: Icon, label, danger = false, onClick }: { icon: LucideIcon; label: string; danger?: boolean; onClick: () => void }) { return ( ); }