Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added src/assets/badge/badge-ca-15.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/badge/badge-ca-3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/badge/badge-ca-30.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/badge/badge-ca-7.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/badge/badge-ta-10.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/badge/badge-ta-67.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
126 changes: 126 additions & 0 deletions src/components/BadgeToast.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="fixed bottom-[2.5vh] right-[1.5vw] z-50 pointer-events-none">
<div
className={`relative w-[28vw] h-[16.5vh] min-w-[20vw] min-h-[12vh] bg-gray700 border border-purple500 rounded-[1.2vw] shadow-2xl p-[1.4vw] flex items-center gap-[1.2vw] text-white transition-all duration-300 transform pointer-events-auto ${
isVisible
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-[2vh]'
}`}
>
{/* X 닫기 버튼 */}
<button
onClick={handleClose}
className="absolute top-[1.2vh] right-[0.8vw] text-purple500 hover:text-purple400 transition-colors p-[0.3vw] rounded-lg hover:bg-gray800-50 cursor-pointer"
aria-label="닫기"
>
<svg
className="w-[1.4vw] min-w-[18px] h-[1.4vw] min-h-[18px]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>

{/* 배지 이미지 영역 */}
<div className="w-[5.8vw] h-[5.8vw] min-w-[3.8vw] min-h-[3.8vw] rounded-[0.8vw] bg-gray800-50 border border-purple500/30 flex items-center justify-center shrink-0 overflow-hidden p-[0.4vw]">
<img
src={badgeData.src}
alt={badgeData.name}
className="w-full h-full object-contain"
/>
</div>

{/* 텍스트 정보 영역 */}
<div className="flex-1 min-w-0 pr-[1.2vw] flex flex-col justify-center gap-[0.3vh]">
<h4 className="text-[2.2vh] font-bold truncate text-white">
{badgeData.name}
</h4>
<p className="text-[1.6vh] text-gray-200 truncate">
{badgeData.desc}
</p>
<p className="text-[1.4vh] font-medium text-purple400 truncate self-start mt-[0.4vh]">
{badgeData.comment}
</p>
</div>
</div>
</div>
);
};

export default BadgeToast;
29 changes: 29 additions & 0 deletions src/components/SideNav/SideNav.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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') || '사용자';
Expand Down Expand Up @@ -65,6 +69,13 @@ function SideNav() {
};
}, []);

// 💡 테스트용: 버튼 클릭 시 뱃지 3개를 연속으로 대기열에 추가 나중에 삭제해야함
const handleTestTrigger = () => {
triggerBadge('starter');
triggerBadge('CA-3');
triggerBadge('master');
};

return (
<div className="panel-base no-hover flex flex-col justify-between w-[17vw] h-full py-[1.67vh] px-[0.94vw] select-none">
<div className="flex flex-col items-center w-[15.10vw] gap-[6.57vh]">
Expand All @@ -75,6 +86,7 @@ function SideNav() {
const isActive = location.pathname === item.path;

return (
//테스트용 버튼 삭제해야함
<button
key={item.path}
onClick={() => navigate(item.path)}
Expand All @@ -91,6 +103,14 @@ function SideNav() {
</button>
);
})}

{/* 💡 뱃지 연속 달성 테스트 버튼 */}
<button
onClick={handleTestTrigger}
className="mt-1 text-xs text-gray-400 bg-gray-800 hover:bg-gray-700 py-1 px-2 rounded cursor-pointer self-center"
>
뱃지 등장 테스트
</button>
</div>
</div>

Expand Down Expand Up @@ -121,6 +141,15 @@ function SideNav() {
onClick={handleToggleModal}
/>
</div>

{/* 💡 큐에 대기 중인 뱃지가 있으면 하나씩 순차적으로 출력 */}
{currentBadgeId && (
<BadgeToast
key={currentBadgeId}
badgeId={currentBadgeId}
onClose={handleToastClose}
/>
)}
</div>
);
}
Expand Down
180 changes: 167 additions & 13 deletions src/components/report/badgedata.js
Original file line number Diff line number Diff line change
@@ -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')
},

];