Compare commits
No commits in common. "83c955f8a95b1e32c2f734e8b1dbbc98599710cb" and "0c6d250a9d9ae4eec957eb853d8afd726feb26a8" have entirely different histories.
83c955f8a9
...
0c6d250a9d
39 changed files with 317 additions and 894 deletions
|
|
@ -3,7 +3,7 @@ export default [
|
||||||
id: 'meilisearch-sync',
|
id: 'meilisearch-sync',
|
||||||
type: 'meilisearch',
|
type: 'meilisearch',
|
||||||
name: 'Meilisearch 동기화',
|
name: 'Meilisearch 동기화',
|
||||||
cron: '0 12 * * *', // 매일 12시 전체 동기화
|
cron: '0 4 * * *', // 4시부터 5분간 버전 체크, 변경 시 동기화
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,6 @@ export const CATEGORY_IDS = {
|
||||||
YOUTUBE: 2,
|
YOUTUBE: 2,
|
||||||
X: 3,
|
X: 3,
|
||||||
BIRTHDAY: 8,
|
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 fp from 'fastify-plugin';
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import bots from '../config/bots.js';
|
import bots from '../config/bots.js';
|
||||||
import { syncAllSchedules } from '../services/meilisearch/index.js';
|
import { syncWithRetry, getVersion } from '../services/meilisearch/index.js';
|
||||||
import { nowKST } from '../utils/date.js';
|
import { nowKST } from '../utils/date.js';
|
||||||
|
|
||||||
const REDIS_PREFIX = 'bot:status:';
|
const REDIS_PREFIX = 'bot:status:';
|
||||||
|
|
@ -48,13 +48,121 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
return fastify.xBot.syncNewTweets;
|
return fastify.xBot.syncNewTweets;
|
||||||
} else if (bot.type === 'meilisearch') {
|
} else if (bot.type === 'meilisearch') {
|
||||||
return async () => {
|
return async () => {
|
||||||
const count = await syncAllSchedules(fastify.meilisearch, fastify.db);
|
const count = await syncWithRetry(fastify.meilisearch, fastify.db);
|
||||||
return { addedCount: count, total: count };
|
return { addedCount: count, total: count };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 동기화 결과 처리 (중복 코드 제거)
|
* 동기화 결과 처리 (중복 코드 제거)
|
||||||
*/
|
*/
|
||||||
|
|
@ -91,6 +199,12 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
tasks.delete(botId);
|
tasks.delete(botId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Meilisearch는 버전 체크 방식 사용
|
||||||
|
if (bot.type === 'meilisearch') {
|
||||||
|
await startMeilisearchVersionCheck(botId, bot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const syncFn = getSyncFunction(bot);
|
const syncFn = getSyncFunction(bot);
|
||||||
if (!syncFn) {
|
if (!syncFn) {
|
||||||
throw new Error(`지원하지 않는 봇 타입: ${bot.type}`);
|
throw new Error(`지원하지 않는 봇 타입: ${bot.type}`);
|
||||||
|
|
|
||||||
|
|
@ -62,17 +62,14 @@ export default async function botsRoutes(fastify) {
|
||||||
for (const bot of bots) {
|
for (const bot of bots) {
|
||||||
const status = await scheduler.getStatus(bot.id);
|
const status = await scheduler.getStatus(bot.id);
|
||||||
|
|
||||||
// cron 표현식에서 간격 추출 (분 단위, 일일 스케줄은 1440분)
|
// cron 표현식에서 간격 추출 (분 단위)
|
||||||
let checkInterval = 2; // 기본값
|
let checkInterval = 2; // 기본값
|
||||||
const cronMatch = bot.cron.match(/^\*\/(\d+)/);
|
const cronMatch = bot.cron.match(/^\*\/(\d+)/);
|
||||||
if (cronMatch) {
|
if (cronMatch) {
|
||||||
checkInterval = parseInt(cronMatch[1]);
|
checkInterval = parseInt(cronMatch[1]);
|
||||||
} else if (/^0 \d+ \* \* \*$/.test(bot.cron)) {
|
|
||||||
// 매일 특정 시간 (예: 0 12 * * *)
|
|
||||||
checkInterval = 1440; // 24시간 = 1440분
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result.push({
|
const botData = {
|
||||||
id: bot.id,
|
id: bot.id,
|
||||||
name: bot.name || bot.channelName || bot.username || bot.id,
|
name: bot.name || bot.channelName || bot.username || bot.id,
|
||||||
type: bot.type,
|
type: bot.type,
|
||||||
|
|
@ -84,7 +81,15 @@ export default async function botsRoutes(fastify) {
|
||||||
check_interval: checkInterval,
|
check_interval: checkInterval,
|
||||||
error_message: status.errorMessage,
|
error_message: status.errorMessage,
|
||||||
enabled: bot.enabled,
|
enabled: bot.enabled,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Meilisearch 봇인 경우 버전 정보 추가
|
||||||
|
if (bot.type === 'meilisearch') {
|
||||||
|
const version = await redis.get('meilisearch:version');
|
||||||
|
botData.version = version || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(botData);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
|
||||||
|
|
@ -181,13 +181,12 @@ async function insertTracks(connection, albumId, tracks) {
|
||||||
track.composer || null,
|
track.composer || null,
|
||||||
track.arranger || null,
|
track.arranger || null,
|
||||||
track.lyrics || null,
|
track.lyrics || null,
|
||||||
track.video_url || null,
|
track.music_video_url || null,
|
||||||
track.video_type || null,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await connection.query(
|
await connection.query(
|
||||||
`INSERT INTO album_tracks
|
`INSERT INTO album_tracks
|
||||||
(album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, video_url, video_type)
|
(album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, music_video_url)
|
||||||
VALUES ?`,
|
VALUES ?`,
|
||||||
[values]
|
[values]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ function formatScheduleResponse(hit) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 일정 추가/업데이트 (데이터 직접 전달)
|
* 일정 추가/업데이트
|
||||||
*/
|
*/
|
||||||
export async function addOrUpdateSchedule(meilisearch, schedule) {
|
export async function addOrUpdateSchedule(meilisearch, schedule) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -187,6 +187,7 @@ export async function addOrUpdateSchedule(meilisearch, schedule) {
|
||||||
const document = {
|
const document = {
|
||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
title: schedule.title,
|
title: schedule.title,
|
||||||
|
description: schedule.description || '',
|
||||||
date: schedule.date,
|
date: schedule.date,
|
||||||
time: schedule.time || '',
|
time: schedule.time || '',
|
||||||
category_id: schedule.category_id,
|
category_id: schedule.category_id,
|
||||||
|
|
@ -203,59 +204,6 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 일정 삭제
|
* 일정 삭제
|
||||||
*/
|
*/
|
||||||
|
|
@ -285,6 +233,7 @@ export async function syncAllSchedules(meilisearch, db) {
|
||||||
SELECT
|
SELECT
|
||||||
s.id,
|
s.id,
|
||||||
s.title,
|
s.title,
|
||||||
|
s.description,
|
||||||
s.date,
|
s.date,
|
||||||
s.time,
|
s.time,
|
||||||
s.category_id,
|
s.category_id,
|
||||||
|
|
@ -306,6 +255,7 @@ export async function syncAllSchedules(meilisearch, db) {
|
||||||
const documents = schedules.map(s => ({
|
const documents = schedules.map(s => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
title: s.title,
|
title: s.title,
|
||||||
|
description: s.description || '',
|
||||||
date: s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date,
|
date: s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date,
|
||||||
time: s.time || '',
|
time: s.time || '',
|
||||||
category_id: s.category_id,
|
category_id: s.category_id,
|
||||||
|
|
@ -360,7 +310,7 @@ async function recreateIndex(meilisearch) {
|
||||||
|
|
||||||
// 설정 복원
|
// 설정 복원
|
||||||
await index.updateSearchableAttributes([
|
await index.updateSearchableAttributes([
|
||||||
'title', 'member_names', 'source_name', 'category_name',
|
'title', 'member_names', 'description', 'source_name', 'category_name',
|
||||||
]);
|
]);
|
||||||
await index.updateFilterableAttributes(['category_id', 'date']);
|
await index.updateFilterableAttributes(['category_id', 'date']);
|
||||||
await index.updateSortableAttributes(['date', 'time']);
|
await index.updateSortableAttributes(['date', 'time']);
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,8 @@ export async function invalidateMemberCache(redis) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이름으로 멤버 조회 (별명 포함)
|
* 이름으로 멤버 조회 (별명 포함)
|
||||||
* 한글명(name) 또는 영문명(name_en) 모두 검색 가능
|
|
||||||
* @param {object} db - 데이터베이스 연결
|
* @param {object} db - 데이터베이스 연결
|
||||||
* @param {string} name - 멤버 이름 (한글 또는 영문)
|
* @param {string} name - 멤버 이름
|
||||||
* @returns {object|null} 멤버 정보 또는 null
|
* @returns {object|null} 멤버 정보 또는 null
|
||||||
*/
|
*/
|
||||||
export async function getMemberByName(db, name) {
|
export async function getMemberByName(db, name) {
|
||||||
|
|
@ -76,8 +75,8 @@ export async function getMemberByName(db, name) {
|
||||||
i.thumb_url as image_thumb
|
i.thumb_url as image_thumb
|
||||||
FROM members m
|
FROM members m
|
||||||
LEFT JOIN images i ON m.image_id = i.id
|
LEFT JOIN images i ON m.image_id = i.id
|
||||||
WHERE m.name = ? OR LOWER(m.name_en) = LOWER(?)
|
WHERE m.name = ?
|
||||||
`, [name, name]);
|
`, [name]);
|
||||||
|
|
||||||
if (members.length === 0) {
|
if (members.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* 스케줄 서비스
|
* 스케줄 서비스
|
||||||
* 스케줄 관련 비즈니스 로직
|
* 스케줄 관련 비즈니스 로직
|
||||||
*/
|
*/
|
||||||
import config, { CATEGORY_IDS, DEBUT_DATE } from '../config/index.js';
|
import config, { CATEGORY_IDS } from '../config/index.js';
|
||||||
import { getOrSet, cacheKeys, TTL } from '../utils/cache.js';
|
import { getOrSet, cacheKeys, TTL } from '../utils/cache.js';
|
||||||
|
|
||||||
// ==================== 공통 포맷팅 함수 ====================
|
// ==================== 공통 포맷팅 함수 ====================
|
||||||
|
|
@ -308,16 +308,6 @@ export async function getMonthlySchedules(db, year, month) {
|
||||||
// 일정 포맷팅
|
// 일정 포맷팅
|
||||||
const schedules = formatSchedules(rawSchedules, memberMap);
|
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(`
|
const [birthdays] = await db.query(`
|
||||||
SELECT m.id, m.name, m.name_en, m.birth_date,
|
SELECT m.id, m.name, m.name_en, m.birth_date,
|
||||||
|
|
@ -334,11 +324,15 @@ export async function getMonthlySchedules(db, year, month) {
|
||||||
const birthdayDate = new Date(year, birthDate.getMonth(), birthDate.getDate());
|
const birthdayDate = new Date(year, birthDate.getMonth(), birthDate.getDate());
|
||||||
|
|
||||||
schedules.push({
|
schedules.push({
|
||||||
id: `birthday-${year}-${member.name_en.toLowerCase()}`,
|
id: `birthday-${member.id}`,
|
||||||
title: `HAPPY ${member.name_en} DAY`,
|
title: `HAPPY ${member.name_en} DAY`,
|
||||||
date: birthdayDate.toISOString().split('T')[0],
|
date: birthdayDate.toISOString().split('T')[0],
|
||||||
time: null,
|
time: null,
|
||||||
category: categoryMap[CATEGORY_IDS.BIRTHDAY],
|
category: {
|
||||||
|
id: CATEGORY_IDS.BIRTHDAY,
|
||||||
|
name: '생일',
|
||||||
|
color: '#f472b6',
|
||||||
|
},
|
||||||
source: null,
|
source: null,
|
||||||
members: [member.name],
|
members: [member.name],
|
||||||
is_birthday: true,
|
is_birthday: true,
|
||||||
|
|
@ -346,70 +340,8 @@ export async function getMonthlySchedules(db, year, month) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데뷔/주년 추가 (1월인 경우)
|
// 날짜순 정렬
|
||||||
if (month === DEBUT_DATE.month) {
|
schedules.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
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 };
|
return { schedules };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { fetchVideoInfo } from '../youtube/api.js';
|
||||||
import { formatDate, formatTime, nowKST } from '../../utils/date.js';
|
import { formatDate, formatTime, nowKST } from '../../utils/date.js';
|
||||||
import bots from '../../config/bots.js';
|
import bots from '../../config/bots.js';
|
||||||
import { withTransaction } from '../../utils/transaction.js';
|
import { withTransaction } from '../../utils/transaction.js';
|
||||||
import { syncScheduleById } from '../meilisearch/index.js';
|
|
||||||
|
|
||||||
const X_CATEGORY_ID = 3;
|
const X_CATEGORY_ID = 3;
|
||||||
const YOUTUBE_CATEGORY_ID = 2;
|
const YOUTUBE_CATEGORY_ID = 2;
|
||||||
|
|
@ -142,12 +141,8 @@ async function xBotPlugin(fastify, opts) {
|
||||||
// 관리 중인 채널이면 스킵
|
// 관리 중인 채널이면 스킵
|
||||||
if (managedChannels.includes(video.channelId)) continue;
|
if (managedChannels.includes(video.channelId)) continue;
|
||||||
|
|
||||||
const scheduleId = await saveYoutubeFromTweet(video);
|
const saved = await saveYoutubeFromTweet(video);
|
||||||
if (scheduleId) {
|
if (saved) addedCount++;
|
||||||
// Meilisearch 동기화
|
|
||||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
|
||||||
addedCount++;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(`YouTube 영상 처리 오류 (${videoId}): ${err.message}`);
|
fastify.log.error(`YouTube 영상 처리 오류 (${videoId}): ${err.message}`);
|
||||||
}
|
}
|
||||||
|
|
@ -171,8 +166,6 @@ async function xBotPlugin(fastify, opts) {
|
||||||
for (const tweet of tweets) {
|
for (const tweet of tweets) {
|
||||||
const scheduleId = await saveTweet(tweet);
|
const scheduleId = await saveTweet(tweet);
|
||||||
if (scheduleId) {
|
if (scheduleId) {
|
||||||
// Meilisearch 동기화
|
|
||||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
|
||||||
addedCount++;
|
addedCount++;
|
||||||
// YouTube 링크 처리
|
// YouTube 링크 처리
|
||||||
ytAddedCount += await processYoutubeLinks(tweet);
|
ytAddedCount += await processYoutubeLinks(tweet);
|
||||||
|
|
@ -194,8 +187,6 @@ async function xBotPlugin(fastify, opts) {
|
||||||
for (const tweet of tweets) {
|
for (const tweet of tweets) {
|
||||||
const scheduleId = await saveTweet(tweet);
|
const scheduleId = await saveTweet(tweet);
|
||||||
if (scheduleId) {
|
if (scheduleId) {
|
||||||
// Meilisearch 동기화
|
|
||||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
|
||||||
addedCount++;
|
addedCount++;
|
||||||
ytAddedCount += await processYoutubeLinks(tweet);
|
ytAddedCount += await processYoutubeLinks(tweet);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.j
|
||||||
import bots from '../../config/bots.js';
|
import bots from '../../config/bots.js';
|
||||||
import { CATEGORY_IDS } from '../../config/index.js';
|
import { CATEGORY_IDS } from '../../config/index.js';
|
||||||
import { withTransaction } from '../../utils/transaction.js';
|
import { withTransaction } from '../../utils/transaction.js';
|
||||||
import { syncScheduleById } from '../meilisearch/index.js';
|
|
||||||
|
|
||||||
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE;
|
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE;
|
||||||
const PLAYLIST_CACHE_PREFIX = 'yt_uploads:';
|
const PLAYLIST_CACHE_PREFIX = 'yt_uploads:';
|
||||||
|
|
@ -127,8 +126,6 @@ async function youtubeBotPlugin(fastify, opts) {
|
||||||
for (const video of videos) {
|
for (const video of videos) {
|
||||||
const scheduleId = await saveVideo(video, bot);
|
const scheduleId = await saveVideo(video, bot);
|
||||||
if (scheduleId) {
|
if (scheduleId) {
|
||||||
// Meilisearch 동기화
|
|
||||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
|
||||||
addedCount++;
|
addedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -147,8 +144,6 @@ async function youtubeBotPlugin(fastify, opts) {
|
||||||
for (const video of videos) {
|
for (const video of videos) {
|
||||||
const scheduleId = await saveVideo(video, bot);
|
const scheduleId = await saveVideo(video, bot);
|
||||||
if (scheduleId) {
|
if (scheduleId) {
|
||||||
// Meilisearch 동기화
|
|
||||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
|
||||||
addedCount++;
|
addedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,6 @@ services:
|
||||||
meilisearch:
|
meilisearch:
|
||||||
image: getmeili/meilisearch:latest
|
image: getmeili/meilisearch:latest
|
||||||
container_name: fromis9-meilisearch
|
container_name: fromis9-meilisearch
|
||||||
labels:
|
|
||||||
- "com.centurylinklabs.watchtower.enable=false"
|
|
||||||
environment:
|
environment:
|
||||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
14
docs/api.md
14
docs/api.md
|
|
@ -19,16 +19,9 @@ Base URL: `/api`
|
||||||
### GET /members
|
### GET /members
|
||||||
멤버 목록 조회
|
멤버 목록 조회
|
||||||
|
|
||||||
### GET /members/:name
|
### GET /members/:id
|
||||||
멤버 상세 조회
|
멤버 상세 조회
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `name` - 멤버 이름 (한글 또는 영문, 대소문자 무관)
|
|
||||||
|
|
||||||
**예시:**
|
|
||||||
- `/members/박지원` - 한글명으로 조회
|
|
||||||
- `/members/jiwon` - 영문명으로 조회
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 앨범
|
## 앨범
|
||||||
|
|
@ -73,11 +66,6 @@ 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"`으로 반환
|
※ `time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ fromis_9/
|
||||||
│ │ │ ├── index.js
|
│ │ │ ├── index.js
|
||||||
│ │ │ ├── cn.js # className 병합
|
│ │ │ ├── cn.js # className 병합
|
||||||
│ │ │ ├── color.js # 색상 상수/유틸
|
│ │ │ ├── color.js # 색상 상수/유틸
|
||||||
│ │ │ ├── confetti.js # 축하 효과 (생일, 데뷔/주년)
|
│ │ │ ├── confetti.js # 생일 축하 효과
|
||||||
│ │ │ ├── date.js # 날짜 포맷
|
│ │ │ ├── date.js # 날짜 포맷
|
||||||
│ │ │ ├── format.js # 문자열 포맷
|
│ │ │ ├── format.js # 문자열 포맷
|
||||||
│ │ │ ├── schedule.js # 일정 관련 유틸
|
│ │ │ ├── schedule.js # 일정 관련 유틸
|
||||||
|
|
@ -107,9 +107,7 @@ fromis_9/
|
||||||
│ │ │ │ ├── MobileLightbox.jsx
|
│ │ │ │ ├── MobileLightbox.jsx
|
||||||
│ │ │ │ ├── LightboxIndicator.jsx
|
│ │ │ │ ├── LightboxIndicator.jsx
|
||||||
│ │ │ │ ├── AnimatedNumber.jsx
|
│ │ │ │ ├── AnimatedNumber.jsx
|
||||||
│ │ │ │ ├── ScrollToTop.jsx
|
│ │ │ │ └── ScrollToTop.jsx
|
||||||
│ │ │ │ ├── Fromis9Logo.jsx # 프로미스나인 로고 SVG
|
|
||||||
│ │ │ │ └── DebutCelebrationDialog.jsx # 데뷔/주년 축하 다이얼로그
|
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ │ ├── pc/
|
│ │ │ ├── pc/
|
||||||
│ │ │ │ ├── public/ # PC 공개 컴포넌트
|
│ │ │ │ ├── public/ # PC 공개 컴포넌트
|
||||||
|
|
@ -121,7 +119,6 @@ fromis_9/
|
||||||
│ │ │ │ │ ├── Calendar.jsx
|
│ │ │ │ │ ├── Calendar.jsx
|
||||||
│ │ │ │ │ ├── ScheduleCard.jsx
|
│ │ │ │ │ ├── ScheduleCard.jsx
|
||||||
│ │ │ │ │ ├── BirthdayCard.jsx
|
│ │ │ │ │ ├── BirthdayCard.jsx
|
||||||
│ │ │ │ │ ├── DebutCard.jsx # 데뷔/주년 카드
|
|
||||||
│ │ │ │ │ └── CategoryFilter.jsx
|
│ │ │ │ │ └── CategoryFilter.jsx
|
||||||
│ │ │ │ │
|
│ │ │ │ │
|
||||||
│ │ │ │ └── admin/ # PC 관리자 컴포넌트
|
│ │ │ │ └── admin/ # PC 관리자 컴포넌트
|
||||||
|
|
@ -162,8 +159,7 @@ fromis_9/
|
||||||
│ │ │ ├── ScheduleCard.jsx
|
│ │ │ ├── ScheduleCard.jsx
|
||||||
│ │ │ ├── ScheduleListCard.jsx
|
│ │ │ ├── ScheduleListCard.jsx
|
||||||
│ │ │ ├── ScheduleSearchCard.jsx
|
│ │ │ ├── ScheduleSearchCard.jsx
|
||||||
│ │ │ ├── BirthdayCard.jsx
|
│ │ │ └── BirthdayCard.jsx
|
||||||
│ │ │ └── DebutCard.jsx # 데뷔/주년 카드
|
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── pages/
|
│ │ ├── pages/
|
||||||
│ │ │ ├── pc/
|
│ │ │ ├── pc/
|
||||||
|
|
@ -325,7 +321,6 @@ fromis_9/
|
||||||
|
|
||||||
### 검색 인덱스 (Meilisearch)
|
### 검색 인덱스 (Meilisearch)
|
||||||
- `schedules` - 일정 검색용 인덱스
|
- `schedules` - 일정 검색용 인덱스
|
||||||
- 검색 필드: title, member_names, source_name, category_name
|
- 검색 필드: title, member_names, description, source_name, category_name
|
||||||
- 필터: category_id, date
|
- 필터: category_id, date
|
||||||
- 정렬: date, time
|
- 정렬: date, time
|
||||||
- 동기화: 봇/수동 일정 추가/수정/삭제 시 실시간 동기화
|
|
||||||
|
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
/**
|
|
||||||
* 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,12 +16,6 @@ function ScrollToTop() {
|
||||||
if (mobileContent) {
|
if (mobileContent) {
|
||||||
mobileContent.scrollTop = 0;
|
mobileContent.scrollTop = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PC 레이아웃 스크롤 컨테이너 초기화
|
|
||||||
const main = document.querySelector('main');
|
|
||||||
if (main) {
|
|
||||||
main.scrollTop = 0;
|
|
||||||
}
|
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -7,5 +7,3 @@ export { default as Lightbox } from './Lightbox';
|
||||||
export { default as MobileLightbox } from './MobileLightbox';
|
export { default as MobileLightbox } from './MobileLightbox';
|
||||||
export { default as LightboxIndicator } from './LightboxIndicator';
|
export { default as LightboxIndicator } from './LightboxIndicator';
|
||||||
export { default as AnimatedNumber } from './AnimatedNumber';
|
export { default as AnimatedNumber } from './AnimatedNumber';
|
||||||
export { default as Fromis9Logo } from './Fromis9Logo';
|
|
||||||
export { default as DebutCelebrationDialog } from './DebutCelebrationDialog';
|
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
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,4 +3,3 @@ export { default as ScheduleCard } from './ScheduleCard';
|
||||||
export { default as ScheduleListCard } from './ScheduleListCard';
|
export { default as ScheduleListCard } from './ScheduleListCard';
|
||||||
export { default as ScheduleSearchCard } from './ScheduleSearchCard';
|
export { default as ScheduleSearchCard } from './ScheduleSearchCard';
|
||||||
export { default as BirthdayCard } from './BirthdayCard';
|
export { default as BirthdayCard } from './BirthdayCard';
|
||||||
export { default as DebutCard } from './DebutCard';
|
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,6 @@
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Trash2, Star, ChevronDown } from 'lucide-react';
|
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
|
* @param {Object} props
|
||||||
|
|
@ -127,26 +120,16 @@ const TrackItem = memo(function TrackItem({ track, index, onUpdate, onRemove })
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 비디오 URL */}
|
{/* MV URL */}
|
||||||
<div className="mt-3">
|
<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
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
value={track.music_video_url || ''}
|
||||||
value={track.video_url || ''}
|
onChange={(e) => onUpdate(index, 'music_video_url', e.target.value)}
|
||||||
onChange={(e) => onUpdate(index, '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"
|
||||||
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=..."
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* 가사 */}
|
{/* 가사 */}
|
||||||
|
|
|
||||||
|
|
@ -142,22 +142,47 @@ const BotCard = memo(function BotCard({
|
||||||
|
|
||||||
{/* 통계 정보 */}
|
{/* 통계 정보 */}
|
||||||
<div className="grid grid-cols-3 divide-x divide-gray-100 bg-gray-50/50">
|
<div className="grid grid-cols-3 divide-x divide-gray-100 bg-gray-50/50">
|
||||||
<div className="p-3 text-center">
|
{bot.type === 'meilisearch' ? (
|
||||||
<div className="text-lg font-bold text-gray-900">{bot.schedules_added || 0}</div>
|
<>
|
||||||
<div className="text-xs text-gray-400">총 추가</div>
|
<div className="p-3 text-center">
|
||||||
</div>
|
<div className="text-lg font-bold text-gray-900">
|
||||||
<div className="p-3 text-center">
|
{bot.last_added_count?.toLocaleString() || '-'}
|
||||||
<div
|
</div>
|
||||||
className={`text-lg font-bold ${bot.last_added_count > 0 ? 'text-green-500' : 'text-gray-400'}`}
|
<div className="text-xs text-gray-400">동기화 수</div>
|
||||||
>
|
</div>
|
||||||
+{bot.last_added_count || 0}
|
<div className="p-3 text-center">
|
||||||
</div>
|
<div className="text-lg font-bold text-gray-900">
|
||||||
<div className="text-xs text-gray-400">마지막</div>
|
{bot.last_sync_duration != null
|
||||||
</div>
|
? `${(bot.last_sync_duration / 1000).toFixed(1)}초`
|
||||||
<div className="p-3 text-center">
|
: '-'}
|
||||||
<div className="text-lg font-bold text-gray-900">{formatInterval(bot.check_interval)}</div>
|
</div>
|
||||||
<div className="text-xs text-gray-400">업데이트 간격</div>
|
<div className="text-xs text-gray-400">소요 시간</div>
|
||||||
</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-xs text-gray-400">총 추가</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 text-center">
|
||||||
|
<div
|
||||||
|
className={`text-lg font-bold ${bot.last_added_count > 0 ? 'text-green-500' : 'text-gray-400'}`}
|
||||||
|
>
|
||||||
|
+{bot.last_added_count || 0}
|
||||||
|
</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">{formatInterval(bot.check_interval)}</div>
|
||||||
|
<div className="text-xs text-gray-400">업데이트 간격</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 오류 메시지 */}
|
{/* 오류 메시지 */}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,10 @@ import { ChevronDown } from 'lucide-react';
|
||||||
* @param {Object} props
|
* @param {Object} props
|
||||||
* @param {string} props.value - 선택된 값
|
* @param {string} props.value - 선택된 값
|
||||||
* @param {Function} props.onChange - 값 변경 핸들러
|
* @param {Function} props.onChange - 값 변경 핸들러
|
||||||
* @param {Array<string|{value: string, label: string}>} props.options - 옵션 목록 (문자열 또는 {value, label} 객체)
|
* @param {string[]} props.options - 옵션 목록
|
||||||
* @param {string} props.placeholder - 플레이스홀더
|
* @param {string} props.placeholder - 플레이스홀더
|
||||||
* @param {string} props.className - 추가 클래스명
|
|
||||||
* @param {string} props.size - 크기 ('sm' | 'md')
|
|
||||||
*/
|
*/
|
||||||
function CustomSelect({ value, onChange, options, placeholder, className = '', size = 'md' }) {
|
function CustomSelect({ value, onChange, options, placeholder }) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
|
@ -28,29 +26,17 @@ function CustomSelect({ value, onChange, options, placeholder, className = '', s
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
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 (
|
return (
|
||||||
<div ref={ref} className={`relative ${className}`}>
|
<div ref={ref} className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
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`}
|
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"
|
||||||
>
|
>
|
||||||
<span className={selectedLabel ? 'text-gray-900' : 'text-gray-400'}>
|
<span className={value ? 'text-gray-900' : 'text-gray-400'}>{value || placeholder}</span>
|
||||||
{selectedLabel || placeholder}
|
|
||||||
</span>
|
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
size={size === 'sm' ? 16 : 18}
|
size={18}
|
||||||
className={`text-gray-400 transition-transform flex-shrink-0 ml-2 ${isOpen ? 'rotate-180' : ''}`}
|
className={`text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -63,19 +49,19 @@ function CustomSelect({ value, onChange, options, placeholder, className = '', s
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
className="absolute z-50 w-full mt-2 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden"
|
className="absolute z-50 w-full mt-2 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden"
|
||||||
>
|
>
|
||||||
{normalizedOptions.map((option) => (
|
{options.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange(option.value);
|
onChange(option);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full ${sizeClasses} text-left hover:bg-gray-50 transition-colors ${
|
className={`w-full px-4 py-2.5 text-left hover:bg-gray-50 transition-colors ${
|
||||||
value === option.value ? 'bg-primary/10 text-primary font-medium' : 'text-gray-700'
|
value === option ? 'bg-primary/10 text-primary font-medium' : 'text-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
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,5 +1,4 @@
|
||||||
export { default as Calendar } from './Calendar';
|
export { default as Calendar } from './Calendar';
|
||||||
export { default as ScheduleCard } from './ScheduleCard';
|
export { default as ScheduleCard } from './ScheduleCard';
|
||||||
export { default as BirthdayCard } from './BirthdayCard';
|
export { default as BirthdayCard } from './BirthdayCard';
|
||||||
export { default as DebutCard } from './DebutCard';
|
|
||||||
export { default as CategoryFilter } from './CategoryFilter';
|
export { default as CategoryFilter } from './CategoryFilter';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useMemo, useEffect, useLayoutEffect } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
|
@ -37,13 +37,7 @@ function MobileTrackDetail() {
|
||||||
enabled: !!albumName && !!trackTitle,
|
enabled: !!albumName && !!trackTitle,
|
||||||
});
|
});
|
||||||
|
|
||||||
const youtubeVideoId = useMemo(() => getYoutubeVideoId(track?.video_url), [track?.video_url]);
|
const youtubeVideoId = useMemo(() => getYoutubeVideoId(track?.music_video_url), [track?.music_video_url]);
|
||||||
const videoLabel = track?.video_type === 'special' ? '스페셜 영상' : '뮤직비디오';
|
|
||||||
|
|
||||||
// 트랙 변경 시 스크롤 맨 위로
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
|
||||||
}, [trackTitle]);
|
|
||||||
|
|
||||||
// 가사 펼침 상태
|
// 가사 펼침 상태
|
||||||
const [showFullLyrics, setShowFullLyrics] = useState(false);
|
const [showFullLyrics, setShowFullLyrics] = useState(false);
|
||||||
|
|
@ -98,7 +92,7 @@ function MobileTrackDetail() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={trackTitle} className="pb-4">
|
<div className="pb-4">
|
||||||
{/* 트랙 정보 헤더 */}
|
{/* 트랙 정보 헤더 */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
|
@ -146,12 +140,12 @@ function MobileTrackDetail() {
|
||||||
>
|
>
|
||||||
<h2 className="text-base font-bold mb-3 flex items-center gap-2">
|
<h2 className="text-base font-bold mb-3 flex items-center gap-2">
|
||||||
<div className="w-1 h-4 bg-red-500 rounded-full" />
|
<div className="w-1 h-4 bg-red-500 rounded-full" />
|
||||||
{videoLabel}
|
뮤직비디오
|
||||||
</h2>
|
</h2>
|
||||||
<div className="relative w-full aspect-video rounded-xl overflow-hidden shadow-md bg-black">
|
<div className="relative w-full aspect-video rounded-xl overflow-hidden shadow-md bg-black">
|
||||||
<iframe
|
<iframe
|
||||||
src={`https://www.youtube.com/embed/${youtubeVideoId}`}
|
src={`https://www.youtube.com/embed/${youtubeVideoId}`}
|
||||||
title={`${track.title} ${videoLabel}`}
|
title={`${track.title} 뮤직비디오`}
|
||||||
className="absolute inset-0 w-full h-full"
|
className="absolute inset-0 w-full h-full"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen; web-share"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen; web-share"
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
import { Cake, X, Instagram } from 'lucide-react';
|
import { Calendar, X, Instagram } from 'lucide-react';
|
||||||
import { useMembers } from '@/hooks';
|
import { useMembers } from '@/hooks';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -173,7 +173,7 @@ function MobileMembers() {
|
||||||
<h3 className="text-lg font-bold">{selectedMember.name}</h3>
|
<h3 className="text-lg font-bold">{selectedMember.name}</h3>
|
||||||
{selectedMember.birth_date && (
|
{selectedMember.birth_date && (
|
||||||
<div className="flex items-center justify-center gap-1 text-gray-500 text-sm mt-1.5">
|
<div className="flex items-center justify-center gap-1 text-gray-500 text-sm mt-1.5">
|
||||||
<Cake size={14} />
|
<Calendar size={14} />
|
||||||
<span>
|
<span>
|
||||||
{selectedMember.birth_date?.slice(0, 10).replaceAll('-', '.')}
|
{selectedMember.birth_date?.slice(0, 10).replaceAll('-', '.')}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
import { Instagram, Cake, ChevronRight } from 'lucide-react';
|
import { Instagram, Calendar, ChevronRight } from 'lucide-react';
|
||||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
import 'swiper/css';
|
import 'swiper/css';
|
||||||
import { useMembers } from '@/hooks';
|
import { useMembers } from '@/hooks';
|
||||||
|
|
@ -152,7 +152,7 @@ function CurrentDesign() {
|
||||||
<h2 className="text-[32px] font-bold text-white drop-shadow-lg">{member.name}</h2>
|
<h2 className="text-[32px] font-bold text-white drop-shadow-lg">{member.name}</h2>
|
||||||
{member.birth_date && (
|
{member.birth_date && (
|
||||||
<div className="flex items-center gap-1.5 mt-1.5 text-white/80">
|
<div className="flex items-center gap-1.5 mt-1.5 text-white/80">
|
||||||
<Cake size={16} className="text-white/70" />
|
<Calendar size={16} className="text-white/70" />
|
||||||
<span className="text-sm">{member.birth_date?.slice(0, 10).replaceAll('-', '.')}</span>
|
<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>}
|
{age && <span className="ml-2 px-2 py-0.5 bg-white/20 rounded-lg text-xs text-white font-medium">{age}세</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -227,7 +227,7 @@ function CardDesign() {
|
||||||
<h2 className="text-2xl font-bold text-gray-900">{member.name}</h2>
|
<h2 className="text-2xl font-bold text-gray-900">{member.name}</h2>
|
||||||
{member.birth_date && (
|
{member.birth_date && (
|
||||||
<div className="flex items-center gap-2 mt-2 text-gray-500">
|
<div className="flex items-center gap-2 mt-2 text-gray-500">
|
||||||
<Cake size={16} />
|
<Calendar size={16} />
|
||||||
<span className="text-sm">{member.birth_date?.slice(0, 10).replaceAll('-', '.')}</span>
|
<span className="text-sm">{member.birth_date?.slice(0, 10).replaceAll('-', '.')}</span>
|
||||||
{age && (
|
{age && (
|
||||||
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-md text-xs font-medium">
|
<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>
|
<h2 className="text-3xl font-bold text-gray-900">{currentMember.name}</h2>
|
||||||
{currentMember.birth_date && (
|
{currentMember.birth_date && (
|
||||||
<div className="flex items-center gap-2 mt-2 text-gray-500">
|
<div className="flex items-center gap-2 mt-2 text-gray-500">
|
||||||
<Cake size={16} />
|
<Calendar size={16} />
|
||||||
<span>{currentMember.birth_date?.slice(0, 10).replaceAll('-', '.')}</span>
|
<span>{currentMember.birth_date?.slice(0, 10).replaceAll('-', '.')}</span>
|
||||||
{age && (
|
{age && (
|
||||||
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-md text-sm font-medium">
|
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-md text-sm font-medium">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
@ -6,23 +6,25 @@ import { fetchApi } from '@/api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile 생일 페이지
|
* Mobile 생일 페이지
|
||||||
* @param {object} props
|
|
||||||
* @param {string} props.year - 연도
|
|
||||||
* @param {string} props.nameEn - 멤버 영문 이름 (소문자)
|
|
||||||
*/
|
*/
|
||||||
function MobileBirthday({ year, nameEn }) {
|
function MobileBirthday() {
|
||||||
// 멤버 정보 조회 (영문 이름으로)
|
const { memberName, year } = useParams();
|
||||||
|
|
||||||
|
// URL 디코딩
|
||||||
|
const decodedMemberName = decodeURIComponent(memberName || '');
|
||||||
|
|
||||||
|
// 멤버 정보 조회
|
||||||
const {
|
const {
|
||||||
data: member,
|
data: member,
|
||||||
isLoading: memberLoading,
|
isLoading: memberLoading,
|
||||||
error,
|
error,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['member', nameEn],
|
queryKey: ['member', decodedMemberName],
|
||||||
queryFn: () => fetchApi(`/members/${encodeURIComponent(nameEn)}`),
|
queryFn: () => fetchApi(`/members/${encodeURIComponent(decodedMemberName)}`),
|
||||||
enabled: !!nameEn,
|
enabled: !!decodedMemberName,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!nameEn || error) {
|
if (!decodedMemberName || error) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-6">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
@ -126,7 +128,7 @@ function MobileBirthday({ year, nameEn }) {
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<div className="text-5xl mb-3">🎁</div>
|
<div className="text-5xl mb-3">🎁</div>
|
||||||
<p className="text-gray-500 text-sm">
|
<p className="text-gray-500 text-sm">
|
||||||
{year}년 {member?.name} 생일카페 정보가 준비 중입니다
|
{year}년 {decodedMemberName} 생일카페 정보가 준비 중입니다
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-400 text-xs mt-1">생일카페 정보가 등록되면 이곳에 표시됩니다</p>
|
<p className="text-gray-400 text-xs mt-1">생일카페 정보가 등록되면 이곳에 표시됩니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,8 @@ import {
|
||||||
ScheduleListCard as MobileScheduleListCard,
|
ScheduleListCard as MobileScheduleListCard,
|
||||||
ScheduleSearchCard as MobileScheduleSearchCard,
|
ScheduleSearchCard as MobileScheduleSearchCard,
|
||||||
BirthdayCard as MobileBirthdayCard,
|
BirthdayCard as MobileBirthdayCard,
|
||||||
DebutCard as MobileDebutCard,
|
|
||||||
} from '@/components/mobile';
|
} from '@/components/mobile';
|
||||||
import { DebutCelebrationDialog } from '@/components/common';
|
import { fireBirthdayConfetti } from '@/utils';
|
||||||
import { fireBirthdayConfetti, fireDebutConfetti } from '@/utils';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모바일 일정 페이지
|
* 모바일 일정 페이지
|
||||||
|
|
@ -53,8 +51,6 @@ function MobileSchedule() {
|
||||||
const [suggestions, setSuggestions] = useState([]);
|
const [suggestions, setSuggestions] = useState([]);
|
||||||
const [lastSearchTerm, setLastSearchTerm] = useState('');
|
const [lastSearchTerm, setLastSearchTerm] = useState('');
|
||||||
const [showSuggestionsScreen, setShowSuggestionsScreen] = useState(false);
|
const [showSuggestionsScreen, setShowSuggestionsScreen] = useState(false);
|
||||||
const [showDebutDialog, setShowDebutDialog] = useState(false);
|
|
||||||
const [debutDialogInfo, setDebutDialogInfo] = useState({ isDebut: false, anniversaryYear: 0 });
|
|
||||||
|
|
||||||
// 검색 모드 진입/종료
|
// 검색 모드 진입/종료
|
||||||
const enterSearchMode = () => {
|
const enterSearchMode = () => {
|
||||||
|
|
@ -202,35 +198,6 @@ function MobileSchedule() {
|
||||||
}
|
}
|
||||||
}, [schedules, loading]);
|
}, [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월 이전으로 이동 불가
|
// 2017년 1월 이전으로 이동 불가
|
||||||
const canGoPrevMonth = !(selectedDate.getFullYear() === MIN_YEAR && selectedDate.getMonth() === 0);
|
const canGoPrevMonth = !(selectedDate.getFullYear() === MIN_YEAR && selectedDate.getMonth() === 0);
|
||||||
|
|
||||||
|
|
@ -336,8 +303,15 @@ function MobileSchedule() {
|
||||||
const month = String(selectedDate.getMonth() + 1).padStart(2, '0');
|
const month = String(selectedDate.getMonth() + 1).padStart(2, '0');
|
||||||
const day = String(selectedDate.getDate()).padStart(2, '0');
|
const day = String(selectedDate.getDate()).padStart(2, '0');
|
||||||
const dateStr = `${year}-${month}-${day}`;
|
const dateStr = `${year}-${month}-${day}`;
|
||||||
// 백엔드에서 이미 정렬된 상태로 전달됨 (특수 일정 우선)
|
return schedules
|
||||||
return schedules.filter((s) => s.date.split('T')[0] === dateStr);
|
.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;
|
||||||
|
});
|
||||||
}, [schedules, selectedDate]);
|
}, [schedules, selectedDate]);
|
||||||
|
|
||||||
// 요일 이름
|
// 요일 이름
|
||||||
|
|
@ -769,7 +743,6 @@ function MobileSchedule() {
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{selectedDateSchedules.map((schedule, index) => {
|
{selectedDateSchedules.map((schedule, index) => {
|
||||||
const isBirthday = schedule.is_birthday || String(schedule.id).startsWith('birthday-');
|
const isBirthday = schedule.is_birthday || String(schedule.id).startsWith('birthday-');
|
||||||
const isDebut = schedule.is_debut || schedule.is_anniversary;
|
|
||||||
|
|
||||||
if (isBirthday) {
|
if (isBirthday) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -777,17 +750,11 @@ function MobileSchedule() {
|
||||||
key={schedule.id}
|
key={schedule.id}
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
delay={index * 0.05}
|
delay={index * 0.05}
|
||||||
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
onClick={() => {
|
||||||
/>
|
const scheduleYear = new Date(schedule.date).getFullYear();
|
||||||
);
|
const memberName = schedule.member_names;
|
||||||
}
|
navigate(`/birthday/${encodeURIComponent(memberName)}/${scheduleYear}`);
|
||||||
|
}}
|
||||||
if (isDebut) {
|
|
||||||
return (
|
|
||||||
<MobileDebutCard
|
|
||||||
key={schedule.id}
|
|
||||||
schedule={schedule}
|
|
||||||
delay={index * 0.05}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -805,14 +772,6 @@ function MobileSchedule() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 데뷔/주년 축하 다이얼로그 */}
|
|
||||||
<DebutCelebrationDialog
|
|
||||||
isOpen={showDebutDialog}
|
|
||||||
onClose={() => setShowDebutDialog(false)}
|
|
||||||
isDebut={debutDialogInfo.isDebut}
|
|
||||||
anniversaryYear={debutDialogInfo.anniversaryYear}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,34 +6,6 @@ import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight } from 'lucide-rea
|
||||||
import Linkify from 'react-linkify';
|
import Linkify from 'react-linkify';
|
||||||
import { getSchedule } from '@/api';
|
import { getSchedule } from '@/api';
|
||||||
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils';
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 전체화면 시 자동 가로 회전 훅 (숏츠가 아닐 때만)
|
* 전체화면 시 자동 가로 회전 훅 (숏츠가 아닐 때만)
|
||||||
|
|
@ -421,12 +393,6 @@ function MobileDefaultSection({ schedule }) {
|
||||||
function MobileScheduleDetail() {
|
function MobileScheduleDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
||||||
// 특수 일정 ID 체크
|
|
||||||
const specialId = parseSpecialId(id);
|
|
||||||
if (specialId?.type === 'birthday') {
|
|
||||||
return <Birthday year={specialId.year} nameEn={specialId.nameEn} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 모바일 레이아웃 활성화
|
// 모바일 레이아웃 활성화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.classList.add('mobile-layout');
|
document.documentElement.classList.add('mobile-layout');
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useLayoutEffect } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
|
@ -37,16 +37,7 @@ function PCTrackDetail() {
|
||||||
enabled: !!albumName && !!trackTitle,
|
enabled: !!albumName && !!trackTitle,
|
||||||
});
|
});
|
||||||
|
|
||||||
const youtubeVideoId = useMemo(() => getYoutubeVideoId(track?.video_url), [track?.video_url]);
|
const youtubeVideoId = useMemo(() => getYoutubeVideoId(track?.music_video_url), [track?.music_video_url]);
|
||||||
const videoLabel = track?.video_type === 'special' ? '스페셜 영상' : '뮤직비디오';
|
|
||||||
|
|
||||||
// 트랙 변경 시 스크롤 맨 위로
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const main = document.querySelector('main');
|
|
||||||
if (main) {
|
|
||||||
main.scrollTop = 0;
|
|
||||||
}
|
|
||||||
}, [trackTitle]);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -69,13 +60,7 @@ function PCTrackDetail() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }} className="py-12">
|
||||||
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="max-w-7xl mx-auto px-6">
|
||||||
{/* 브레드크럼 네비게이션 */}
|
{/* 브레드크럼 네비게이션 */}
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-8">
|
<div className="flex items-center gap-2 text-sm text-gray-500 mb-8">
|
||||||
|
|
@ -149,12 +134,12 @@ function PCTrackDetail() {
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-bold mb-4 flex items-center gap-2">
|
<h2 className="text-lg font-bold mb-4 flex items-center gap-2">
|
||||||
<div className="w-1 h-5 bg-red-500 rounded-full" />
|
<div className="w-1 h-5 bg-red-500 rounded-full" />
|
||||||
{videoLabel}
|
뮤직비디오
|
||||||
</h2>
|
</h2>
|
||||||
<div className="relative w-full aspect-video rounded-2xl overflow-hidden shadow-lg bg-black">
|
<div className="relative w-full aspect-video rounded-2xl overflow-hidden shadow-lg bg-black">
|
||||||
<iframe
|
<iframe
|
||||||
src={`https://www.youtube.com/embed/${youtubeVideoId}`}
|
src={`https://www.youtube.com/embed/${youtubeVideoId}`}
|
||||||
title={`${track.title} ${videoLabel}`}
|
title={`${track.title} 뮤직비디오`}
|
||||||
className="absolute inset-0 w-full h-full"
|
className="absolute inset-0 w-full h-full"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
|
|
@ -232,18 +217,10 @@ function PCTrackDetail() {
|
||||||
{track.otherTracks?.map((t) => {
|
{track.otherTracks?.map((t) => {
|
||||||
const isCurrent = t.title === track.title;
|
const isCurrent = t.title === track.title;
|
||||||
return (
|
return (
|
||||||
<button
|
<Link
|
||||||
key={t.id}
|
key={t.id}
|
||||||
type="button"
|
to={`/album/${encodeURIComponent(track.album?.title || albumName)}/track/${encodeURIComponent(t.title)}`}
|
||||||
onClick={() => {
|
className={`group flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all ${
|
||||||
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'
|
isCurrent ? 'bg-primary text-white' : 'hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
@ -280,7 +257,7 @@ function PCTrackDetail() {
|
||||||
<span className={`text-xs tabular-nums ${isCurrent ? 'text-white/70' : 'text-gray-400'}`}>
|
<span className={`text-xs tabular-nums ${isCurrent ? 'text-white/70' : 'text-gray-400'}`}>
|
||||||
{t.duration || ''}
|
{t.duration || ''}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Instagram, Cake } from 'lucide-react';
|
import { Instagram, Calendar } from 'lucide-react';
|
||||||
import { useMembers } from '@/hooks';
|
import { useMembers } from '@/hooks';
|
||||||
import { Loading } from '@/components/common';
|
import { Loading } from '@/components/common';
|
||||||
import { formatDate } from '@/utils';
|
import { formatDate } from '@/utils';
|
||||||
|
|
@ -67,7 +67,7 @@ function Members() {
|
||||||
<h3 className="text-xl font-bold mb-3">{member.name}</h3>
|
<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">
|
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
|
||||||
<Cake size={14} />
|
<Calendar size={14} />
|
||||||
<span>{formatDate(member.birth_date, 'YYYY.MM.DD')}</span>
|
<span>{formatDate(member.birth_date, 'YYYY.MM.DD')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -129,7 +129,7 @@ function Members() {
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
<Cake size={14} />
|
<Calendar size={14} />
|
||||||
<span>
|
<span>
|
||||||
{formatDate(member.birth_date, 'YYYY.MM.DD')}
|
{formatDate(member.birth_date, 'YYYY.MM.DD')}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
@ -6,23 +6,25 @@ import { fetchApi } from '@/api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PC 생일 페이지
|
* PC 생일 페이지
|
||||||
* @param {object} props
|
|
||||||
* @param {string} props.year - 연도
|
|
||||||
* @param {string} props.nameEn - 멤버 영문 이름 (소문자)
|
|
||||||
*/
|
*/
|
||||||
function PCBirthday({ year, nameEn }) {
|
function PCBirthday() {
|
||||||
// 멤버 정보 조회 (영문 이름으로)
|
const { memberName, year } = useParams();
|
||||||
|
|
||||||
|
// URL 디코딩
|
||||||
|
const decodedMemberName = decodeURIComponent(memberName || '');
|
||||||
|
|
||||||
|
// 멤버 정보 조회
|
||||||
const {
|
const {
|
||||||
data: member,
|
data: member,
|
||||||
isLoading: memberLoading,
|
isLoading: memberLoading,
|
||||||
error,
|
error,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['member', nameEn],
|
queryKey: ['member', decodedMemberName],
|
||||||
queryFn: () => fetchApi(`/members/${encodeURIComponent(nameEn)}`),
|
queryFn: () => fetchApi(`/members/${encodeURIComponent(decodedMemberName)}`),
|
||||||
enabled: !!nameEn,
|
enabled: !!decodedMemberName,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!nameEn || error) {
|
if (!decodedMemberName || error) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center">
|
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
@ -123,7 +125,7 @@ function PCBirthday({ year, nameEn }) {
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="text-6xl mb-4">🎁</div>
|
<div className="text-6xl mb-4">🎁</div>
|
||||||
<p className="text-gray-500 text-lg">
|
<p className="text-gray-500 text-lg">
|
||||||
{year}년 {member?.name} 생일카페 정보가 준비 중입니다
|
{year}년 {decodedMemberName} 생일카페 정보가 준비 중입니다
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-400 text-sm mt-2">생일카페 정보가 등록되면 이곳에 표시됩니다</p>
|
<p className="text-gray-400 text-sm mt-2">생일카페 정보가 등록되면 이곳에 표시됩니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,8 @@ import {
|
||||||
CategoryFilter,
|
CategoryFilter,
|
||||||
ScheduleCard,
|
ScheduleCard,
|
||||||
BirthdayCard,
|
BirthdayCard,
|
||||||
DebutCard,
|
|
||||||
} from '@/components/pc/public';
|
} from '@/components/pc/public';
|
||||||
import { DebutCelebrationDialog } from '@/components/common';
|
import { fireBirthdayConfetti } from '@/utils';
|
||||||
import { fireBirthdayConfetti, fireDebutConfetti } from '@/utils';
|
|
||||||
import { getSchedules, searchSchedules } from '@/api';
|
import { getSchedules, searchSchedules } from '@/api';
|
||||||
import { useScheduleStore } from '@/stores';
|
import { useScheduleStore } from '@/stores';
|
||||||
import { getTodayKST } from '@/utils';
|
import { getTodayKST } from '@/utils';
|
||||||
|
|
@ -55,8 +53,6 @@ function PCSchedule() {
|
||||||
const [suggestions, setSuggestions] = useState([]);
|
const [suggestions, setSuggestions] = useState([]);
|
||||||
const [originalSearchQuery, setOriginalSearchQuery] = useState('');
|
const [originalSearchQuery, setOriginalSearchQuery] = useState('');
|
||||||
const [showCategoryTooltip, setShowCategoryTooltip] = useState(false);
|
const [showCategoryTooltip, setShowCategoryTooltip] = useState(false);
|
||||||
const [showDebutDialog, setShowDebutDialog] = useState(false);
|
|
||||||
const [debutDialogInfo, setDebutDialogInfo] = useState({ isDebut: false, anniversaryYear: 0 });
|
|
||||||
|
|
||||||
const year = currentDate.getFullYear();
|
const year = currentDate.getFullYear();
|
||||||
const month = currentDate.getMonth();
|
const month = currentDate.getMonth();
|
||||||
|
|
@ -139,27 +135,6 @@ function PCSchedule() {
|
||||||
}
|
}
|
||||||
}, [schedules, loading]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event) => {
|
const handleClickOutside = (event) => {
|
||||||
|
|
@ -238,18 +213,37 @@ function PCSchedule() {
|
||||||
const currentYearMonth = `${year}-${String(month + 1).padStart(2, '0')}`;
|
const currentYearMonth = `${year}-${String(month + 1).padStart(2, '0')}`;
|
||||||
|
|
||||||
const filteredSchedules = useMemo(() => {
|
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 (isSearchMode) {
|
||||||
if (!searchTerm) return [];
|
if (!searchTerm) return [];
|
||||||
if (selectedCategories.length === 0) return searchResults;
|
if (selectedCategories.length === 0) return sortWithBirthdayFirst(searchResults);
|
||||||
return searchResults.filter((s) => selectedCategories.includes(s.category_id));
|
return sortWithBirthdayFirst(searchResults.filter((s) => selectedCategories.includes(s.category_id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
return schedules.filter((s) => {
|
const filtered = schedules
|
||||||
const matchesDate = selectedDate ? s.date === selectedDate : s.date?.startsWith(currentYearMonth);
|
.filter((s) => {
|
||||||
const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(s.category_id);
|
const matchesDate = selectedDate ? s.date === selectedDate : s.date?.startsWith(currentYearMonth);
|
||||||
return matchesDate && matchesCategory;
|
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]);
|
}, [schedules, selectedDate, currentYearMonth, selectedCategories, isSearchMode, searchTerm, searchResults]);
|
||||||
|
|
||||||
// 가상 스크롤
|
// 가상 스크롤
|
||||||
|
|
@ -262,17 +256,15 @@ function PCSchedule() {
|
||||||
|
|
||||||
// 일정 클릭 핸들러
|
// 일정 클릭 핸들러
|
||||||
const handleScheduleClick = (schedule) => {
|
const handleScheduleClick = (schedule) => {
|
||||||
// 생일, 데뷔, 주년 등 특수 일정
|
if (schedule.is_birthday || String(schedule.id).startsWith('birthday-')) {
|
||||||
if (schedule.is_birthday || schedule.is_debut || schedule.is_anniversary) {
|
const scheduleYear = new Date(schedule.date).getFullYear();
|
||||||
navigate(`/schedule/${schedule.id}`);
|
navigate(`/birthday/${encodeURIComponent(schedule.member_names)}/${scheduleYear}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 유튜브(2), X(3), 콘서트(6) 카테고리
|
|
||||||
if ([2, 3, 6].includes(schedule.category_id)) {
|
if ([2, 3, 6].includes(schedule.category_id)) {
|
||||||
navigate(`/schedule/${schedule.id}`);
|
navigate(`/schedule/${schedule.id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 소스 URL이 있으면 외부 링크로
|
|
||||||
if (!schedule.description && schedule.source?.url) {
|
if (!schedule.description && schedule.source?.url) {
|
||||||
window.open(schedule.source.url, '_blank');
|
window.open(schedule.source.url, '_blank');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -543,8 +535,6 @@ function PCSchedule() {
|
||||||
<div className={virtualItem.index < filteredSchedules.length - 1 ? 'pb-4' : ''}>
|
<div className={virtualItem.index < filteredSchedules.length - 1 ? 'pb-4' : ''}>
|
||||||
{schedule.is_birthday ? (
|
{schedule.is_birthday ? (
|
||||||
<BirthdayCard schedule={schedule} showYear onClick={() => handleScheduleClick(schedule)} />
|
<BirthdayCard schedule={schedule} showYear onClick={() => handleScheduleClick(schedule)} />
|
||||||
) : schedule.is_debut || schedule.is_anniversary ? (
|
|
||||||
<DebutCard schedule={schedule} showYear />
|
|
||||||
) : (
|
) : (
|
||||||
<ScheduleCard schedule={schedule} showYear onClick={() => handleScheduleClick(schedule)} />
|
<ScheduleCard schedule={schedule} showYear onClick={() => handleScheduleClick(schedule)} />
|
||||||
)}
|
)}
|
||||||
|
|
@ -574,8 +564,6 @@ function PCSchedule() {
|
||||||
>
|
>
|
||||||
{schedule.is_birthday ? (
|
{schedule.is_birthday ? (
|
||||||
<BirthdayCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
|
<BirthdayCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
|
||||||
) : schedule.is_debut || schedule.is_anniversary ? (
|
|
||||||
<DebutCard schedule={schedule} />
|
|
||||||
) : (
|
) : (
|
||||||
<ScheduleCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
|
<ScheduleCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
|
||||||
)}
|
)}
|
||||||
|
|
@ -599,14 +587,6 @@ function PCSchedule() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 데뷔/주년 축하 다이얼로그 */}
|
|
||||||
<DebutCelebrationDialog
|
|
||||||
isOpen={showDebutDialog}
|
|
||||||
onClose={() => setShowDebutDialog(false)}
|
|
||||||
isDebut={debutDialogInfo.isDebut}
|
|
||||||
anniversaryYear={debutDialogInfo.anniversaryYear}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,34 +6,6 @@ import { getSchedule } from '@/api';
|
||||||
|
|
||||||
// 섹션 컴포넌트들
|
// 섹션 컴포넌트들
|
||||||
import { YoutubeSection, XSection, DefaultSection, decodeHtmlEntities } from './sections';
|
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 일정 상세 페이지
|
* PC 일정 상세 페이지
|
||||||
|
|
@ -41,12 +13,6 @@ function parseSpecialId(id) {
|
||||||
function PCScheduleDetail() {
|
function PCScheduleDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
||||||
// 특수 일정 ID 체크
|
|
||||||
const specialId = parseSpecialId(id);
|
|
||||||
if (specialId?.type === 'birthday') {
|
|
||||||
return <Birthday year={specialId.year} nameEn={specialId.nameEn} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: schedule,
|
data: schedule,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import Members from '@/pages/mobile/members/Members';
|
||||||
import MembersPreview from '@/pages/mobile/members/MembersPreview';
|
import MembersPreview from '@/pages/mobile/members/MembersPreview';
|
||||||
import Schedule from '@/pages/mobile/schedule/Schedule';
|
import Schedule from '@/pages/mobile/schedule/Schedule';
|
||||||
import ScheduleDetail from '@/pages/mobile/schedule/ScheduleDetail';
|
import ScheduleDetail from '@/pages/mobile/schedule/ScheduleDetail';
|
||||||
|
import Birthday from '@/pages/mobile/schedule/Birthday';
|
||||||
import Album from '@/pages/mobile/album/Album';
|
import Album from '@/pages/mobile/album/Album';
|
||||||
import AlbumDetail from '@/pages/mobile/album/AlbumDetail';
|
import AlbumDetail from '@/pages/mobile/album/AlbumDetail';
|
||||||
import TrackDetail from '@/pages/mobile/album/TrackDetail';
|
import TrackDetail from '@/pages/mobile/album/TrackDetail';
|
||||||
|
|
@ -54,6 +55,7 @@ export default function MobileRoutes() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/schedule/:id" element={<ScheduleDetail />} />
|
<Route path="/schedule/:id" element={<ScheduleDetail />} />
|
||||||
|
<Route path="/birthday/:memberName/:year" element={<Birthday />} />
|
||||||
<Route
|
<Route
|
||||||
path="/album"
|
path="/album"
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import Home from '@/pages/pc/public/home/Home';
|
||||||
import Members from '@/pages/pc/public/members/Members';
|
import Members from '@/pages/pc/public/members/Members';
|
||||||
import Schedule from '@/pages/pc/public/schedule/Schedule';
|
import Schedule from '@/pages/pc/public/schedule/Schedule';
|
||||||
import ScheduleDetail from '@/pages/pc/public/schedule/ScheduleDetail';
|
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 Album from '@/pages/pc/public/album/Album';
|
||||||
import AlbumDetail from '@/pages/pc/public/album/AlbumDetail';
|
import AlbumDetail from '@/pages/pc/public/album/AlbumDetail';
|
||||||
import TrackDetail from '@/pages/pc/public/album/TrackDetail';
|
import TrackDetail from '@/pages/pc/public/album/TrackDetail';
|
||||||
|
|
@ -29,6 +30,7 @@ export default function PublicRoutes() {
|
||||||
<Route path="/members" element={<Members />} />
|
<Route path="/members" element={<Members />} />
|
||||||
<Route path="/schedule" element={<Schedule />} />
|
<Route path="/schedule" element={<Schedule />} />
|
||||||
<Route path="/schedule/:id" element={<ScheduleDetail />} />
|
<Route path="/schedule/:id" element={<ScheduleDetail />} />
|
||||||
|
<Route path="/birthday/:memberName/:year" element={<Birthday />} />
|
||||||
<Route path="/album" element={<Album />} />
|
<Route path="/album" element={<Album />} />
|
||||||
<Route path="/album/:name" element={<AlbumDetail />} />
|
<Route path="/album/:name" element={<AlbumDetail />} />
|
||||||
<Route path="/album/:name/track/:trackTitle" element={<TrackDetail />} />
|
<Route path="/album/:name/track/:trackTitle" element={<TrackDetail />} />
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,5 @@
|
||||||
import confetti from 'canvas-confetti';
|
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 공용
|
* PC/Mobile 공용
|
||||||
|
|
|
||||||
|
|
@ -48,4 +48,4 @@ export {
|
||||||
} from './schedule';
|
} from './schedule';
|
||||||
|
|
||||||
// 애니메이션 관련
|
// 애니메이션 관련
|
||||||
export { fireBirthdayConfetti, fireDebutConfetti } from './confetti';
|
export { fireBirthdayConfetti } from './confetti';
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue