diff --git a/src/assets/badge/badge-ca-15.png b/src/assets/badge/badge-ca-15.png new file mode 100644 index 0000000..1e6baac Binary files /dev/null and b/src/assets/badge/badge-ca-15.png differ diff --git a/src/assets/badge/badge-ca-3.png b/src/assets/badge/badge-ca-3.png new file mode 100644 index 0000000..c08fe38 Binary files /dev/null and b/src/assets/badge/badge-ca-3.png differ diff --git a/src/assets/badge/badge-ca-30.png b/src/assets/badge/badge-ca-30.png new file mode 100644 index 0000000..39d3de9 Binary files /dev/null and b/src/assets/badge/badge-ca-30.png differ diff --git a/src/assets/badge/badge-ca-7.png b/src/assets/badge/badge-ca-7.png new file mode 100644 index 0000000..c13469b Binary files /dev/null and b/src/assets/badge/badge-ca-7.png differ diff --git a/src/assets/badge/badge-ta-10.png b/src/assets/badge/badge-ta-10.png new file mode 100644 index 0000000..c480182 Binary files /dev/null and b/src/assets/badge/badge-ta-10.png differ diff --git a/src/assets/badge/badge-ta-67.png b/src/assets/badge/badge-ta-67.png new file mode 100644 index 0000000..56e05a0 Binary files /dev/null and b/src/assets/badge/badge-ta-67.png differ diff --git a/src/components/BadgeToast.jsx b/src/components/BadgeToast.jsx new file mode 100644 index 0000000..0033d89 --- /dev/null +++ b/src/components/BadgeToast.jsx @@ -0,0 +1,126 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { BADGE_DATA } from './report/badgedata'; // badgedata.js 경로 + +// ---------------------------------------------------- +// 1. 뱃지 순차 출력(큐)을 관리하는 Custom Hook +// ---------------------------------------------------- +export const useBadgeToast = () => { + const [badgeQueue, setBadgeQueue] = useState([]); + + // 뱃지 달성 이벤트 발생 시 대기열(큐)에 추가 + const triggerBadge = useCallback((badgeId) => { + setBadgeQueue((prev) => [...prev, badgeId]); + }, []); + + // 토스트 하나가 완전히 닫히면 대기열에서 완료된 뱃지 제거 + const handleToastClose = useCallback(() => { + setBadgeQueue((prev) => prev.slice(1)); + }, []); + + // 현재 출력할 뱃지 ID (큐의 첫 번째 항목) + const currentBadgeId = badgeQueue[0] || null; + + return { + currentBadgeId, + triggerBadge, + handleToastClose, + }; +}; + +// ---------------------------------------------------- +// 2. 뷰포트 기반 BadgeToast UI 컴포넌트 +// ---------------------------------------------------- +const BadgeToast = ({ badgeId = 'starter', onClose }) => { + const [isVisible, setIsVisible] = useState(false); + + // BADGE_DATA에서 전달받은 badgeId에 맞는 뱃지 데이터 검색 + const badgeData = BADGE_DATA.find((badge) => badge.id === badgeId) || BADGE_DATA[0]; + + // 수동 닫기 (X 버튼 클릭 시) + const handleClose = () => { + setIsVisible(false); + setTimeout(() => { + if (onClose) onClose(); + }, 600); + }; + + useEffect(() => { + // 1. 마운트 직후 페이드인 & 슬라이드 업 + const showTimer = setTimeout(() => { + setIsVisible(true); + }, 50); + + // 2. 5초 유지 후 페이드아웃 시작 + const hideTimer = setTimeout(() => { + setIsVisible(false); + }, 5000); + + // 3. 5.6초 후 완전히 종료 콜백 호출 -> 큐에서 다음 뱃지 꺼냄 + const closeTimer = setTimeout(() => { + if (onClose) onClose(); + }, 5600); + + return () => { + clearTimeout(showTimer); + clearTimeout(hideTimer); + clearTimeout(closeTimer); + }; + }, [badgeId, onClose]); + + return ( +
+
+ {/* X 닫기 버튼 */} + + + {/* 배지 이미지 영역 */} +
+ {badgeData.name} +
+ + {/* 텍스트 정보 영역 */} +
+

+ {badgeData.name} +

+

