Compare commits
12 commits
0c6d250a9d
...
83c955f8a9
| Author | SHA1 | Date | |
|---|---|---|---|
| 83c955f8a9 | |||
| dc216a0f98 | |||
| f86e7d1b33 | |||
| f97c925fba | |||
| e1ee0b47a0 | |||
| f8c73c5a0a | |||
| 2fec6c552d | |||
| ea9922de00 | |||
| 5b9d93b37f | |||
| a7bc2e9800 | |||
| eae56df146 | |||
| 5415893f9d |
39 changed files with 894 additions and 317 deletions
|
|
@ -3,7 +3,7 @@ export default [
|
|||
id: 'meilisearch-sync',
|
||||
type: 'meilisearch',
|
||||
name: 'Meilisearch 동기화',
|
||||
cron: '0 4 * * *', // 4시부터 5분간 버전 체크, 변경 시 동기화
|
||||
cron: '0 12 * * *', // 매일 12시 전체 동기화
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
// 필수 환경변수 검증
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import cron from 'node-cron';
|
||||
import bots from '../config/bots.js';
|
||||
import { syncWithRetry, getVersion } from '../services/meilisearch/index.js';
|
||||
import { syncAllSchedules } from '../services/meilisearch/index.js';
|
||||
import { nowKST } from '../utils/date.js';
|
||||
|
||||
const REDIS_PREFIX = 'bot:status:';
|
||||
|
|
@ -48,121 +48,13 @@ async function schedulerPlugin(fastify, opts) {
|
|||
return fastify.xBot.syncNewTweets;
|
||||
} else if (bot.type === 'meilisearch') {
|
||||
return async () => {
|
||||
const count = await syncWithRetry(fastify.meilisearch, fastify.db);
|
||||
const count = await syncAllSchedules(fastify.meilisearch, fastify.db);
|
||||
return { addedCount: count, total: count };
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Meilisearch 버전 체크 및 동기화 (업데이트 감지용)
|
||||
*/
|
||||
async function startMeilisearchVersionCheck(botId, bot) {
|
||||
const REDIS_VERSION_KEY = 'meilisearch:version';
|
||||
const CHECK_INTERVAL = 60 * 1000; // 1분
|
||||
const CHECK_DURATION = 5 * 60 * 1000; // 5분간 체크
|
||||
|
||||
// 체크 시작 cron (매일 4시 KST)
|
||||
const task = cron.schedule(bot.cron, async () => {
|
||||
fastify.log.info(`[${botId}] 버전 체크 시작 (5분간 1분 간격)`);
|
||||
await updateStatus(botId, { status: 'running' });
|
||||
|
||||
const startTime = Date.now();
|
||||
let synced = false;
|
||||
let checkCount = 0;
|
||||
|
||||
// 초기 버전 저장
|
||||
const initialVersion = await getVersion(fastify.meilisearch);
|
||||
if (!initialVersion) {
|
||||
fastify.log.error(`[${botId}] Meilisearch 연결 실패`);
|
||||
await updateStatus(botId, { status: 'error', errorMessage: 'Meilisearch 연결 실패' });
|
||||
return;
|
||||
}
|
||||
|
||||
const savedVersion = await fastify.redis.get(REDIS_VERSION_KEY);
|
||||
fastify.log.info(`[${botId}] 현재 버전: ${initialVersion}, 저장된 버전: ${savedVersion || '없음'}`);
|
||||
|
||||
// 버전이 이미 다르면 즉시 동기화
|
||||
if (savedVersion && savedVersion !== initialVersion) {
|
||||
fastify.log.info(`[${botId}] 버전 변경 감지! ${savedVersion} → ${initialVersion}`);
|
||||
await performSync(botId, initialVersion, REDIS_VERSION_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5분간 1분 간격으로 체크
|
||||
const intervalId = setInterval(async () => {
|
||||
checkCount++;
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
if (synced || elapsed >= CHECK_DURATION) {
|
||||
clearInterval(intervalId);
|
||||
if (!synced) {
|
||||
fastify.log.info(`[${botId}] 버전 변경 없음, 체크 종료`);
|
||||
await updateStatus(botId, {
|
||||
status: 'running',
|
||||
lastCheckAt: nowKST(),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const currentVersion = await getVersion(fastify.meilisearch);
|
||||
fastify.log.info(`[${botId}] 체크 #${checkCount}: 버전 ${currentVersion}`);
|
||||
|
||||
if (currentVersion && currentVersion !== initialVersion) {
|
||||
synced = true;
|
||||
clearInterval(intervalId);
|
||||
fastify.log.info(`[${botId}] 버전 변경 감지! ${initialVersion} → ${currentVersion}`);
|
||||
await performSync(botId, currentVersion, REDIS_VERSION_KEY);
|
||||
}
|
||||
}, CHECK_INTERVAL);
|
||||
}, { timezone: TIMEZONE });
|
||||
|
||||
tasks.set(botId, task);
|
||||
await updateStatus(botId, { status: 'running' });
|
||||
fastify.log.info(`[${botId}] 버전 체크 스케줄 시작 (cron: ${bot.cron})`);
|
||||
|
||||
// 초기 버전 저장 (최초 실행 시)
|
||||
const currentVersion = await getVersion(fastify.meilisearch);
|
||||
if (currentVersion) {
|
||||
const savedVersion = await fastify.redis.get(REDIS_VERSION_KEY);
|
||||
if (!savedVersion) {
|
||||
await fastify.redis.set(REDIS_VERSION_KEY, currentVersion);
|
||||
fastify.log.info(`[${botId}] 초기 버전 저장: ${currentVersion}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 동기화 실행 및 상태 업데이트
|
||||
*/
|
||||
async function performSync(botId, newVersion, versionKey) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const count = await syncWithRetry(fastify.meilisearch, fastify.db);
|
||||
const duration = Date.now() - startTime;
|
||||
await fastify.redis.set(versionKey, newVersion);
|
||||
await updateStatus(botId, {
|
||||
status: 'running',
|
||||
lastCheckAt: nowKST(),
|
||||
lastAddedCount: count,
|
||||
lastSyncDuration: duration,
|
||||
errorMessage: null,
|
||||
});
|
||||
fastify.log.info(`[${botId}] 동기화 완료: ${count}개, ${duration}ms, 새 버전: ${newVersion}`);
|
||||
} catch (err) {
|
||||
const duration = Date.now() - startTime;
|
||||
await updateStatus(botId, {
|
||||
status: 'error',
|
||||
lastCheckAt: nowKST(),
|
||||
lastSyncDuration: duration,
|
||||
errorMessage: err.message,
|
||||
});
|
||||
fastify.log.error(`[${botId}] 동기화 오류: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 동기화 결과 처리 (중복 코드 제거)
|
||||
*/
|
||||
|
|
@ -199,12 +91,6 @@ async function schedulerPlugin(fastify, opts) {
|
|||
tasks.delete(botId);
|
||||
}
|
||||
|
||||
// Meilisearch는 버전 체크 방식 사용
|
||||
if (bot.type === 'meilisearch') {
|
||||
await startMeilisearchVersionCheck(botId, bot);
|
||||
return;
|
||||
}
|
||||
|
||||
const syncFn = getSyncFunction(bot);
|
||||
if (!syncFn) {
|
||||
throw new Error(`지원하지 않는 봇 타입: ${bot.type}`);
|
||||
|
|
|
|||
|
|
@ -62,14 +62,17 @@ export default async function botsRoutes(fastify) {
|
|||
for (const bot of bots) {
|
||||
const status = await scheduler.getStatus(bot.id);
|
||||
|
||||
// cron 표현식에서 간격 추출 (분 단위)
|
||||
// cron 표현식에서 간격 추출 (분 단위, 일일 스케줄은 1440분)
|
||||
let checkInterval = 2; // 기본값
|
||||
const cronMatch = bot.cron.match(/^\*\/(\d+)/);
|
||||
if (cronMatch) {
|
||||
checkInterval = parseInt(cronMatch[1]);
|
||||
} else if (/^0 \d+ \* \* \*$/.test(bot.cron)) {
|
||||
// 매일 특정 시간 (예: 0 12 * * *)
|
||||
checkInterval = 1440; // 24시간 = 1440분
|
||||
}
|
||||
|
||||
const botData = {
|
||||
result.push({
|
||||
id: bot.id,
|
||||
name: bot.name || bot.channelName || bot.username || bot.id,
|
||||
type: bot.type,
|
||||
|
|
@ -81,15 +84,7 @@ export default async function botsRoutes(fastify) {
|
|||
check_interval: checkInterval,
|
||||
error_message: status.errorMessage,
|
||||
enabled: bot.enabled,
|
||||
};
|
||||
|
||||
// Meilisearch 봇인 경우 버전 정보 추가
|
||||
if (bot.type === 'meilisearch') {
|
||||
const version = await redis.get('meilisearch:version');
|
||||
botData.version = version || '-';
|
||||
}
|
||||
|
||||
result.push(botData);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -181,12 +181,13 @@ async function insertTracks(connection, albumId, tracks) {
|
|||
track.composer || null,
|
||||
track.arranger || null,
|
||||
track.lyrics || null,
|
||||
track.music_video_url || null,
|
||||
track.video_url || null,
|
||||
track.video_type || null,
|
||||
]);
|
||||
|
||||
await connection.query(
|
||||
`INSERT INTO album_tracks
|
||||
(album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, music_video_url)
|
||||
(album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, video_url, video_type)
|
||||
VALUES ?`,
|
||||
[values]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ function formatScheduleResponse(hit) {
|
|||
}
|
||||
|
||||
/**
|
||||
* 일정 추가/업데이트
|
||||
* 일정 추가/업데이트 (데이터 직접 전달)
|
||||
*/
|
||||
export async function addOrUpdateSchedule(meilisearch, schedule) {
|
||||
try {
|
||||
|
|
@ -187,7 +187,6 @@ export async function addOrUpdateSchedule(meilisearch, schedule) {
|
|||
const document = {
|
||||
id: schedule.id,
|
||||
title: schedule.title,
|
||||
description: schedule.description || '',
|
||||
date: schedule.date,
|
||||
time: schedule.time || '',
|
||||
category_id: schedule.category_id,
|
||||
|
|
@ -204,6 +203,59 @@ export async function addOrUpdateSchedule(meilisearch, schedule) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 ID로 DB에서 조회 후 Meilisearch에 동기화
|
||||
*/
|
||||
export async function syncScheduleById(meilisearch, db, scheduleId) {
|
||||
try {
|
||||
const [rows] = await db.query(`
|
||||
SELECT
|
||||
s.id,
|
||||
s.title,
|
||||
s.date,
|
||||
s.time,
|
||||
s.category_id,
|
||||
c.name as category_name,
|
||||
c.color as category_color,
|
||||
sy.channel_name as source_name,
|
||||
GROUP_CONCAT(DISTINCT m.name ORDER BY m.id SEPARATOR ',') as member_names
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
|
||||
LEFT JOIN members m ON sm.member_id = m.id AND m.is_former = 0
|
||||
WHERE s.id = ?
|
||||
GROUP BY s.id
|
||||
`, [scheduleId]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
logger.warn(`일정을 찾을 수 없음: ${scheduleId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const s = rows[0];
|
||||
const document = {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
date: s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date,
|
||||
time: s.time || '',
|
||||
category_id: s.category_id,
|
||||
category_name: s.category_name || '',
|
||||
category_color: s.category_color || '',
|
||||
source_name: s.source_name || '',
|
||||
member_names: s.member_names || '',
|
||||
};
|
||||
|
||||
const index = meilisearch.index(INDEX_NAME);
|
||||
await index.addDocuments([document]);
|
||||
logger.info(`일정 동기화: ${scheduleId}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error(`일정 동기화 오류 (${scheduleId}): ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 삭제
|
||||
*/
|
||||
|
|
@ -233,7 +285,6 @@ export async function syncAllSchedules(meilisearch, db) {
|
|||
SELECT
|
||||
s.id,
|
||||
s.title,
|
||||
s.description,
|
||||
s.date,
|
||||
s.time,
|
||||
s.category_id,
|
||||
|
|
@ -255,7 +306,6 @@ export async function syncAllSchedules(meilisearch, db) {
|
|||
const documents = schedules.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
description: s.description || '',
|
||||
date: s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date,
|
||||
time: s.time || '',
|
||||
category_id: s.category_id,
|
||||
|
|
@ -310,7 +360,7 @@ async function recreateIndex(meilisearch) {
|
|||
|
||||
// 설정 복원
|
||||
await index.updateSearchableAttributes([
|
||||
'title', 'member_names', 'description', 'source_name', 'category_name',
|
||||
'title', 'member_names', 'source_name', 'category_name',
|
||||
]);
|
||||
await index.updateFilterableAttributes(['category_id', 'date']);
|
||||
await index.updateSortableAttributes(['date', 'time']);
|
||||
|
|
|
|||
|
|
@ -62,8 +62,9 @@ export async function invalidateMemberCache(redis) {
|
|||
|
||||
/**
|
||||
* 이름으로 멤버 조회 (별명 포함)
|
||||
* 한글명(name) 또는 영문명(name_en) 모두 검색 가능
|
||||
* @param {object} db - 데이터베이스 연결
|
||||
* @param {string} name - 멤버 이름
|
||||
* @param {string} name - 멤버 이름 (한글 또는 영문)
|
||||
* @returns {object|null} 멤버 정보 또는 null
|
||||
*/
|
||||
export async function getMemberByName(db, name) {
|
||||
|
|
@ -75,8 +76,8 @@ export async function getMemberByName(db, name) {
|
|||
i.thumb_url as image_thumb
|
||||
FROM members m
|
||||
LEFT JOIN images i ON m.image_id = i.id
|
||||
WHERE m.name = ?
|
||||
`, [name]);
|
||||
WHERE m.name = ? OR LOWER(m.name_en) = LOWER(?)
|
||||
`, [name, name]);
|
||||
|
||||
if (members.length === 0) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -324,15 +334,11 @@ export async function getMonthlySchedules(db, year, month) {
|
|||
const birthdayDate = new Date(year, birthDate.getMonth(), birthDate.getDate());
|
||||
|
||||
schedules.push({
|
||||
id: `birthday-${member.id}`,
|
||||
id: `birthday-${year}-${member.name_en.toLowerCase()}`,
|
||||
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,8 +346,70 @@ export async function getMonthlySchedules(db, year, month) {
|
|||
});
|
||||
}
|
||||
|
||||
// 날짜순 정렬
|
||||
schedules.sort((a, b) => a.date.localeCompare(b.date));
|
||||
// 데뷔/주년 추가 (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-${year}`,
|
||||
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-${year}`,
|
||||
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) => {
|
||||
// 날짜 비교
|
||||
const dateCompare = a.date.localeCompare(b.date);
|
||||
if (dateCompare !== 0) return dateCompare;
|
||||
|
||||
// 같은 날짜면 특수 일정(생일, 기념일)을 먼저
|
||||
const aSpecial = a.is_birthday || a.is_debut || a.is_anniversary;
|
||||
const bSpecial = b.is_birthday || b.is_debut || b.is_anniversary;
|
||||
if (aSpecial && !bSpecial) return -1;
|
||||
if (!aSpecial && bSpecial) return 1;
|
||||
|
||||
// 둘 다 특수 일정이면 기념일 > 생일 순서
|
||||
if (aSpecial && bSpecial) {
|
||||
const aDebut = a.is_debut || a.is_anniversary;
|
||||
const bDebut = b.is_debut || b.is_anniversary;
|
||||
if (aDebut && !bDebut) return -1;
|
||||
if (!aDebut && bDebut) return 1;
|
||||
}
|
||||
|
||||
// 시간순 정렬
|
||||
if (a.time && b.time) return a.time.localeCompare(b.time);
|
||||
if (a.time) return -1;
|
||||
if (b.time) return 1;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return { schedules };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { fetchVideoInfo } from '../youtube/api.js';
|
|||
import { formatDate, formatTime, nowKST } from '../../utils/date.js';
|
||||
import bots from '../../config/bots.js';
|
||||
import { withTransaction } from '../../utils/transaction.js';
|
||||
import { syncScheduleById } from '../meilisearch/index.js';
|
||||
|
||||
const X_CATEGORY_ID = 3;
|
||||
const YOUTUBE_CATEGORY_ID = 2;
|
||||
|
|
@ -141,8 +142,12 @@ async function xBotPlugin(fastify, opts) {
|
|||
// 관리 중인 채널이면 스킵
|
||||
if (managedChannels.includes(video.channelId)) continue;
|
||||
|
||||
const saved = await saveYoutubeFromTweet(video);
|
||||
if (saved) addedCount++;
|
||||
const scheduleId = await saveYoutubeFromTweet(video);
|
||||
if (scheduleId) {
|
||||
// Meilisearch 동기화
|
||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
||||
addedCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
fastify.log.error(`YouTube 영상 처리 오류 (${videoId}): ${err.message}`);
|
||||
}
|
||||
|
|
@ -166,6 +171,8 @@ async function xBotPlugin(fastify, opts) {
|
|||
for (const tweet of tweets) {
|
||||
const scheduleId = await saveTweet(tweet);
|
||||
if (scheduleId) {
|
||||
// Meilisearch 동기화
|
||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
||||
addedCount++;
|
||||
// YouTube 링크 처리
|
||||
ytAddedCount += await processYoutubeLinks(tweet);
|
||||
|
|
@ -187,6 +194,8 @@ async function xBotPlugin(fastify, opts) {
|
|||
for (const tweet of tweets) {
|
||||
const scheduleId = await saveTweet(tweet);
|
||||
if (scheduleId) {
|
||||
// Meilisearch 동기화
|
||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
||||
addedCount++;
|
||||
ytAddedCount += await processYoutubeLinks(tweet);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.j
|
|||
import bots from '../../config/bots.js';
|
||||
import { CATEGORY_IDS } from '../../config/index.js';
|
||||
import { withTransaction } from '../../utils/transaction.js';
|
||||
import { syncScheduleById } from '../meilisearch/index.js';
|
||||
|
||||
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE;
|
||||
const PLAYLIST_CACHE_PREFIX = 'yt_uploads:';
|
||||
|
|
@ -126,6 +127,8 @@ async function youtubeBotPlugin(fastify, opts) {
|
|||
for (const video of videos) {
|
||||
const scheduleId = await saveVideo(video, bot);
|
||||
if (scheduleId) {
|
||||
// Meilisearch 동기화
|
||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
|
|
@ -144,6 +147,8 @@ async function youtubeBotPlugin(fastify, opts) {
|
|||
for (const video of videos) {
|
||||
const scheduleId = await saveVideo(video, bot);
|
||||
if (scheduleId) {
|
||||
// Meilisearch 동기화
|
||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ services:
|
|||
meilisearch:
|
||||
image: getmeili/meilisearch:latest
|
||||
container_name: fromis9-meilisearch
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
environment:
|
||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
||||
volumes:
|
||||
|
|
|
|||
14
docs/api.md
14
docs/api.md
|
|
@ -19,9 +19,16 @@ Base URL: `/api`
|
|||
### GET /members
|
||||
멤버 목록 조회
|
||||
|
||||
### GET /members/:id
|
||||
### GET /members/:name
|
||||
멤버 상세 조회
|
||||
|
||||
**Parameters:**
|
||||
- `name` - 멤버 이름 (한글 또는 영문, 대소문자 무관)
|
||||
|
||||
**예시:**
|
||||
- `/members/박지원` - 한글명으로 조회
|
||||
- `/members/jiwon` - 영문명으로 조회
|
||||
|
||||
---
|
||||
|
||||
## 앨범
|
||||
|
|
@ -66,6 +73,11 @@ Base URL: `/api`
|
|||
]
|
||||
}
|
||||
```
|
||||
|
||||
**특수 일정 ID 형식:**
|
||||
- 생일: `birthday-{year}-{nameEn}` (예: `birthday-2026-jiwon`)
|
||||
- 데뷔: `debut-{year}` (예: `debut-2018`)
|
||||
- 주년: `anniversary-{year}` (예: `anniversary-2026`)
|
||||
※ `time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
@ -321,6 +325,7 @@ fromis_9/
|
|||
|
||||
### 검색 인덱스 (Meilisearch)
|
||||
- `schedules` - 일정 검색용 인덱스
|
||||
- 검색 필드: title, member_names, description, source_name, category_name
|
||||
- 검색 필드: title, member_names, source_name, category_name
|
||||
- 필터: category_id, date
|
||||
- 정렬: date, time
|
||||
- 동기화: 봇/수동 일정 추가/수정/삭제 시 실시간 동기화
|
||||
|
|
|
|||
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;
|
||||
|
|
@ -16,6 +16,12 @@ function ScrollToTop() {
|
|||
if (mobileContent) {
|
||||
mobileContent.scrollTop = 0;
|
||||
}
|
||||
|
||||
// PC 레이아웃 스크롤 컨테이너 초기화
|
||||
const main = document.querySelector('main');
|
||||
if (main) {
|
||||
main.scrollTop = 0;
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@
|
|||
import { memo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Trash2, Star, ChevronDown } from 'lucide-react';
|
||||
import { CustomSelect } from '../common';
|
||||
|
||||
const VIDEO_TYPE_OPTIONS = [
|
||||
{ value: '', label: '선택' },
|
||||
{ value: 'music_video', label: '뮤직비디오' },
|
||||
{ value: 'special', label: '스페셜 영상' },
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
|
|
@ -120,16 +127,26 @@ const TrackItem = memo(function TrackItem({ track, index, onUpdate, onRemove })
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* MV URL */}
|
||||
{/* 비디오 URL */}
|
||||
<div className="mt-3">
|
||||
<label className="block text-xs text-gray-500 mb-1">뮤직비디오 URL</label>
|
||||
<label className="block text-xs text-gray-500 mb-1">영상 URL</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={track.music_video_url || ''}
|
||||
onChange={(e) => onUpdate(index, 'music_video_url', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
value={track.video_url || ''}
|
||||
onChange={(e) => onUpdate(index, 'video_url', e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="https://youtube.com/watch?v=..."
|
||||
/>
|
||||
<CustomSelect
|
||||
value={track.video_type || ''}
|
||||
onChange={(value) => onUpdate(index, 'video_type', value)}
|
||||
options={VIDEO_TYPE_OPTIONS}
|
||||
placeholder="선택"
|
||||
size="sm"
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 가사 */}
|
||||
|
|
|
|||
|
|
@ -142,31 +142,8 @@ const BotCard = memo(function BotCard({
|
|||
|
||||
{/* 통계 정보 */}
|
||||
<div className="grid grid-cols-3 divide-x divide-gray-100 bg-gray-50/50">
|
||||
{bot.type === 'meilisearch' ? (
|
||||
<>
|
||||
<div className="p-3 text-center">
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{bot.last_added_count?.toLocaleString() || '-'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">동기화 수</div>
|
||||
</div>
|
||||
<div className="p-3 text-center">
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{bot.last_sync_duration != null
|
||||
? `${(bot.last_sync_duration / 1000).toFixed(1)}초`
|
||||
: '-'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">소요 시간</div>
|
||||
</div>
|
||||
<div className="p-3 text-center">
|
||||
<div className="text-lg font-bold text-gray-900">{bot.version || '-'}</div>
|
||||
<div className="text-xs text-gray-400">버전</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-3 text-center">
|
||||
<div className="text-lg font-bold text-gray-900">{bot.schedules_added}</div>
|
||||
<div className="text-lg font-bold text-gray-900">{bot.schedules_added || 0}</div>
|
||||
<div className="text-xs text-gray-400">총 추가</div>
|
||||
</div>
|
||||
<div className="p-3 text-center">
|
||||
|
|
@ -181,8 +158,6 @@ const BotCard = memo(function BotCard({
|
|||
<div className="text-lg font-bold text-gray-900">{formatInterval(bot.check_interval)}</div>
|
||||
<div className="text-xs text-gray-400">업데이트 간격</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오류 메시지 */}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ import { ChevronDown } from 'lucide-react';
|
|||
* @param {Object} props
|
||||
* @param {string} props.value - 선택된 값
|
||||
* @param {Function} props.onChange - 값 변경 핸들러
|
||||
* @param {string[]} props.options - 옵션 목록
|
||||
* @param {Array<string|{value: string, label: string}>} props.options - 옵션 목록 (문자열 또는 {value, label} 객체)
|
||||
* @param {string} props.placeholder - 플레이스홀더
|
||||
* @param {string} props.className - 추가 클래스명
|
||||
* @param {string} props.size - 크기 ('sm' | 'md')
|
||||
*/
|
||||
function CustomSelect({ value, onChange, options, placeholder }) {
|
||||
function CustomSelect({ value, onChange, options, placeholder, className = '', size = 'md' }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
|
|
@ -26,17 +28,29 @@ function CustomSelect({ value, onChange, options, placeholder }) {
|
|||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// 옵션을 {value, label} 형태로 정규화
|
||||
const normalizedOptions = options.map((opt) =>
|
||||
typeof opt === 'string' ? { value: opt, label: opt } : opt
|
||||
);
|
||||
|
||||
// 현재 선택된 옵션의 라벨 찾기
|
||||
const selectedLabel = normalizedOptions.find((opt) => opt.value === value)?.label;
|
||||
|
||||
const sizeClasses = size === 'sm' ? 'px-3 py-2 text-sm' : 'px-4 py-2.5';
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<div ref={ref} className={`relative ${className}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
className={`w-full ${sizeClasses} border border-gray-200 rounded-lg bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent`}
|
||||
>
|
||||
<span className={value ? 'text-gray-900' : 'text-gray-400'}>{value || placeholder}</span>
|
||||
<span className={selectedLabel ? 'text-gray-900' : 'text-gray-400'}>
|
||||
{selectedLabel || placeholder}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={18}
|
||||
className={`text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
size={size === 'sm' ? 16 : 18}
|
||||
className={`text-gray-400 transition-transform flex-shrink-0 ml-2 ${isOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
|
|
@ -49,19 +63,19 @@ function CustomSelect({ value, onChange, options, placeholder }) {
|
|||
transition={{ duration: 0.15 }}
|
||||
className="absolute z-50 w-full mt-2 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden"
|
||||
>
|
||||
{options.map((option) => (
|
||||
{normalizedOptions.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option);
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full px-4 py-2.5 text-left hover:bg-gray-50 transition-colors ${
|
||||
value === option ? 'bg-primary/10 text-primary font-medium' : 'text-gray-700'
|
||||
className={`w-full ${sizeClasses} text-left hover:bg-gray-50 transition-colors ${
|
||||
value === option.value ? 'bg-primary/10 text-primary font-medium' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
|
|
|
|||
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';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useState, useMemo, useEffect, useLayoutEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
|
|
@ -37,7 +37,13 @@ function MobileTrackDetail() {
|
|||
enabled: !!albumName && !!trackTitle,
|
||||
});
|
||||
|
||||
const youtubeVideoId = useMemo(() => getYoutubeVideoId(track?.music_video_url), [track?.music_video_url]);
|
||||
const youtubeVideoId = useMemo(() => getYoutubeVideoId(track?.video_url), [track?.video_url]);
|
||||
const videoLabel = track?.video_type === 'special' ? '스페셜 영상' : '뮤직비디오';
|
||||
|
||||
// 트랙 변경 시 스크롤 맨 위로
|
||||
useLayoutEffect(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
}, [trackTitle]);
|
||||
|
||||
// 가사 펼침 상태
|
||||
const [showFullLyrics, setShowFullLyrics] = useState(false);
|
||||
|
|
@ -92,7 +98,7 @@ function MobileTrackDetail() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="pb-4">
|
||||
<div key={trackTitle} className="pb-4">
|
||||
{/* 트랙 정보 헤더 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
|
|
@ -140,12 +146,12 @@ function MobileTrackDetail() {
|
|||
>
|
||||
<h2 className="text-base font-bold mb-3 flex items-center gap-2">
|
||||
<div className="w-1 h-4 bg-red-500 rounded-full" />
|
||||
뮤직비디오
|
||||
{videoLabel}
|
||||
</h2>
|
||||
<div className="relative w-full aspect-video rounded-xl overflow-hidden shadow-md bg-black">
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${youtubeVideoId}`}
|
||||
title={`${track.title} 뮤직비디오`}
|
||||
title={`${track.title} ${videoLabel}`}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen; web-share"
|
||||
allowFullScreen
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { Calendar, X, Instagram } from 'lucide-react';
|
||||
import { Cake, X, Instagram } from 'lucide-react';
|
||||
import { useMembers } from '@/hooks';
|
||||
|
||||
/**
|
||||
|
|
@ -173,7 +173,7 @@ function MobileMembers() {
|
|||
<h3 className="text-lg font-bold">{selectedMember.name}</h3>
|
||||
{selectedMember.birth_date && (
|
||||
<div className="flex items-center justify-center gap-1 text-gray-500 text-sm mt-1.5">
|
||||
<Calendar size={14} />
|
||||
<Cake size={14} />
|
||||
<span>
|
||||
{selectedMember.birth_date?.slice(0, 10).replaceAll('-', '.')}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { Instagram, Calendar, ChevronRight } from 'lucide-react';
|
||||
import { Instagram, Cake, ChevronRight } from 'lucide-react';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import 'swiper/css';
|
||||
import { useMembers } from '@/hooks';
|
||||
|
|
@ -152,7 +152,7 @@ function CurrentDesign() {
|
|||
<h2 className="text-[32px] font-bold text-white drop-shadow-lg">{member.name}</h2>
|
||||
{member.birth_date && (
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-white/80">
|
||||
<Calendar size={16} className="text-white/70" />
|
||||
<Cake size={16} className="text-white/70" />
|
||||
<span className="text-sm">{member.birth_date?.slice(0, 10).replaceAll('-', '.')}</span>
|
||||
{age && <span className="ml-2 px-2 py-0.5 bg-white/20 rounded-lg text-xs text-white font-medium">{age}세</span>}
|
||||
</div>
|
||||
|
|
@ -227,7 +227,7 @@ function CardDesign() {
|
|||
<h2 className="text-2xl font-bold text-gray-900">{member.name}</h2>
|
||||
{member.birth_date && (
|
||||
<div className="flex items-center gap-2 mt-2 text-gray-500">
|
||||
<Calendar size={16} />
|
||||
<Cake size={16} />
|
||||
<span className="text-sm">{member.birth_date?.slice(0, 10).replaceAll('-', '.')}</span>
|
||||
{age && (
|
||||
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-md text-xs font-medium">
|
||||
|
|
@ -432,7 +432,7 @@ function SheetDesign() {
|
|||
<h2 className="text-3xl font-bold text-gray-900">{currentMember.name}</h2>
|
||||
{currentMember.birth_date && (
|
||||
<div className="flex items-center gap-2 mt-2 text-gray-500">
|
||||
<Calendar size={16} />
|
||||
<Cake size={16} />
|
||||
<span>{currentMember.birth_date?.slice(0, 10).replaceAll('-', '.')}</span>
|
||||
{age && (
|
||||
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-md text-sm font-medium">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useParams, Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
|
@ -6,25 +6,23 @@ import { fetchApi } from '@/api';
|
|||
|
||||
/**
|
||||
* Mobile 생일 페이지
|
||||
* @param {object} props
|
||||
* @param {string} props.year - 연도
|
||||
* @param {string} props.nameEn - 멤버 영문 이름 (소문자)
|
||||
*/
|
||||
function MobileBirthday() {
|
||||
const { memberName, year } = useParams();
|
||||
|
||||
// URL 디코딩
|
||||
const decodedMemberName = decodeURIComponent(memberName || '');
|
||||
|
||||
// 멤버 정보 조회
|
||||
function MobileBirthday({ year, nameEn }) {
|
||||
// 멤버 정보 조회 (영문 이름으로)
|
||||
const {
|
||||
data: member,
|
||||
isLoading: memberLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['member', decodedMemberName],
|
||||
queryFn: () => fetchApi(`/members/${encodeURIComponent(decodedMemberName)}`),
|
||||
enabled: !!decodedMemberName,
|
||||
queryKey: ['member', nameEn],
|
||||
queryFn: () => fetchApi(`/members/${encodeURIComponent(nameEn)}`),
|
||||
enabled: !!nameEn,
|
||||
});
|
||||
|
||||
if (!decodedMemberName || error) {
|
||||
if (!nameEn || error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-6">
|
||||
<div className="text-center">
|
||||
|
|
@ -128,7 +126,7 @@ function MobileBirthday() {
|
|||
<div className="text-center py-8">
|
||||
<div className="text-5xl mb-3">🎁</div>
|
||||
<p className="text-gray-500 text-sm">
|
||||
{year}년 {decodedMemberName} 생일카페 정보가 준비 중입니다
|
||||
{year}년 {member?.name} 생일카페 정보가 준비 중입니다
|
||||
</p>
|
||||
<p className="text-gray-400 text-xs mt-1">생일카페 정보가 등록되면 이곳에 표시됩니다</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
@ -303,15 +336,8 @@ function MobileSchedule() {
|
|||
const month = String(selectedDate.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(selectedDate.getDate()).padStart(2, '0');
|
||||
const dateStr = `${year}-${month}-${day}`;
|
||||
return schedules
|
||||
.filter((s) => s.date.split('T')[0] === dateStr)
|
||||
.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;
|
||||
return 0;
|
||||
});
|
||||
// 백엔드에서 이미 정렬된 상태로 전달됨 (특수 일정 우선)
|
||||
return schedules.filter((s) => s.date.split('T')[0] === dateStr);
|
||||
}, [schedules, selectedDate]);
|
||||
|
||||
// 요일 이름
|
||||
|
|
@ -743,6 +769,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 (
|
||||
|
|
@ -750,11 +777,17 @@ function MobileSchedule() {
|
|||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
delay={index * 0.05}
|
||||
onClick={() => {
|
||||
const scheduleYear = new Date(schedule.date).getFullYear();
|
||||
const memberName = schedule.member_names;
|
||||
navigate(`/birthday/${encodeURIComponent(memberName)}/${scheduleYear}`);
|
||||
}}
|
||||
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDebut) {
|
||||
return (
|
||||
<MobileDebutCard
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
delay={index * 0.05}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -772,6 +805,14 @@ function MobileSchedule() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데뷔/주년 축하 다이얼로그 */}
|
||||
<DebutCelebrationDialog
|
||||
isOpen={showDebutDialog}
|
||||
onClose={() => setShowDebutDialog(false)}
|
||||
isDebut={debutDialogInfo.isDebut}
|
||||
anniversaryYear={debutDialogInfo.anniversaryYear}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,34 @@ import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight } from 'lucide-rea
|
|||
import Linkify from 'react-linkify';
|
||||
import { getSchedule } from '@/api';
|
||||
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils';
|
||||
import Birthday from './Birthday';
|
||||
|
||||
/**
|
||||
* 특수 일정 ID 파싱
|
||||
* @param {string} id - 일정 ID
|
||||
* @returns {object|null} { type, year, nameEn } 또는 null
|
||||
*/
|
||||
function parseSpecialId(id) {
|
||||
// birthday-{year}-{nameEn} 형식
|
||||
const birthdayMatch = id.match(/^birthday-(\d{4})-(.+)$/);
|
||||
if (birthdayMatch) {
|
||||
return { type: 'birthday', year: birthdayMatch[1], nameEn: birthdayMatch[2] };
|
||||
}
|
||||
|
||||
// debut-{year} 형식
|
||||
const debutMatch = id.match(/^debut-(\d{4})$/);
|
||||
if (debutMatch) {
|
||||
return { type: 'debut', year: debutMatch[1] };
|
||||
}
|
||||
|
||||
// anniversary-{year} 형식
|
||||
const anniversaryMatch = id.match(/^anniversary-(\d{4})$/);
|
||||
if (anniversaryMatch) {
|
||||
return { type: 'anniversary', year: anniversaryMatch[1] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체화면 시 자동 가로 회전 훅 (숏츠가 아닐 때만)
|
||||
|
|
@ -393,6 +421,12 @@ function MobileDefaultSection({ schedule }) {
|
|||
function MobileScheduleDetail() {
|
||||
const { id } = useParams();
|
||||
|
||||
// 특수 일정 ID 체크
|
||||
const specialId = parseSpecialId(id);
|
||||
if (specialId?.type === 'birthday') {
|
||||
return <Birthday year={specialId.year} nameEn={specialId.nameEn} />;
|
||||
}
|
||||
|
||||
// 모바일 레이아웃 활성화
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add('mobile-layout');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useMemo, useLayoutEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
|
|
@ -37,7 +37,16 @@ function PCTrackDetail() {
|
|||
enabled: !!albumName && !!trackTitle,
|
||||
});
|
||||
|
||||
const youtubeVideoId = useMemo(() => getYoutubeVideoId(track?.music_video_url), [track?.music_video_url]);
|
||||
const youtubeVideoId = useMemo(() => getYoutubeVideoId(track?.video_url), [track?.video_url]);
|
||||
const videoLabel = track?.video_type === 'special' ? '스페셜 영상' : '뮤직비디오';
|
||||
|
||||
// 트랙 변경 시 스크롤 맨 위로
|
||||
useLayoutEffect(() => {
|
||||
const main = document.querySelector('main');
|
||||
if (main) {
|
||||
main.scrollTop = 0;
|
||||
}
|
||||
}, [trackTitle]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -60,7 +69,13 @@ function PCTrackDetail() {
|
|||
}
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }} className="py-12">
|
||||
<motion.div
|
||||
key={trackTitle}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="py-12"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
{/* 브레드크럼 네비게이션 */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-8">
|
||||
|
|
@ -134,12 +149,12 @@ function PCTrackDetail() {
|
|||
>
|
||||
<h2 className="text-lg font-bold mb-4 flex items-center gap-2">
|
||||
<div className="w-1 h-5 bg-red-500 rounded-full" />
|
||||
뮤직비디오
|
||||
{videoLabel}
|
||||
</h2>
|
||||
<div className="relative w-full aspect-video rounded-2xl overflow-hidden shadow-lg bg-black">
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${youtubeVideoId}`}
|
||||
title={`${track.title} 뮤직비디오`}
|
||||
title={`${track.title} ${videoLabel}`}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
|
|
@ -217,10 +232,18 @@ function PCTrackDetail() {
|
|||
{track.otherTracks?.map((t) => {
|
||||
const isCurrent = t.title === track.title;
|
||||
return (
|
||||
<Link
|
||||
<button
|
||||
key={t.id}
|
||||
to={`/album/${encodeURIComponent(track.album?.title || albumName)}/track/${encodeURIComponent(t.title)}`}
|
||||
className={`group flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all ${
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isCurrent) {
|
||||
navigate(
|
||||
`/album/${encodeURIComponent(track.album?.title || albumName)}/track/${encodeURIComponent(t.title)}`,
|
||||
{ replace: true }
|
||||
);
|
||||
}
|
||||
}}
|
||||
className={`w-full group flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all ${
|
||||
isCurrent ? 'bg-primary text-white' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
|
|
@ -257,7 +280,7 @@ function PCTrackDetail() {
|
|||
<span className={`text-xs tabular-nums ${isCurrent ? 'text-white/70' : 'text-gray-400'}`}>
|
||||
{t.duration || ''}
|
||||
</span>
|
||||
</Link>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import { Instagram, Calendar } from 'lucide-react';
|
||||
import { Instagram, Cake } from 'lucide-react';
|
||||
import { useMembers } from '@/hooks';
|
||||
import { Loading } from '@/components/common';
|
||||
import { formatDate } from '@/utils';
|
||||
|
|
@ -67,7 +67,7 @@ function Members() {
|
|||
<h3 className="text-xl font-bold mb-3">{member.name}</h3>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
|
||||
<Calendar size={14} />
|
||||
<Cake size={14} />
|
||||
<span>{formatDate(member.birth_date, 'YYYY.MM.DD')}</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -129,7 +129,7 @@ function Members() {
|
|||
</h3>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Calendar size={14} />
|
||||
<Cake size={14} />
|
||||
<span>
|
||||
{formatDate(member.birth_date, 'YYYY.MM.DD')}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useParams, Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
|
@ -6,25 +6,23 @@ import { fetchApi } from '@/api';
|
|||
|
||||
/**
|
||||
* PC 생일 페이지
|
||||
* @param {object} props
|
||||
* @param {string} props.year - 연도
|
||||
* @param {string} props.nameEn - 멤버 영문 이름 (소문자)
|
||||
*/
|
||||
function PCBirthday() {
|
||||
const { memberName, year } = useParams();
|
||||
|
||||
// URL 디코딩
|
||||
const decodedMemberName = decodeURIComponent(memberName || '');
|
||||
|
||||
// 멤버 정보 조회
|
||||
function PCBirthday({ year, nameEn }) {
|
||||
// 멤버 정보 조회 (영문 이름으로)
|
||||
const {
|
||||
data: member,
|
||||
isLoading: memberLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['member', decodedMemberName],
|
||||
queryFn: () => fetchApi(`/members/${encodeURIComponent(decodedMemberName)}`),
|
||||
enabled: !!decodedMemberName,
|
||||
queryKey: ['member', nameEn],
|
||||
queryFn: () => fetchApi(`/members/${encodeURIComponent(nameEn)}`),
|
||||
enabled: !!nameEn,
|
||||
});
|
||||
|
||||
if (!decodedMemberName || error) {
|
||||
if (!nameEn || error) {
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -125,7 +123,7 @@ function PCBirthday() {
|
|||
<div className="text-center py-12">
|
||||
<div className="text-6xl mb-4">🎁</div>
|
||||
<p className="text-gray-500 text-lg">
|
||||
{year}년 {decodedMemberName} 생일카페 정보가 준비 중입니다
|
||||
{year}년 {member?.name} 생일카페 정보가 준비 중입니다
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm mt-2">생일카페 정보가 등록되면 이곳에 표시됩니다</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,37 +238,18 @@ function PCSchedule() {
|
|||
const currentYearMonth = `${year}-${String(month + 1).padStart(2, '0')}`;
|
||||
|
||||
const filteredSchedules = useMemo(() => {
|
||||
const sortWithBirthdayFirst = (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;
|
||||
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 searchResults;
|
||||
return searchResults.filter((s) => selectedCategories.includes(s.category_id));
|
||||
}
|
||||
|
||||
const filtered = schedules
|
||||
.filter((s) => {
|
||||
return schedules.filter((s) => {
|
||||
const matchesDate = selectedDate ? s.date === selectedDate : s.date?.startsWith(currentYearMonth);
|
||||
const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(s.category_id);
|
||||
return matchesDate && matchesCategory;
|
||||
})
|
||||
.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;
|
||||
if (a.date !== b.date) return a.date.localeCompare(b.date);
|
||||
return (a.time || '00:00:00').localeCompare(b.time || '00:00:00');
|
||||
});
|
||||
return filtered;
|
||||
}, [schedules, selectedDate, currentYearMonth, selectedCategories, isSearchMode, searchTerm, searchResults]);
|
||||
|
||||
// 가상 스크롤
|
||||
|
|
@ -256,15 +262,17 @@ function PCSchedule() {
|
|||
|
||||
// 일정 클릭 핸들러
|
||||
const handleScheduleClick = (schedule) => {
|
||||
if (schedule.is_birthday || String(schedule.id).startsWith('birthday-')) {
|
||||
const scheduleYear = new Date(schedule.date).getFullYear();
|
||||
navigate(`/birthday/${encodeURIComponent(schedule.member_names)}/${scheduleYear}`);
|
||||
// 생일, 데뷔, 주년 등 특수 일정
|
||||
if (schedule.is_birthday || schedule.is_debut || schedule.is_anniversary) {
|
||||
navigate(`/schedule/${schedule.id}`);
|
||||
return;
|
||||
}
|
||||
// 유튜브(2), X(3), 콘서트(6) 카테고리
|
||||
if ([2, 3, 6].includes(schedule.category_id)) {
|
||||
navigate(`/schedule/${schedule.id}`);
|
||||
return;
|
||||
}
|
||||
// 소스 URL이 있으면 외부 링크로
|
||||
if (!schedule.description && schedule.source?.url) {
|
||||
window.open(schedule.source.url, '_blank');
|
||||
} else {
|
||||
|
|
@ -535,6 +543,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 +574,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 +599,14 @@ function PCSchedule() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데뷔/주년 축하 다이얼로그 */}
|
||||
<DebutCelebrationDialog
|
||||
isOpen={showDebutDialog}
|
||||
onClose={() => setShowDebutDialog(false)}
|
||||
isDebut={debutDialogInfo.isDebut}
|
||||
anniversaryYear={debutDialogInfo.anniversaryYear}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,34 @@ import { getSchedule } from '@/api';
|
|||
|
||||
// 섹션 컴포넌트들
|
||||
import { YoutubeSection, XSection, DefaultSection, decodeHtmlEntities } from './sections';
|
||||
import Birthday from './Birthday';
|
||||
|
||||
/**
|
||||
* 특수 일정 ID 파싱
|
||||
* @param {string} id - 일정 ID
|
||||
* @returns {object|null} { type, year, nameEn } 또는 null
|
||||
*/
|
||||
function parseSpecialId(id) {
|
||||
// birthday-{year}-{nameEn} 형식
|
||||
const birthdayMatch = id.match(/^birthday-(\d{4})-(.+)$/);
|
||||
if (birthdayMatch) {
|
||||
return { type: 'birthday', year: birthdayMatch[1], nameEn: birthdayMatch[2] };
|
||||
}
|
||||
|
||||
// debut-{year} 형식
|
||||
const debutMatch = id.match(/^debut-(\d{4})$/);
|
||||
if (debutMatch) {
|
||||
return { type: 'debut', year: debutMatch[1] };
|
||||
}
|
||||
|
||||
// anniversary-{year} 형식
|
||||
const anniversaryMatch = id.match(/^anniversary-(\d{4})$/);
|
||||
if (anniversaryMatch) {
|
||||
return { type: 'anniversary', year: anniversaryMatch[1] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PC 일정 상세 페이지
|
||||
|
|
@ -13,6 +41,12 @@ import { YoutubeSection, XSection, DefaultSection, decodeHtmlEntities } from './
|
|||
function PCScheduleDetail() {
|
||||
const { id } = useParams();
|
||||
|
||||
// 특수 일정 ID 체크
|
||||
const specialId = parseSpecialId(id);
|
||||
if (specialId?.type === 'birthday') {
|
||||
return <Birthday year={specialId.year} nameEn={specialId.nameEn} />;
|
||||
}
|
||||
|
||||
const {
|
||||
data: schedule,
|
||||
isLoading,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import Members from '@/pages/mobile/members/Members';
|
|||
import MembersPreview from '@/pages/mobile/members/MembersPreview';
|
||||
import Schedule from '@/pages/mobile/schedule/Schedule';
|
||||
import ScheduleDetail from '@/pages/mobile/schedule/ScheduleDetail';
|
||||
import Birthday from '@/pages/mobile/schedule/Birthday';
|
||||
import Album from '@/pages/mobile/album/Album';
|
||||
import AlbumDetail from '@/pages/mobile/album/AlbumDetail';
|
||||
import TrackDetail from '@/pages/mobile/album/TrackDetail';
|
||||
|
|
@ -55,7 +54,6 @@ export default function MobileRoutes() {
|
|||
}
|
||||
/>
|
||||
<Route path="/schedule/:id" element={<ScheduleDetail />} />
|
||||
<Route path="/birthday/:memberName/:year" element={<Birthday />} />
|
||||
<Route
|
||||
path="/album"
|
||||
element={
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import Home from '@/pages/pc/public/home/Home';
|
|||
import Members from '@/pages/pc/public/members/Members';
|
||||
import Schedule from '@/pages/pc/public/schedule/Schedule';
|
||||
import ScheduleDetail from '@/pages/pc/public/schedule/ScheduleDetail';
|
||||
import Birthday from '@/pages/pc/public/schedule/Birthday';
|
||||
import Album from '@/pages/pc/public/album/Album';
|
||||
import AlbumDetail from '@/pages/pc/public/album/AlbumDetail';
|
||||
import TrackDetail from '@/pages/pc/public/album/TrackDetail';
|
||||
|
|
@ -30,7 +29,6 @@ export default function PublicRoutes() {
|
|||
<Route path="/members" element={<Members />} />
|
||||
<Route path="/schedule" element={<Schedule />} />
|
||||
<Route path="/schedule/:id" element={<ScheduleDetail />} />
|
||||
<Route path="/birthday/:memberName/:year" element={<Birthday />} />
|
||||
<Route path="/album" element={<Album />} />
|
||||
<Route path="/album/:name" element={<AlbumDetail />} />
|
||||
<Route path="/album/:name/track/:trackTitle" element={<TrackDetail />} />
|
||||
|
|
|
|||
|
|
@ -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