From 5b9d93b37f7647ff94a2a32145087785830275e9 Mon Sep 17 00:00:00 2001 From: caadiq Date: Sat, 24 Jan 2026 15:04:29 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8D=B0=EB=B7=94/=EC=A3=BC=EB=85=84?= =?UTF-8?q?=20=EA=B8=B0=EB=85=90=EC=9D=BC=20=EC=B9=B4=EB=93=9C=20=EB=B0=8F?= =?UTF-8?q?=20=EC=B6=95=ED=95=98=20=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DebutCard 컴포넌트 추가 (PC/모바일) - DebutCelebrationDialog 축하 다이얼로그 추가 - Fromis9Logo SVG 컴포넌트 추가 - 기념일 카테고리 추가 (ID: 9) - 데뷔일(2018.01.24) 및 주년 일정 자동 생성 - 폭죽 효과 추가 (fireDebutConfetti) - 카테고리 정보 DB에서 동적 조회하도록 개선 Co-Authored-By: Claude Opus 4.5 --- backend/src/config/index.js | 8 ++ backend/src/services/schedule.js | 55 ++++++++- docs/architecture.md | 10 +- .../common/DebutCelebrationDialog.jsx | 107 ++++++++++++++++++ .../src/components/common/Fromis9Logo.jsx | 22 ++++ frontend/src/components/common/index.js | 2 + .../components/mobile/schedule/DebutCard.jsx | 99 ++++++++++++++++ .../src/components/mobile/schedule/index.js | 1 + .../pc/public/schedule/DebutCard.jsx | 82 ++++++++++++++ .../components/pc/public/schedule/index.js | 1 + .../src/pages/mobile/schedule/Schedule.jsx | 62 +++++++++- .../src/pages/pc/public/schedule/Schedule.jsx | 61 ++++++++-- frontend/src/utils/confetti.js | 59 ++++++++++ frontend/src/utils/index.js | 2 +- 14 files changed, 550 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/common/DebutCelebrationDialog.jsx create mode 100644 frontend/src/components/common/Fromis9Logo.jsx create mode 100644 frontend/src/components/mobile/schedule/DebutCard.jsx create mode 100644 frontend/src/components/pc/public/schedule/DebutCard.jsx diff --git a/backend/src/config/index.js b/backend/src/config/index.js index a6a4b28..b185fea 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -3,6 +3,14 @@ export const CATEGORY_IDS = { YOUTUBE: 2, X: 3, BIRTHDAY: 8, + DEBUT: 9, +}; + +// 데뷔일 (fromis_9: 2018년 1월 24일) +export const DEBUT_DATE = { + year: 2018, + month: 1, + day: 24, }; // 필수 환경변수 검증 diff --git a/backend/src/services/schedule.js b/backend/src/services/schedule.js index 4b70a72..415b798 100644 --- a/backend/src/services/schedule.js +++ b/backend/src/services/schedule.js @@ -2,7 +2,7 @@ * 스케줄 서비스 * 스케줄 관련 비즈니스 로직 */ -import config, { CATEGORY_IDS } from '../config/index.js'; +import config, { CATEGORY_IDS, DEBUT_DATE } from '../config/index.js'; import { getOrSet, cacheKeys, TTL } from '../utils/cache.js'; // ==================== 공통 포맷팅 함수 ==================== @@ -308,6 +308,16 @@ export async function getMonthlySchedules(db, year, month) { // 일정 포맷팅 const schedules = formatSchedules(rawSchedules, memberMap); + // 특수 카테고리 조회 (생일, 기념일) + const [specialCategories] = await db.query( + 'SELECT id, name, color FROM schedule_categories WHERE id IN (?, ?)', + [CATEGORY_IDS.BIRTHDAY, CATEGORY_IDS.DEBUT] + ); + const categoryMap = {}; + for (const cat of specialCategories) { + categoryMap[cat.id] = { id: cat.id, name: cat.name, color: cat.color }; + } + // 생일 조회 및 추가 const [birthdays] = await db.query(` SELECT m.id, m.name, m.name_en, m.birth_date, @@ -328,11 +338,7 @@ export async function getMonthlySchedules(db, year, month) { title: `HAPPY ${member.name_en} DAY`, date: birthdayDate.toISOString().split('T')[0], time: null, - category: { - id: CATEGORY_IDS.BIRTHDAY, - name: '생일', - color: '#f472b6', - }, + category: categoryMap[CATEGORY_IDS.BIRTHDAY], source: null, members: [member.name], is_birthday: true, @@ -340,6 +346,43 @@ export async function getMonthlySchedules(db, year, month) { }); } + // 데뷔/주년 추가 (1월인 경우) + if (month === DEBUT_DATE.month) { + const debutYear = DEBUT_DATE.year; + const anniversaryYear = year - debutYear; + + if (year >= debutYear) { + const debutDate = new Date(year, DEBUT_DATE.month - 1, DEBUT_DATE.day); + + if (year === debutYear) { + // 데뷔 당일 + schedules.push({ + id: 'debut', + title: '프로미스나인 데뷔', + date: debutDate.toISOString().split('T')[0], + time: null, + category: categoryMap[CATEGORY_IDS.DEBUT], + source: null, + members: ['프로미스나인'], + is_debut: true, + }); + } else { + // N주년 + schedules.push({ + id: `anniversary-${anniversaryYear}`, + title: `프로미스나인 데뷔 ${anniversaryYear}주년`, + date: debutDate.toISOString().split('T')[0], + time: null, + category: categoryMap[CATEGORY_IDS.DEBUT], + source: null, + members: ['프로미스나인'], + is_anniversary: true, + anniversary_year: anniversaryYear, + }); + } + } + } + // 날짜순 정렬 schedules.sort((a, b) => a.date.localeCompare(b.date)); diff --git a/docs/architecture.md b/docs/architecture.md index f920afb..6f77936 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,7 +86,7 @@ fromis_9/ │ │ │ ├── index.js │ │ │ ├── cn.js # className 병합 │ │ │ ├── color.js # 색상 상수/유틸 -│ │ │ ├── confetti.js # 생일 축하 효과 +│ │ │ ├── confetti.js # 축하 효과 (생일, 데뷔/주년) │ │ │ ├── date.js # 날짜 포맷 │ │ │ ├── format.js # 문자열 포맷 │ │ │ ├── schedule.js # 일정 관련 유틸 @@ -107,7 +107,9 @@ fromis_9/ │ │ │ │ ├── MobileLightbox.jsx │ │ │ │ ├── LightboxIndicator.jsx │ │ │ │ ├── AnimatedNumber.jsx -│ │ │ │ └── ScrollToTop.jsx +│ │ │ │ ├── ScrollToTop.jsx +│ │ │ │ ├── Fromis9Logo.jsx # 프로미스나인 로고 SVG +│ │ │ │ └── DebutCelebrationDialog.jsx # 데뷔/주년 축하 다이얼로그 │ │ │ │ │ │ │ ├── pc/ │ │ │ │ ├── public/ # PC 공개 컴포넌트 @@ -119,6 +121,7 @@ fromis_9/ │ │ │ │ │ ├── Calendar.jsx │ │ │ │ │ ├── ScheduleCard.jsx │ │ │ │ │ ├── BirthdayCard.jsx +│ │ │ │ │ ├── DebutCard.jsx # 데뷔/주년 카드 │ │ │ │ │ └── CategoryFilter.jsx │ │ │ │ │ │ │ │ │ └── admin/ # PC 관리자 컴포넌트 @@ -159,7 +162,8 @@ fromis_9/ │ │ │ ├── ScheduleCard.jsx │ │ │ ├── ScheduleListCard.jsx │ │ │ ├── ScheduleSearchCard.jsx -│ │ │ └── BirthdayCard.jsx +│ │ │ ├── BirthdayCard.jsx +│ │ │ └── DebutCard.jsx # 데뷔/주년 카드 │ │ │ │ │ ├── pages/ │ │ │ ├── pc/ diff --git a/frontend/src/components/common/DebutCelebrationDialog.jsx b/frontend/src/components/common/DebutCelebrationDialog.jsx new file mode 100644 index 0000000..3c267f5 --- /dev/null +++ b/frontend/src/components/common/DebutCelebrationDialog.jsx @@ -0,0 +1,107 @@ +import { memo, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { X } from 'lucide-react'; +import Fromis9Logo from './Fromis9Logo'; + +/** + * 데뷔/주년 축하 다이얼로그 + * @param {boolean} isOpen - 다이얼로그 표시 여부 + * @param {function} onClose - 닫기 핸들러 + * @param {boolean} isDebut - 데뷔일 여부 (false면 주년) + * @param {number} anniversaryYear - 주년 수 (isDebut이 false일 때) + */ +const DebutCelebrationDialog = memo(function DebutCelebrationDialog({ + isOpen, + onClose, + isDebut = false, + anniversaryYear = 0, +}) { + // ESC 키로 닫기 + useEffect(() => { + const handleKeyDown = (e) => { + if (e.key === 'Escape') onClose(); + }; + if (isOpen) { + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + } + }, [isOpen, onClose]); + + return ( + + {isOpen && ( + + {/* 배경 오버레이 */} +
+ + {/* 다이얼로그 */} + e.stopPropagation()} + className="relative w-full max-w-md overflow-hidden rounded-3xl bg-gradient-to-br from-[#7a99c8] via-[#98b0d8] to-[#b8c8e8] shadow-2xl" + > + {/* 닫기 버튼 */} + + + {/* 배경 장식 */} +
+
+
+
+
+
+
+
+
+
+ + {/* 컨텐츠 */} +
+ {/* 로고/숫자 */} +
+ {isDebut ? ( + + ) : ( +
+
{anniversaryYear}
+
YEARS
+
+ )} +
+ + {/* 텍스트 */} +

