feat: 데뷔/주년 기념일 카드 및 축하 다이얼로그 추가
- DebutCard 컴포넌트 추가 (PC/모바일) - DebutCelebrationDialog 축하 다이얼로그 추가 - Fromis9Logo SVG 컴포넌트 추가 - 기념일 카테고리 추가 (ID: 9) - 데뷔일(2018.01.24) 및 주년 일정 자동 생성 - 폭죽 효과 추가 (fireDebutConfetti) - 카테고리 정보 DB에서 동적 조회하도록 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a7bc2e9800
commit
5b9d93b37f
14 changed files with 550 additions and 21 deletions
|
|
@ -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,
|
||||
};
|
||||
|
||||
// 필수 환경변수 검증
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
107
frontend/src/components/common/DebutCelebrationDialog.jsx
Normal file
107
frontend/src/components/common/DebutCelebrationDialog.jsx
Normal file
|
|
@ -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 (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* 배경 오버레이 */}
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
||||
|
||||
{/* 다이얼로그 */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.8, opacity: 0, y: 20 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
onClick={(e) => 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"
|
||||
>
|
||||
{/* 닫기 버튼 */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 z-10 p-2 rounded-full bg-white/20 hover:bg-white/30 transition-colors"
|
||||
>
|
||||
<X size={20} className="text-white" />
|
||||
</button>
|
||||
|
||||
{/* 배경 장식 */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-4 left-8 text-yellow-200 text-2xl animate-pulse">✦</div>
|
||||
<div className="absolute top-12 right-16 text-yellow-200/80 text-xl animate-pulse delay-100">✦</div>
|
||||
<div className="absolute bottom-20 left-12 text-yellow-200/60 text-3xl animate-pulse delay-200">✦</div>
|
||||
<div className="absolute bottom-12 right-8 text-yellow-200 text-xl animate-pulse delay-150">✦</div>
|
||||
<div className="absolute top-1/3 left-4 text-white/40 text-lg animate-pulse delay-300">✦</div>
|
||||
<div className="absolute top-1/2 right-4 text-white/30 text-sm animate-pulse delay-250">✦</div>
|
||||
<div className="absolute -top-16 -left-16 w-48 h-48 bg-white/10 rounded-full" />
|
||||
<div className="absolute -bottom-20 -right-20 w-56 h-56 bg-white/10 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div className="relative flex flex-col items-center py-12 px-8 text-center">
|
||||
{/* 로고/숫자 */}
|
||||
<div className="w-28 h-28 rounded-full bg-white/30 backdrop-blur-sm flex items-center justify-center shadow-lg border-4 border-white/30 mb-6">
|
||||
{isDebut ? (
|
||||
<Fromis9Logo size={56} fill="white" className="drop-shadow-lg" />
|
||||
) : (
|
||||
<div className="text-center" style={{ textShadow: '0 2px 4px rgba(0,0,0,0.2)' }}>
|
||||
<div className="text-white font-black text-4xl leading-none">{anniversaryYear}</div>
|
||||
<div className="text-white/80 text-xs font-bold tracking-wider">YEARS</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 텍스트 */}
|
||||
<h2
|
||||
className="text-white font-bold text-2xl mb-2"
|
||||
style={{ textShadow: '0 2px 4px rgba(0,0,0,0.2)' }}
|
||||
>
|
||||
{isDebut ? '프로미스나인 데뷔' : `프로미스나인 데뷔 ${anniversaryYear}주년`}
|
||||
</h2>
|
||||
<p
|
||||
className="text-white/80 text-base"
|
||||
style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}
|
||||
>
|
||||
2018. 01. 24
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
|
||||
export default DebutCelebrationDialog;
|
||||
22
frontend/src/components/common/Fromis9Logo.jsx
Normal file
22
frontend/src/components/common/Fromis9Logo.jsx
Normal file
|
|
@ -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 (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1000 1000"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
fill={fill}
|
||||
>
|
||||
<path d="m330.8,82.15c48.81-14.33,102.1-13.34,150.34,2.79,49.01,16.79,93.21,49.01,122.21,92.14,36.07-5.57,73.13-9.03,109.3-1.96,80.03,13.24,152.43,65.03,191.02,136.34-29.07,16.59-59.76,30.16-89.46,45.59-23.99-32.72-54.75-61.02-91.31-79.07-24.55-12.04-52.56-14.87-79.5-11.88,9.39,49.94,5.71,102.69-14.63,149.58-16.99,40.02-45.23,74.62-79.47,101.17,75.29.56,150.57-.23,225.83.4.63,35.84.5,71.67.17,107.51-98.02.1-196,.23-294.02-.07-1.36,44.63-2.19,90.55-18.61,132.72-14.6,37.33-32.38,73.99-58.3,104.88-19.24,22.86-37.56,46.95-61.12,65.63-19.74-29.73-35.6-61.98-53.85-92.71,11.51-8.89,24.52-15.89,34.74-26.45,37.03-36.8,54.62-88.76,60.65-139.66,3.09-34.47,2.26-69.18,4.31-103.72-52.89-3.25-104.98-22.73-145.53-57.14-54.12-44.63-87.53-113.41-88.49-183.62-1.26-45.36,10.25-90.95,33.21-130.1,30.96-53.72,83.12-94.7,142.51-112.38m-33.08,116.33c-34.31,29.46-55.45,74.13-55.01,119.48-.5,33.65,10.62,67.16,30.43,94.27,27.27,37.66,71.87,62.88,118.49,64.9,1.66-34.51.23-69.61,9.79-103.19,16.62-65.93,59.49-124.3,116.43-161.19l3.55.83c-20.97-22.83-47.91-40.45-78.01-48.38-50.07-14.3-106.71-1.16-145.66,33.28m192.78,252.57c47.68-32.65,75.85-91.25,69.71-148.95-45.56,34.44-73.46,91.65-69.71,148.95Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Fromis9Logo;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
99
frontend/src/components/mobile/schedule/DebutCard.jsx
Normal file
99
frontend/src/components/mobile/schedule/DebutCard.jsx
Normal file
|
|
@ -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 = (
|
||||
<div className="relative overflow-hidden bg-gradient-to-br from-[#7a99c8] via-[#98b0d8] to-[#b8c8e8] rounded-xl shadow-md">
|
||||
{/* 배경 별 장식 */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
{/* 반짝이는 별들 */}
|
||||
<div className="absolute top-2 right-4 text-white/60 text-xs animate-pulse">✦</div>
|
||||
<div className="absolute top-4 right-12 text-white/40 text-[10px] animate-pulse delay-100">✦</div>
|
||||
<div className="absolute bottom-3 right-6 text-white/50 text-sm animate-pulse delay-200">✦</div>
|
||||
<div className="absolute top-1/2 right-1/4 text-white/30 text-xs animate-pulse delay-300">✦</div>
|
||||
<div className="absolute bottom-4 left-4 text-white/40 text-[10px] animate-pulse delay-150">✦</div>
|
||||
{/* 원형 장식 */}
|
||||
<div className="absolute -top-6 -left-6 w-20 h-20 bg-white/10 rounded-full" />
|
||||
<div className="absolute -bottom-8 -right-8 w-24 h-24 bg-white/10 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center p-4 gap-3">
|
||||
{/* 아이콘 영역 */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-14 h-14 rounded-full bg-white/30 backdrop-blur-sm flex items-center justify-center shadow-inner">
|
||||
{isDebut ? (
|
||||
<div className="text-center" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}>
|
||||
<div className="text-white font-black text-[11px] tracking-wider">DEBUT</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}>
|
||||
<div className="text-white font-black text-xl leading-none">{anniversaryYear}</div>
|
||||
<div className="text-white/80 text-[8px] font-bold">YEARS</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="flex-1 text-white min-w-0">
|
||||
<h3
|
||||
className="font-bold text-base tracking-wide truncate flex items-center gap-1.5"
|
||||
style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}
|
||||
>
|
||||
{isDebut ? (
|
||||
<Fromis9Logo size={18} fill="white" className="flex-shrink-0 drop-shadow" />
|
||||
) : (
|
||||
<Clover size={18} className="flex-shrink-0 drop-shadow" strokeWidth={2.5} />
|
||||
)}
|
||||
{schedule.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 날짜 뱃지 */}
|
||||
{showYear && (
|
||||
<div className="flex-shrink-0 bg-white/25 backdrop-blur-sm rounded-lg px-3 py-1.5 text-center">
|
||||
<div className="text-white/80 text-[10px] font-medium">{formatted.year}</div>
|
||||
<div className="text-white/80 text-[10px] font-medium">{formatted.month}월</div>
|
||||
<div className="text-white text-xl font-bold">{formatted.day}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// delay가 있으면 motion 사용
|
||||
if (delay > 0) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay, type: 'spring', stiffness: 300, damping: 30 }}
|
||||
>
|
||||
{CardContent}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return CardContent;
|
||||
});
|
||||
|
||||
export default DebutCard;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
82
frontend/src/components/pc/public/schedule/DebutCard.jsx
Normal file
82
frontend/src/components/pc/public/schedule/DebutCard.jsx
Normal file
|
|
@ -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 (
|
||||
<div className="relative overflow-hidden bg-gradient-to-br from-[#7a99c8] via-[#98b0d8] to-[#b8c8e8] rounded-2xl shadow-lg">
|
||||
{/* 배경 별 장식 */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
{/* 반짝이는 별들 */}
|
||||
<div className="absolute top-3 right-6 text-white/60 text-base animate-pulse">✦</div>
|
||||
<div className="absolute top-6 right-16 text-white/40 text-sm animate-pulse delay-100">✦</div>
|
||||
<div className="absolute bottom-4 right-10 text-white/50 text-lg animate-pulse delay-200">✦</div>
|
||||
<div className="absolute top-1/2 right-1/3 text-white/30 text-sm animate-pulse delay-300">✦</div>
|
||||
<div className="absolute bottom-6 left-6 text-white/40 text-sm animate-pulse delay-150">✦</div>
|
||||
<div className="absolute top-4 left-1/4 text-white/30 text-xs animate-pulse delay-250">✦</div>
|
||||
{/* 원형 장식 */}
|
||||
<div className="absolute -top-8 -left-8 w-28 h-28 bg-white/10 rounded-full" />
|
||||
<div className="absolute -bottom-10 -right-10 w-36 h-36 bg-white/10 rounded-full" />
|
||||
<div className="absolute top-1/2 left-1/2 w-16 h-16 bg-white/5 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center p-4 gap-4">
|
||||
{/* 아이콘 영역 */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-20 h-20 rounded-full bg-white/30 backdrop-blur-sm flex items-center justify-center shadow-inner border-2 border-white/20">
|
||||
{isDebut ? (
|
||||
<div className="text-center" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}>
|
||||
<div className="text-white font-black text-base tracking-wider">DEBUT</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}>
|
||||
<div className="text-white font-black text-3xl leading-none">{anniversaryYear}</div>
|
||||
<div className="text-white/80 text-[10px] font-bold tracking-wider">YEARS</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="flex-1 text-white flex flex-col justify-center">
|
||||
<h3
|
||||
className="font-bold text-2xl tracking-wide flex items-center gap-2"
|
||||
style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}
|
||||
>
|
||||
{isDebut ? (
|
||||
<Fromis9Logo size={28} fill="white" className="flex-shrink-0 drop-shadow" />
|
||||
) : (
|
||||
<Clover size={28} className="flex-shrink-0 drop-shadow" strokeWidth={2.5} />
|
||||
)}
|
||||
{schedule.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 날짜 뱃지 */}
|
||||
<div className="flex-shrink-0 bg-white/25 backdrop-blur-sm rounded-xl px-4 py-2 text-center">
|
||||
{showYear && (
|
||||
<div className="text-white/80 text-xs font-medium">{formatted.year}</div>
|
||||
)}
|
||||
<div className="text-white/80 text-xs font-medium">{formatted.month}월</div>
|
||||
<div className="text-white text-2xl font-bold">{formatted.day}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default DebutCard;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="space-y-3">
|
||||
{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 (
|
||||
<MobileDebutCard
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
delay={index * 0.05}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileScheduleListCard
|
||||
key={schedule.id}
|
||||
|
|
@ -772,6 +820,14 @@ function MobileSchedule() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데뷔/주년 축하 다이얼로그 */}
|
||||
<DebutCelebrationDialog
|
||||
isOpen={showDebutDialog}
|
||||
onClose={() => setShowDebutDialog(false)}
|
||||
isDebut={debutDialogInfo.isDebut}
|
||||
anniversaryYear={debutDialogInfo.anniversaryYear}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className={virtualItem.index < filteredSchedules.length - 1 ? 'pb-4' : ''}>
|
||||
{schedule.is_birthday ? (
|
||||
<BirthdayCard schedule={schedule} showYear onClick={() => handleScheduleClick(schedule)} />
|
||||
) : schedule.is_debut || schedule.is_anniversary ? (
|
||||
<DebutCard schedule={schedule} showYear />
|
||||
) : (
|
||||
<ScheduleCard schedule={schedule} showYear onClick={() => handleScheduleClick(schedule)} />
|
||||
)}
|
||||
|
|
@ -564,6 +599,8 @@ function PCSchedule() {
|
|||
>
|
||||
{schedule.is_birthday ? (
|
||||
<BirthdayCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
|
||||
) : schedule.is_debut || schedule.is_anniversary ? (
|
||||
<DebutCard schedule={schedule} />
|
||||
) : (
|
||||
<ScheduleCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
|
||||
)}
|
||||
|
|
@ -587,6 +624,14 @@ function PCSchedule() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데뷔/주년 축하 다이얼로그 */}
|
||||
<DebutCelebrationDialog
|
||||
isOpen={showDebutDialog}
|
||||
onClose={() => setShowDebutDialog(false)}
|
||||
isDebut={debutDialogInfo.isDebut}
|
||||
anniversaryYear={debutDialogInfo.anniversaryYear}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 공용
|
||||
|
|
|
|||
|
|
@ -48,4 +48,4 @@ export {
|
|||
} from './schedule';
|
||||
|
||||
// 애니메이션 관련
|
||||
export { fireBirthdayConfetti } from './confetti';
|
||||
export { fireBirthdayConfetti, fireDebutConfetti } from './confetti';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue