diff --git a/backend/routes/admin.js b/backend/routes/admin.js
index 322f621..d3303aa 100644
--- a/backend/routes/admin.js
+++ b/backend/routes/admin.js
@@ -1290,8 +1290,8 @@ router.get("/schedules", async (req, res) => {
: "";
const [schedules] = await pool.query(
- `SELECT
- s.id, s.title, s.date, s.time, s.end_date, s.end_time,
+ `SELECT
+ s.id, s.title, s.date, s.time, s.end_date, s.end_time,
s.category_id, s.description, s.source_url, s.source_name,
s.location_name, s.location_address, s.location_detail, s.location_lat, s.location_lng,
s.created_at,
@@ -1320,6 +1320,58 @@ router.get("/schedules", async (req, res) => {
})
);
+ // 년/월 필터가 있고 검색이 아닌 경우 생일 데이터 추가
+ if (year && month && !search) {
+ const [birthdays] = await pool.query(
+ `SELECT id, name, name_en, birth_date, image_url
+ FROM members
+ WHERE is_former = 0 AND MONTH(birth_date) = ?`,
+ [parseInt(month)]
+ );
+
+ const birthdaySchedules = birthdays.map((member) => {
+ const birthDate = new Date(member.birth_date);
+ const birthdayThisYear = new Date(
+ parseInt(year),
+ birthDate.getMonth(),
+ birthDate.getDate()
+ );
+
+ return {
+ id: `birthday-${member.id}`,
+ title: `HAPPY ${member.name_en} DAY`,
+ description: null,
+ date: birthdayThisYear,
+ time: null,
+ end_date: null,
+ end_time: null,
+ category_id: 8,
+ source_url: null,
+ source_name: null,
+ location_name: null,
+ location_address: null,
+ location_detail: null,
+ location_lat: null,
+ location_lng: null,
+ created_at: null,
+ category_name: "생일",
+ category_color: "#f472b6",
+ images: [],
+ members: [{ id: member.id, name: member.name }],
+ member_names: member.name,
+ is_birthday: true,
+ member_image: member.image_url,
+ };
+ });
+
+ // 일정과 생일을 합쳐서 날짜순 정렬
+ const allSchedules = [...schedulesWithDetails, ...birthdaySchedules].sort(
+ (a, b) => new Date(a.date) - new Date(b.date)
+ );
+
+ return res.json(allSchedules);
+ }
+
res.json(schedulesWithDetails);
} catch (error) {
console.error("일정 조회 오류:", error);
diff --git a/backend/routes/schedules.js b/backend/routes/schedules.js
index 7768abc..c33e713 100644
--- a/backend/routes/schedules.js
+++ b/backend/routes/schedules.js
@@ -85,7 +85,7 @@ router.get("/", async (req, res) => {
// 검색어 없으면 DB에서 전체 조회
const [schedules] = await pool.query(
`
- SELECT
+ SELECT
s.id,
s.title,
s.description,
@@ -110,6 +110,49 @@ router.get("/", async (req, res) => {
params
);
+ // 년/월 필터가 있으면 해당 월의 현재 멤버 생일을 가상 일정으로 추가
+ if (year && month) {
+ const [birthdays] = await pool.query(
+ `SELECT id, name, name_en, birth_date, image_url
+ FROM members
+ WHERE is_former = 0 AND MONTH(birth_date) = ?`,
+ [parseInt(month)]
+ );
+
+ const birthdaySchedules = birthdays.map((member) => {
+ const birthDate = new Date(member.birth_date);
+ const birthdayThisYear = new Date(
+ parseInt(year),
+ birthDate.getMonth(),
+ birthDate.getDate()
+ );
+
+ return {
+ id: `birthday-${member.id}`,
+ title: `HAPPY ${member.name_en} DAY`,
+ description: null,
+ date: birthdayThisYear,
+ time: null,
+ category_id: 8,
+ source_url: null,
+ source_name: null,
+ location_name: null,
+ category_name: "생일",
+ category_color: "#f472b6",
+ member_names: member.name,
+ is_birthday: true,
+ member_image: member.image_url,
+ };
+ });
+
+ // 일정과 생일을 합쳐서 날짜순 정렬
+ const allSchedules = [...schedules, ...birthdaySchedules].sort(
+ (a, b) => new Date(a.date) - new Date(b.date)
+ );
+
+ return res.json(allSchedules);
+ }
+
res.json(schedules);
} catch (error) {
console.error("일정 목록 조회 오류:", error);
@@ -164,6 +207,16 @@ router.get("/:id", async (req, res) => {
);
schedule.images = images.map((img) => img.image_url);
+ // 멤버 조회
+ const [members] = await pool.query(
+ `SELECT m.id, m.name FROM members m
+ JOIN schedule_members sm ON m.id = sm.member_id
+ WHERE sm.schedule_id = ?
+ ORDER BY m.id`,
+ [id]
+ );
+ schedule.members = members;
+
// 콘서트 카테고리(id=6)인 경우 같은 제목의 관련 일정들도 조회
if (schedule.category_id === 6) {
const [relatedSchedules] = await pool.query(
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 0301b9c..f67c5d4 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@tanstack/react-query": "^5.90.16",
"@tanstack/react-virtual": "^3.13.18",
+ "canvas-confetti": "^1.9.4",
"dayjs": "^1.11.19",
"framer-motion": "^11.0.8",
"lucide-react": "^0.344.0",
@@ -1464,6 +1465,16 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/canvas-confetti": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz",
+ "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==",
+ "license": "ISC",
+ "funding": {
+ "type": "donate",
+ "url": "https://www.paypal.me/kirilvatev"
+ }
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index c0899d3..f662dcb 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -11,6 +11,7 @@
"dependencies": {
"@tanstack/react-query": "^5.90.16",
"@tanstack/react-virtual": "^3.13.18",
+ "canvas-confetti": "^1.9.4",
"dayjs": "^1.11.19",
"framer-motion": "^11.0.8",
"lucide-react": "^0.344.0",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 22fbf1c..42b5787 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -14,6 +14,7 @@ import PCAlbumGallery from './pages/pc/public/AlbumGallery';
import PCTrackDetail from './pages/pc/public/TrackDetail';
import PCSchedule from './pages/pc/public/Schedule';
import PCScheduleDetail from './pages/pc/public/ScheduleDetail';
+import PCBirthday from './pages/pc/public/Birthday';
import PCNotFound from './pages/pc/public/NotFound';
// 모바일 페이지
@@ -86,6 +87,7 @@ function App() {
} />
} />
} />
+ } />
} />
diff --git a/frontend/src/pages/mobile/public/Schedule.jsx b/frontend/src/pages/mobile/public/Schedule.jsx
index 40c18d3..3b606ce 100644
--- a/frontend/src/pages/mobile/public/Schedule.jsx
+++ b/frontend/src/pages/mobile/public/Schedule.jsx
@@ -1,11 +1,70 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
+import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Clock, Tag, Link2, ChevronLeft, ChevronRight, ChevronDown, Search, X, Calendar } from 'lucide-react';
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
import { useVirtualizer } from '@tanstack/react-virtual';
+import confetti from 'canvas-confetti';
+import { getTodayKST } from '../../../utils/date';
import { getSchedules, getCategories, searchSchedules } from '../../../api/public/schedules';
+// 폭죽 애니메이션 함수
+const fireBirthdayConfetti = () => {
+ const duration = 3000;
+ const animationEnd = Date.now() + duration;
+ const colors = ['#ff69b4', '#ff1493', '#da70d6', '#ba55d3', '#9370db', '#8a2be2', '#ffd700', '#ff6347'];
+
+ 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: 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: 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: colors,
+ shapes: ['circle', 'square'],
+ startVelocity: 45,
+ });
+};
+
// HTML 엔티티 디코딩 함수
const decodeHtmlEntities = (text) => {
if (!text) return '';
@@ -14,8 +73,54 @@ const decodeHtmlEntities = (text) => {
return textarea.value;
};
+// 모바일 생일 카드 컴포넌트
+function MobileBirthdayCard({ schedule, onClick, delay = 0 }) {
+ return (
+
+
+ {/* 배경 장식 */}
+
+
+
+ {/* 멤버 사진 */}
+ {schedule.member_image && (
+
+
+

+
+
+ )}
+
+ {/* 내용 */}
+
+ 🎂
+
+ {decodeHtmlEntities(schedule.title)}
+
+
+
+
+
+ );
+}
+
// 모바일 일정 페이지
function MobileSchedule() {
+ const navigate = useNavigate();
const [selectedDate, setSelectedDate] = useState(new Date());
const [isSearchMode, setIsSearchMode] = useState(false);
const [searchInput, setSearchInput] = useState(''); // 입력값
@@ -161,6 +266,32 @@ function MobileSchedule() {
queryFn: () => getSchedules(viewYear, viewMonth),
});
+ // 생일 폭죽 효과 (하루에 한 번만)
+ useEffect(() => {
+ if (loading || schedules.length === 0) return;
+
+ const today = getTodayKST();
+ const confettiKey = `birthday-confetti-${today}`;
+
+ // 이미 오늘 폭죽을 봤으면 스킵
+ if (localStorage.getItem(confettiKey)) return;
+
+ const hasBirthdayToday = schedules.some(s => {
+ if (!s.is_birthday) return false;
+ const scheduleDate = s.date ? s.date.split('T')[0] : '';
+ return scheduleDate === today;
+ });
+
+ if (hasBirthdayToday) {
+ // 약간의 딜레이 후 폭죽 발사 (페이지 렌더링 완료 후)
+ const timer = setTimeout(() => {
+ fireBirthdayConfetti();
+ localStorage.setItem(confettiKey, 'true');
+ }, 500);
+ return () => clearTimeout(timer);
+ }
+ }, [schedules, loading]);
+
// 월 변경
const changeMonth = (delta) => {
const newDate = new Date(selectedDate);
@@ -723,15 +854,34 @@ function MobileSchedule() {
) : (
// 선택된 날짜의 일정
- {selectedDateSchedules.map((schedule, index) => (
-
- ))}
+ {selectedDateSchedules.map((schedule, index) => {
+ const isBirthday = schedule.is_birthday || String(schedule.id).startsWith('birthday-');
+
+ if (isBirthday) {
+ return (
+ {
+ const scheduleYear = new Date(schedule.date).getFullYear();
+ const memberName = schedule.member_names;
+ navigate(`/birthday/${encodeURIComponent(memberName)}/${scheduleYear}`);
+ }}
+ />
+ );
+ }
+
+ return (
+
+ );
+ })}
)}
diff --git a/frontend/src/pages/pc/admin/AdminSchedule.jsx b/frontend/src/pages/pc/admin/AdminSchedule.jsx
index 50d215d..91082fc 100644
--- a/frontend/src/pages/pc/admin/AdminSchedule.jsx
+++ b/frontend/src/pages/pc/admin/AdminSchedule.jsx
@@ -28,16 +28,17 @@ const decodeHtmlEntities = (text) => {
};
// 일정 아이템 컴포넌트 - React.memo로 불필요한 리렌더링 방지
-const ScheduleItem = memo(function ScheduleItem({
- schedule,
- index,
- selectedDate,
- categories,
- getColorStyle,
- navigate,
- openDeleteDialog
+const ScheduleItem = memo(function ScheduleItem({
+ schedule,
+ index,
+ selectedDate,
+ categories,
+ getColorStyle,
+ navigate,
+ openDeleteDialog
}) {
const scheduleDate = new Date(schedule.date);
+ const isBirthday = schedule.is_birthday || String(schedule.id).startsWith('birthday-');
const categoryColor = getColorStyle(categories.find(c => c.id === schedule.category_id)?.color)?.style?.backgroundColor || '#6b7280';
const categoryName = categories.find(c => c.id === schedule.category_id)?.name || '미분류';
const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || '';
@@ -61,7 +62,7 @@ const ScheduleItem = memo(function ScheduleItem({
-
@@ -103,31 +104,34 @@ const ScheduleItem = memo(function ScheduleItem({
)}
-
+ )}
);
@@ -1249,7 +1253,7 @@ function AdminSchedule() {
- c.id === schedule.category_id)?.color)?.style?.backgroundColor || '#6b7280' }}
/>
@@ -1293,7 +1297,7 @@ function AdminSchedule() {
{schedule.source_url && (
-
)}
-
-