+ {isDebut ? '프로미스나인 데뷔' : `프로미스나인 데뷔 ${anniversaryYear}주년`} +

+

+ 2018. 01. 24 +

+
+ + + )} + + ); +}); + +export default DebutCelebrationDialog; diff --git a/frontend/src/components/common/Fromis9Logo.jsx b/frontend/src/components/common/Fromis9Logo.jsx new file mode 100644 index 0000000..71fe1b0 --- /dev/null +++ b/frontend/src/components/common/Fromis9Logo.jsx @@ -0,0 +1,22 @@ +/** + * fromis_9 로고 컴포넌트 + * @param {string} className - 추가 클래스 + * @param {string} fill - 채우기 색상 (기본: currentColor) + * @param {number} size - 크기 (기본: 24) + */ +function Fromis9Logo({ className = '', fill = 'currentColor', size = 24 }) { + return ( + + + + ); +} + +export default Fromis9Logo; diff --git a/frontend/src/components/common/index.js b/frontend/src/components/common/index.js index e3db5c6..2344b66 100644 --- a/frontend/src/components/common/index.js +++ b/frontend/src/components/common/index.js @@ -7,3 +7,5 @@ export { default as Lightbox } from './Lightbox'; export { default as MobileLightbox } from './MobileLightbox'; export { default as LightboxIndicator } from './LightboxIndicator'; export { default as AnimatedNumber } from './AnimatedNumber'; +export { default as Fromis9Logo } from './Fromis9Logo'; +export { default as DebutCelebrationDialog } from './DebutCelebrationDialog'; diff --git a/frontend/src/components/mobile/schedule/DebutCard.jsx b/frontend/src/components/mobile/schedule/DebutCard.jsx new file mode 100644 index 0000000..a99421e --- /dev/null +++ b/frontend/src/components/mobile/schedule/DebutCard.jsx @@ -0,0 +1,99 @@ +import { memo } from 'react'; +import { motion } from 'framer-motion'; +import { Clover } from 'lucide-react'; +import { dayjs } from '@/utils'; +import { Fromis9Logo } from '@/components/common'; + +/** + * Mobile용 데뷔/주년 카드 컴포넌트 + * @param {Object} schedule - 일정 데이터 + * @param {boolean} showYear - 년도 표시 여부 + * @param {number} delay - 애니메이션 딜레이 (초) + */ +const DebutCard = memo(function DebutCard({ schedule, showYear = false, delay = 0 }) { + const scheduleDate = dayjs(schedule.date); + const formatted = { + year: scheduleDate.year(), + month: scheduleDate.month() + 1, + day: scheduleDate.date(), + }; + + const isDebut = schedule.is_debut; + const anniversaryYear = schedule.anniversary_year; + + const CardContent = ( +
+ {/* 배경 별 장식 */} +
+ {/* 반짝이는 별들 */} +
+
+
+
+
+ {/* 원형 장식 */} +
+
+
+ +
+ {/* 아이콘 영역 */} +
+
+ {isDebut ? ( +
+
DEBUT
+
+ ) : ( +
+
{anniversaryYear}
+
YEARS
+
+ )} +
+
+ + {/* 내용 */} +
+

+ {isDebut ? ( + + ) : ( + + )} + {schedule.title} +

+
+ + {/* 날짜 뱃지 */} + {showYear && ( +
+
{formatted.year}
+
{formatted.month}월
+
{formatted.day}
+
+ )} +
+
+ ); + + // delay가 있으면 motion 사용 + if (delay > 0) { + return ( + + {CardContent} + + ); + } + + return CardContent; +}); + +export default DebutCard; diff --git a/frontend/src/components/mobile/schedule/index.js b/frontend/src/components/mobile/schedule/index.js index 348feab..6cff5ca 100644 --- a/frontend/src/components/mobile/schedule/index.js +++ b/frontend/src/components/mobile/schedule/index.js @@ -3,3 +3,4 @@ export { default as ScheduleCard } from './ScheduleCard'; export { default as ScheduleListCard } from './ScheduleListCard'; export { default as ScheduleSearchCard } from './ScheduleSearchCard'; export { default as BirthdayCard } from './BirthdayCard'; +export { default as DebutCard } from './DebutCard'; diff --git a/frontend/src/components/pc/public/schedule/DebutCard.jsx b/frontend/src/components/pc/public/schedule/DebutCard.jsx new file mode 100644 index 0000000..1d18019 --- /dev/null +++ b/frontend/src/components/pc/public/schedule/DebutCard.jsx @@ -0,0 +1,82 @@ +import { memo } from 'react'; +import { Clover } from 'lucide-react'; +import { dayjs } from '@/utils'; +import { Fromis9Logo } from '@/components/common'; + +/** + * PC용 데뷔/주년 카드 컴포넌트 + */ +const DebutCard = memo(function DebutCard({ schedule, showYear = false }) { + const scheduleDate = dayjs(schedule.date); + const formatted = { + year: scheduleDate.year(), + month: scheduleDate.month() + 1, + day: scheduleDate.date(), + }; + + const isDebut = schedule.is_debut; + const anniversaryYear = schedule.anniversary_year; + + return ( +
+ {/* 배경 별 장식 */} +
+ {/* 반짝이는 별들 */} +
+
+
+
+
+
+ {/* 원형 장식 */} +
+
+
+
+ +
+ {/* 아이콘 영역 */} +
+
+ {isDebut ? ( +
+
DEBUT
+
+ ) : ( +
+
{anniversaryYear}
+
YEARS
+
+ )} +
+
+ + {/* 내용 */} +
+

+ {isDebut ? ( + + ) : ( + + )} + {schedule.title} +

+
+ + {/* 날짜 뱃지 */} +
+ {showYear && ( +
{formatted.year}
+ )} +
{formatted.month}월
+
{formatted.day}
+
+
+
+ ); +}); + +export default DebutCard; diff --git a/frontend/src/components/pc/public/schedule/index.js b/frontend/src/components/pc/public/schedule/index.js index 196d75a..ccb3c55 100644 --- a/frontend/src/components/pc/public/schedule/index.js +++ b/frontend/src/components/pc/public/schedule/index.js @@ -1,4 +1,5 @@ export { default as Calendar } from './Calendar'; export { default as ScheduleCard } from './ScheduleCard'; export { default as BirthdayCard } from './BirthdayCard'; +export { default as DebutCard } from './DebutCard'; export { default as CategoryFilter } from './CategoryFilter'; diff --git a/frontend/src/pages/mobile/schedule/Schedule.jsx b/frontend/src/pages/mobile/schedule/Schedule.jsx index 245253a..b8b7695 100644 --- a/frontend/src/pages/mobile/schedule/Schedule.jsx +++ b/frontend/src/pages/mobile/schedule/Schedule.jsx @@ -15,8 +15,10 @@ import { ScheduleListCard as MobileScheduleListCard, ScheduleSearchCard as MobileScheduleSearchCard, BirthdayCard as MobileBirthdayCard, + DebutCard as MobileDebutCard, } from '@/components/mobile'; -import { fireBirthdayConfetti } from '@/utils'; +import { DebutCelebrationDialog } from '@/components/common'; +import { fireBirthdayConfetti, fireDebutConfetti } from '@/utils'; /** * 모바일 일정 페이지 @@ -51,6 +53,8 @@ function MobileSchedule() { const [suggestions, setSuggestions] = useState([]); const [lastSearchTerm, setLastSearchTerm] = useState(''); const [showSuggestionsScreen, setShowSuggestionsScreen] = useState(false); + const [showDebutDialog, setShowDebutDialog] = useState(false); + const [debutDialogInfo, setDebutDialogInfo] = useState({ isDebut: false, anniversaryYear: 0 }); // 검색 모드 진입/종료 const enterSearchMode = () => { @@ -198,6 +202,35 @@ function MobileSchedule() { } }, [schedules, loading]); + // 데뷔/주년 폭죽 효과 및 다이얼로그 + useEffect(() => { + if (loading || schedules.length === 0) return; + + const today = getTodayKST(); + const confettiKey = `debut-confetti-${today}`; + + if (localStorage.getItem(confettiKey)) return; + + const debutSchedule = schedules.find((s) => { + if (!s.is_debut && !s.is_anniversary) return false; + const scheduleDate = s.date ? s.date.split('T')[0] : ''; + return scheduleDate === today; + }); + + if (debutSchedule) { + const timer = setTimeout(() => { + fireDebutConfetti(); + setDebutDialogInfo({ + isDebut: debutSchedule.is_debut, + anniversaryYear: debutSchedule.anniversary_year || 0, + }); + setShowDebutDialog(true); + localStorage.setItem(confettiKey, 'true'); + }, 500); + return () => clearTimeout(timer); + } + }, [schedules, loading]); + // 2017년 1월 이전으로 이동 불가 const canGoPrevMonth = !(selectedDate.getFullYear() === MIN_YEAR && selectedDate.getMonth() === 0); @@ -308,8 +341,12 @@ function MobileSchedule() { .sort((a, b) => { const aIsBirthday = a.is_birthday || String(a.id).startsWith('birthday-'); const bIsBirthday = b.is_birthday || String(b.id).startsWith('birthday-'); - if (aIsBirthday && !bIsBirthday) return -1; - if (!aIsBirthday && bIsBirthday) return 1; + const aIsDebut = a.is_debut || a.is_anniversary; + const bIsDebut = b.is_debut || b.is_anniversary; + const aIsSpecial = aIsBirthday || aIsDebut; + const bIsSpecial = bIsBirthday || bIsDebut; + if (aIsSpecial && !bIsSpecial) return -1; + if (!aIsSpecial && bIsSpecial) return 1; return 0; }); }, [schedules, selectedDate]); @@ -743,6 +780,7 @@ function MobileSchedule() {
{selectedDateSchedules.map((schedule, index) => { const isBirthday = schedule.is_birthday || String(schedule.id).startsWith('birthday-'); + const isDebut = schedule.is_debut || schedule.is_anniversary; if (isBirthday) { return ( @@ -759,6 +797,16 @@ function MobileSchedule() { ); } + if (isDebut) { + return ( + + ); + } + return (
+ + {/* 데뷔/주년 축하 다이얼로그 */} + setShowDebutDialog(false)} + isDebut={debutDialogInfo.isDebut} + anniversaryYear={debutDialogInfo.anniversaryYear} + /> ); } diff --git a/frontend/src/pages/pc/public/schedule/Schedule.jsx b/frontend/src/pages/pc/public/schedule/Schedule.jsx index 5ca2e0d..72859bc 100644 --- a/frontend/src/pages/pc/public/schedule/Schedule.jsx +++ b/frontend/src/pages/pc/public/schedule/Schedule.jsx @@ -11,8 +11,10 @@ import { CategoryFilter, ScheduleCard, BirthdayCard, + DebutCard, } from '@/components/pc/public'; -import { fireBirthdayConfetti } from '@/utils'; +import { DebutCelebrationDialog } from '@/components/common'; +import { fireBirthdayConfetti, fireDebutConfetti } from '@/utils'; import { getSchedules, searchSchedules } from '@/api'; import { useScheduleStore } from '@/stores'; import { getTodayKST } from '@/utils'; @@ -53,6 +55,8 @@ function PCSchedule() { const [suggestions, setSuggestions] = useState([]); const [originalSearchQuery, setOriginalSearchQuery] = useState(''); const [showCategoryTooltip, setShowCategoryTooltip] = useState(false); + const [showDebutDialog, setShowDebutDialog] = useState(false); + const [debutDialogInfo, setDebutDialogInfo] = useState({ isDebut: false, anniversaryYear: 0 }); const year = currentDate.getFullYear(); const month = currentDate.getMonth(); @@ -135,6 +139,27 @@ function PCSchedule() { } }, [schedules, loading]); + // 오늘 데뷔/주년 폭죽 및 다이얼로그 + useEffect(() => { + if (loading || schedules.length === 0) return; + const today = getTodayKST(); + const confettiKey = `debut-confetti-${today}`; + if (localStorage.getItem(confettiKey)) return; + const debutSchedule = schedules.find((s) => (s.is_debut || s.is_anniversary) && s.date === today); + if (debutSchedule) { + const timer = setTimeout(() => { + fireDebutConfetti(); + setDebutDialogInfo({ + isDebut: debutSchedule.is_debut, + anniversaryYear: debutSchedule.anniversary_year || 0, + }); + setShowDebutDialog(true); + localStorage.setItem(confettiKey, 'true'); + }, 500); + return () => clearTimeout(timer); + } + }, [schedules, loading]); + // 외부 클릭 처리 useEffect(() => { const handleClickOutside = (event) => { @@ -213,20 +238,24 @@ function PCSchedule() { const currentYearMonth = `${year}-${String(month + 1).padStart(2, '0')}`; const filteredSchedules = useMemo(() => { - const sortWithBirthdayFirst = (list) => { + const sortWithSpecialFirst = (list) => { return [...list].sort((a, b) => { const aIsBirthday = a.is_birthday || String(a.id).startsWith('birthday-'); const bIsBirthday = b.is_birthday || String(b.id).startsWith('birthday-'); - if (aIsBirthday && !bIsBirthday) return -1; - if (!aIsBirthday && bIsBirthday) return 1; + const aIsDebut = a.is_debut || a.is_anniversary; + const bIsDebut = b.is_debut || b.is_anniversary; + const aIsSpecial = aIsBirthday || aIsDebut; + const bIsSpecial = bIsBirthday || bIsDebut; + if (aIsSpecial && !bIsSpecial) return -1; + if (!aIsSpecial && bIsSpecial) return 1; return 0; }); }; if (isSearchMode) { if (!searchTerm) return []; - if (selectedCategories.length === 0) return sortWithBirthdayFirst(searchResults); - return sortWithBirthdayFirst(searchResults.filter((s) => selectedCategories.includes(s.category_id))); + if (selectedCategories.length === 0) return sortWithSpecialFirst(searchResults); + return sortWithSpecialFirst(searchResults.filter((s) => selectedCategories.includes(s.category_id))); } const filtered = schedules @@ -238,8 +267,12 @@ function PCSchedule() { .sort((a, b) => { const aIsBirthday = a.is_birthday || String(a.id).startsWith('birthday-'); const bIsBirthday = b.is_birthday || String(b.id).startsWith('birthday-'); - if (aIsBirthday && !bIsBirthday) return -1; - if (!aIsBirthday && bIsBirthday) return 1; + const aIsDebut = a.is_debut || a.is_anniversary; + const bIsDebut = b.is_debut || b.is_anniversary; + const aIsSpecial = aIsBirthday || aIsDebut; + const bIsSpecial = bIsBirthday || bIsDebut; + if (aIsSpecial && !bIsSpecial) return -1; + if (!aIsSpecial && bIsSpecial) return 1; if (a.date !== b.date) return a.date.localeCompare(b.date); return (a.time || '00:00:00').localeCompare(b.time || '00:00:00'); }); @@ -535,6 +568,8 @@ function PCSchedule() {
{schedule.is_birthday ? ( handleScheduleClick(schedule)} /> + ) : schedule.is_debut || schedule.is_anniversary ? ( + ) : ( handleScheduleClick(schedule)} /> )} @@ -564,6 +599,8 @@ function PCSchedule() { > {schedule.is_birthday ? ( handleScheduleClick(schedule)} /> + ) : schedule.is_debut || schedule.is_anniversary ? ( + ) : ( handleScheduleClick(schedule)} /> )} @@ -587,6 +624,14 @@ function PCSchedule() {
+ + {/* 데뷔/주년 축하 다이얼로그 */} + setShowDebutDialog(false)} + isDebut={debutDialogInfo.isDebut} + anniversaryYear={debutDialogInfo.anniversaryYear} + />
); } diff --git a/frontend/src/utils/confetti.js b/frontend/src/utils/confetti.js index 8d04b83..7be889b 100644 --- a/frontend/src/utils/confetti.js +++ b/frontend/src/utils/confetti.js @@ -1,5 +1,64 @@ import confetti from 'canvas-confetti'; +/** + * 데뷔/주년 폭죽 애니메이션 + * PC/Mobile 공용 + */ +export function fireDebutConfetti() { + const duration = 3000; + const animationEnd = Date.now() + duration; + const colors = ['#7a99c8', '#98b0d8', '#b8c8e8', '#ffffff', '#ffd700', '#c0c0c0']; + + const randomInRange = (min, max) => Math.random() * (max - min) + min; + + const interval = setInterval(() => { + const timeLeft = animationEnd - Date.now(); + + if (timeLeft <= 0) { + clearInterval(interval); + return; + } + + const particleCount = 50 * (timeLeft / duration); + + // 왼쪽에서 발사 + confetti({ + particleCount: Math.floor(particleCount), + startVelocity: 30, + spread: 60, + origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }, + colors, + shapes: ['circle', 'square'], + gravity: 1.2, + scalar: randomInRange(0.8, 1.2), + drift: randomInRange(-0.5, 0.5), + }); + + // 오른쪽에서 발사 + confetti({ + particleCount: Math.floor(particleCount), + startVelocity: 30, + spread: 60, + origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }, + colors, + shapes: ['circle', 'square'], + gravity: 1.2, + scalar: randomInRange(0.8, 1.2), + drift: randomInRange(-0.5, 0.5), + }); + }, 250); + + // 초기 대형 폭죽 + confetti({ + particleCount: 100, + spread: 100, + origin: { x: 0.5, y: 0.6 }, + colors, + shapes: ['circle', 'square'], + startVelocity: 45, + }); +} + /** * 생일 폭죽 애니메이션 * PC/Mobile 공용 diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index 42bb58d..e256463 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -48,4 +48,4 @@ export { } from './schedule'; // 애니메이션 관련 -export { fireBirthdayConfetti } from './confetti'; +export { fireBirthdayConfetti, fireDebutConfetti } from './confetti';