diff --git a/components/Headers/Alarm.tsx b/components/Headers/Alarm.tsx index 2d94bff..fa541e7 100644 --- a/components/Headers/Alarm.tsx +++ b/components/Headers/Alarm.tsx @@ -1,28 +1,46 @@ import Image from 'next/image'; +import { useRef, useState } from 'react'; +import NotificationWrapper from '@/components/Notification/NotificationWrapper'; +import useOutsideClick from '@/hooks/useOutsideClick'; interface AlarmProps { isLoggedIn: boolean; } -/** - * 알림 컴포넌트 - * @param isLoggedIn 로그인 여부 판별 - */ - export default function Alarm({ isLoggedIn }: AlarmProps) { + const [isOpen, setIsOpen] = useState(false); + const handleAlarmOpen = () => setIsOpen(!isOpen); + const handleAlarmClose = () => setIsOpen(false); + const alarmRef = useRef(null); + + useOutsideClick(alarmRef, () => setIsOpen(false)); + if (!isLoggedIn) { return null; } return ( - +
+
{ + if (e.key === 'Enter' || e.key === ' ') handleAlarmOpen(); + }} + > + 알림 아이콘 +
+ + {isOpen && ( + + )} +
); } diff --git a/components/Headers/Headers.tsx b/components/Headers/Headers.tsx index 4612185..f5877b2 100644 --- a/components/Headers/Headers.tsx +++ b/components/Headers/Headers.tsx @@ -13,7 +13,7 @@ export default function Headers() { const isMobile = useCheckMobile(); return ( -
+
{/* 로그인 여부에 따라 조건부로 노출 */} -
+
([]); + const [showNotification, setShowNotification] = useState(false); const { showSnackbar } = useSnackbar(); // ProfileContext에서 상태 가져오기 @@ -39,9 +41,16 @@ export default function Login({ isMobile }: LoginProps) { if (!isAuthenticated) { if (isMobile) return ['로그인', '위키목록', '자유게시판']; } else if (isMobile) { - return ['위키목록', '자유게시판', '알림', '마이페이지', '로그아웃']; + return [ + '위키목록', + '자유게시판', + '내 위키', + '알림', + '마이페이지', + '로그아웃', + ]; } else { - return ['마이페이지', '로그아웃']; + return ['마이페이지', '내 위키', '로그아웃']; } return []; }, [isAuthenticated, isMobile]); // 의존성 배열에 필요한 값만 포함 @@ -67,74 +76,94 @@ export default function Login({ isMobile }: LoginProps) { localStorage.removeItem('refreshToken'); // refreshToken 제거 await router.push('/'); showSnackbar('로그아웃 되었습니다.', 'fail'); + } else if (option === '알림') { + setShowNotification(true); + } else if (option === '내 위키') { + await router.push(`/wiki/${profile?.code}`); } }; + const closeNotification = () => { + setShowNotification(false); + }; + useOutsideClick(loginMenuRef, () => setIsOpen(false)); - return isAuthenticated ? ( -
-
setIsOpen(!isOpen)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - setIsOpen(!isOpen); - } - }} - > -
- 프로필 아이콘 -
- 메뉴 아이콘 + return ( + <> + {isAuthenticated ? ( +
+
setIsOpen(!isOpen)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + setIsOpen(!isOpen); + } + }} + > +
+ 프로필 아이콘 +
+ 메뉴 아이콘 - {isOpen && ( - - )} -
-
- ) : isMobile ? ( -
-
+
+ ) : isMobile ? ( +
+ +
+ ) : ( + + )} + {showNotification && ( + - {isOpen && ( - - )} - -
- ) : ( - + )} + ); } diff --git a/components/Menu.tsx b/components/Menu.tsx index 8be8d22..25072f1 100644 --- a/components/Menu.tsx +++ b/components/Menu.tsx @@ -2,6 +2,7 @@ interface MenuProps { options: string[]; menuSize?: string; onSelect: (option: string) => void; + isBorder?: boolean; } /** @@ -11,25 +12,32 @@ interface MenuProps { * @param onSelect 선택한 옵션을 반환 */ -export default function Menu({ options, onSelect, menuSize }: MenuProps) { - const fadeIn = 'pc:animate-pcFadeIn tamo:animate-tamoFadeIn'; +export default function Menu({ + options, + onSelect, + menuSize, + isBorder = true, +}: MenuProps) { return (
    - {options.map((option, index) => ( - - ))} + {options.map((option, index) => { + const isLogout = option === '로그아웃'; + return ( + + ); + })}
); } diff --git a/components/Notification/NorificationBox.tsx b/components/Notification/NorificationBox.tsx new file mode 100644 index 0000000..802a054 --- /dev/null +++ b/components/Notification/NorificationBox.tsx @@ -0,0 +1,76 @@ +import Image from 'next/image'; +import NotificationContents from './NotificationContents'; +import { NotificationProps } from '../Notification/NotificationWrapper'; +import instance from '@/lib/axios-client'; +import { useSnackbar } from 'context/SnackBarContext'; + +interface NotificationBoxProps { + onClose: () => void; + content: NotificationProps | undefined; + setNotification: (notification: NotificationProps) => void; +} + +export default function NotificationBox({ + onClose, + content, + setNotification, +}: NotificationBoxProps) { + const { showSnackbar } = useSnackbar(); + const handleNotiContent = async (id: number) => { + try { + await instance.delete(`/notifications/${id}`); + + if (content?.list) { + const updatedList = content.list.filter( + (notification) => notification.id !== id + ); + + setNotification({ + totalCount: updatedList.length, + list: updatedList, + }); + } + } catch (error) { + showSnackbar('오류가 발생했습니다', 'fail'); + } + }; + + return ( +
+
+
알림 {content?.totalCount}개
+ +
+
+ {content?.list && content.list.length > 0 ? ( + content.list.map((notification) => ( + handleNotiContent(notification.id)} + onClose={onClose} + /> + )) + ) : ( +
+ 최근 알림이 없습니다 +
+ )} +
+
+ ); +} diff --git a/components/Notification/NotificationContents.tsx b/components/Notification/NotificationContents.tsx new file mode 100644 index 0000000..248a131 --- /dev/null +++ b/components/Notification/NotificationContents.tsx @@ -0,0 +1,83 @@ +import { useProfileContext } from '@/hooks/useProfileContext'; +import Image from 'next/image'; +import { useRouter } from 'next/router'; + +interface NotificationContentsProps { + createdAt: Date; + handleNotiContent: () => void; + onClose: () => void; +} + +export default function NotificationContents({ + createdAt, + handleNotiContent, + onClose, +}: NotificationContentsProps) { + const { profile } = useProfileContext(); + const router = useRouter(); + + const now = new Date(); + + const timeDifference = now.getTime() - new Date(createdAt).getTime(); + + const minutes = Math.floor(timeDifference / 1000 / 60); + const hours = Math.floor(timeDifference / 1000 / 60 / 60); + const days = Math.floor(timeDifference / 1000 / 60 / 60 / 24); + + let timeAgo = ''; + if (days > 0) { + timeAgo = `${days}일 전`; + } else if (hours > 0) { + timeAgo = `${hours}시간 전`; + } else if (minutes > 0) { + timeAgo = `${minutes}분 전`; + } else { + timeAgo = '방금'; + } + + const handleLinkClick = async () => { + const url = `/wiki/${profile?.code}`; + await router.replace(url); + handleNotiContent(); + onClose(); + }; + + const handleCloseClick = (e: React.MouseEvent) => { + e.stopPropagation(); + handleNotiContent(); + }; + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') handleLinkClick(); + }} + > +
+
+
6 ? 'bg-red-100' : 'bg-blue-600' + }`} + >
+ +
+
+ 내 위키가 수정되었습니다. +
+
+
{timeAgo}
+
+ ); +} diff --git a/components/Notification/NotificationWrapper.tsx b/components/Notification/NotificationWrapper.tsx new file mode 100644 index 0000000..7d0e561 --- /dev/null +++ b/components/Notification/NotificationWrapper.tsx @@ -0,0 +1,69 @@ +import { useEffect, useRef, useState } from 'react'; +import NotificationBox from '../Notification/NorificationBox'; +import instance from '@/lib/axios-client'; +import { useSnackbar } from 'context/SnackBarContext'; +import useOutsideClick from '@/hooks/useOutsideClick'; +import useCheckMobile from '@/hooks/useCheckMobile'; + +export interface NotificationProps { + totalCount: number; + list: { + createdAt: Date; + content: string; + id: number; + }[]; +} + +interface NotificationWrapperProps { + isOpen: boolean; + onClose: () => void; +} + +export default function NotificationWrapper({ + isOpen, + onClose, +}: NotificationWrapperProps) { + const [notification, setNotification] = useState< + NotificationProps | undefined + >(); + const { showSnackbar } = useSnackbar(); + const notificationRef = useRef(null); + const isMobile = useCheckMobile(); + + // 모바일일 때만 useOutsideClick 훅을 적용 + useOutsideClick(isMobile ? notificationRef : { current: null }, onClose); + + useEffect(() => { + const getNotification = async () => { + const accessToken = localStorage.getItem('accessToken'); + try { + const noti = await instance.get('/notifications?pages=1&pageSize=99', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + setNotification(noti.data); + } catch { + showSnackbar('오류가 발생했습니다', 'fail'); + } + }; + + if (isOpen) { + getNotification(); + } + }, [isOpen, showSnackbar]); + + if (!isOpen) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/pages/wiki/[code].tsx b/pages/wiki/[code].tsx index fa55a4d..70be1ff 100644 --- a/pages/wiki/[code].tsx +++ b/pages/wiki/[code].tsx @@ -63,7 +63,7 @@ export default function Wiki() {
) : profile ? (
- +
) : (
diff --git a/styles/globals.css b/styles/globals.css index 79393f2..afcde08 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -187,3 +187,11 @@ body { .scaleStyle { @apply transition-all hover:scale-[1.02] hover:bg-gray-100 hover:shadow-xl; } + +.downFadein { + @apply animate-pcFadeIn tamo:animate-tamoFadeIn; +} + +.alarmDownFadein { + @apply animate-tamoFadeIn; +}