Files
lms-adm-fe/src/layouts/InstructorLayout.tsx
T

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>
);
}