+ {badgeData.desc} +

+

+ {badgeData.comment} +

+
+
+
+ ); +}; + +export default BadgeToast; \ No newline at end of file diff --git a/src/components/SideNav/SideNav.jsx b/src/components/SideNav/SideNav.jsx index 0d30e91..0725d74 100644 --- a/src/components/SideNav/SideNav.jsx +++ b/src/components/SideNav/SideNav.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import SettingIcon from "../../assets/SettingIcon.png"; import UserModal from "./UserModal"; +import BadgeToast, { useBadgeToast } from "../BadgeToast"; // 💡 통합된 BadgeToast 및 useBadgeToast import function SideNav() { const menuItems = [ @@ -16,6 +17,9 @@ function SideNav() { const [isModalOpen, setIsModalOpen] = useState(false); + // 💡 뱃지 큐 관리를 위한 Custom Hook 호출 + const { currentBadgeId, triggerBadge, handleToastClose } = useBadgeToast(); + // 💡 닉네임을 state로 관리하여 변경 감지 가능하게 설정 const [nickname, setNickname] = useState(() => { return localStorage.getItem('nickname') || '사용자'; @@ -65,6 +69,13 @@ function SideNav() { }; }, []); + // 💡 테스트용: 버튼 클릭 시 뱃지 3개를 연속으로 대기열에 추가 나중에 삭제해야함 + const handleTestTrigger = () => { + triggerBadge('starter'); + triggerBadge('CA-3'); + triggerBadge('master'); + }; + return (
@@ -75,6 +86,7 @@ function SideNav() { const isActive = location.pathname === item.path; return ( + //테스트용 버튼 삭제해야함 ); })} + + {/* 💡 뱃지 연속 달성 테스트 버튼 */} +
@@ -121,6 +141,15 @@ function SideNav() { onClick={handleToggleModal} /> + + {/* 💡 큐에 대기 중인 뱃지가 있으면 하나씩 순차적으로 출력 */} + {currentBadgeId && ( + + )} ); } diff --git a/src/components/report/badgedata.js b/src/components/report/badgedata.js index c771f48..cb80332 100644 --- a/src/components/report/badgedata.js +++ b/src/components/report/badgedata.js @@ -1,25 +1,179 @@ -import badgeStarter from '../../assets/badge/badge-starter.png'; -import badgeMaster from '../../assets/badge/badge-master.png'; -import badgeTotal500 from '../../assets/badge/badge-total-500.png'; +const images = import.meta.glob('../../assets/badge/*.png', { + eager: true, + import: 'default' +}); + +// 파일명(확장자 제외)만 넣으면 해당 이미지를 반환하는 함수 +const getImg = (name) => images[`../../assets/badge/${name}.png`]; export const BADGE_DATA = [ { id: 'starter', - name: '스타터 배지', - desc: 'CoFix에 오신것을 환영합니다.', - src: badgeStarter + name: 'Hello, World!', + desc: 'Cofix 첫 시작', + comment: '"Cofix에 오신것을 환영합니다!"', + src: getImg('badge-starter') }, { id: 'master', - name: '마스터 배지', - desc: '모든 배지를 획득하셨습니다!', - src: badgeMaster + name: '진(眞) 고인물', + desc: 'Cofix의 모든 배지 획득', + comment: '"모든 배지를 획득하셨습니다!"', + src: getImg('badge-master') + }, + { + id: 'CA-3', //Consecutive Attendance 연속출석 + name: '또 왔어?', + desc: '연속출석 3일 완료하셨습니다!', + comment: '"또 오셨네요?"', + src: getImg('badge-ca-3') + }, + { + id: 'CA-7', + name: '단골손님', + desc: '연속출석 7일 완료하셨습니다.', + comment: '"저희 가게 단골이십니다."', + src: getImg('badge-ca-7') + }, + { + id: 'CA-15', + name: '근면성실 코딩 개미', + desc: '연속출석 15일 완료하셨습니다.', + comment: '"개미는.. 뚠뚠 오늘도.. 뚠뚠"', + src: getImg('badge-ca-15') + }, + { + id: 'CA-30', + name: '사이버 지박령', + desc: '연속출석 30일 완료하셨습니다!', + comment: '"당신은 사이버 지박령입니다."', + src: getImg('badge-ca-30') + }, + { + id: 'TA-10', //Total Attendance 누적출석 약자 + name: '열 번 찍어 안 넘어 가는 코드 없다.', + desc: '누적 출석 10일 완료하셨습니다!', + comment: '"10번 찍으면 넘어갈지도.."', + src: getImg('badge-ta-10') + }, + { + id: 'TA-67', + name: '식세븐', + desc: '누적 출석 67일 완료하셨습니다!', + comment: '"67!!!!!!!!!!!!!!!!!"', + src: getImg('badge-ta-67') + }, + { + id: 'TA-100', + name: '사람이 되었습니다.', + desc: '누적 출석 100일 완료하셨습니다!', + comment: '"사람되기 이렇게 힘든일이엿나?"', + src: getImg('badge-starter') + }, + { + id: 'TA-365', + name: '코딩과 물아일체', + desc: '누적 출석 365일 완료하셨습니다!', + comment: '"깨달았다.. 코딩의 핵심!!"', + src: getImg('badge-starter') + }, + { + id: 'TA-777', + name: '럭키데이', + desc: '누적 출석 777일 완료하셨습니다!', + comment: '"뭔가 좋은일이 있으면 좋겠네.."', + src: getImg('badge-starter') }, { - id: 'total-500', - name: '오답노트 500회', - desc: '오답노트 500회 사용하셨습니다!', - src: badgeTotal500 + id: 'VD-100', //Vulnerability Detection 약점 발견 약자 + name: '꼬마 탐정', + desc: '약점을 100개 발견하였습니다!', + comment: '"약점을 찾았다!!"', + src: getImg('badge-starter') + }, + { + id: 'VD-300', + name: '견습 탐정', + desc: '약점을 300개 발견하였습니다!', + comment: '"내 이름은 Co난.."', + src: getImg('badge-starter') + }, + { + id: 'VD-500', + name: '유명한 탐정', + desc: '약점을 500개 발견하였습니다!', + comment: '"내 이름은 유명한.."', + src: getImg('badge-total-500') + }, + { + id: 'VD-999', + name: '비둘기 탐정', + desc: '약점을 999개 발견하였습니다!', + comment: '"비둘기야 먹자~"', + src: getImg('badge-starter') + }, + { + id: 'WR-50', //Weakness Resolution 약점 해결 + name: '동네 해결사', + desc: '약점을 50회 해결했습니다!', + comment: '"내가 해결해주마."', + src: getImg('badge-starter') + }, + { + id: 'WR-100', + name: '약점엔 쎈 해결사', + desc: '약점을 100회 해결했습니다!', + comment: '"이것도 해결해 보시지!"', + src: getImg('badge-starter') + }, + { + id: 'WR-300', + name: '은빛 해결사', + desc: '약점을 300회 해결했습니다!', + comment: '"더 이상의 허점은 존재하지 않는다."', + src: getImg('badge-starter') + }, + { + id: 'WR-500', + name: '은빛 해결사', + desc: '약점을 500회 해결했습니다!', + comment: '"내가.. 정점에 서겠다."', + src: getImg('badge-starter') + }, + { + id: 'WR-999', + name: '비둘기 해결사', + desc: '약점을 999회 해결했습니다!', + comment: '"비둘기야 먹자~~"', + src: getImg('badge-starter') + }, + { + id: 'CD-5', //Coding Duration 누적 코딩시간 + name: '어린이', + desc: '누적 코딩 5시간 달성하셨습니다!', + comment: '"벌써 그렇게 됬나?"', + src: getImg('badge-starter') + }, + { + id: 'CD-20', + name: '청년', + desc: '누적 코딩 20시간 달성하셨습니다!', + comment: '"코딩했음청년"', + src: getImg('badge-starter') + }, + { + id: 'CD-90', + name: '코드 깎는 노인', + desc: '누적 코딩 90시간 달성하셨습니다!', + comment: '"니들이 게 맛을 알어?"', + src: getImg('badge-starter') + }, + { + id: 'CD-999', + name: '코드 깎는 비둘기', + desc: '누적 코딩 999시간 달성하셨습니다!', + comment: '"비둘기야 먹자~~~"', + src: getImg('badge-starter') }, ]; \ No newline at end of file