Compare commits

...

12 commits

Author SHA1 Message Date
83c955f8a9 refactor: Meilisearch 봇을 단순 일일 동기화 방식으로 변경
- Watchtower 제외 라벨 추가하여 자동 업데이트 방지
- 버전 체크 방식 제거, 매일 12시 전체 동기화로 변경
- 봇 관리 UI를 다른 봇들과 동일하게 통일 (버전 → 업데이트 간격)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:59:18 +09:00
dc216a0f98 docs: API 문서 업데이트
- 멤버 API: 영문명 조회 지원 추가
- 일정 API: 특수 일정 ID 형식 설명 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 20:39:39 +09:00
f86e7d1b33 fix: 멤버 페이지 생일 아이콘을 Calendar에서 Cake로 변경
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:39:54 +09:00
f97c925fba refactor: 생일 페이지 라우트를 /schedule/:id 형식으로 변경
- /birthday/:memberName/:year → /schedule/birthday-{year}-{nameEn}
- ScheduleDetail에서 특수 ID(birthday, debut, anniversary) 감지
- Birthday 컴포넌트가 props로 year, nameEn 받도록 변경
- 멤버 API가 영문명으로도 조회 가능하도록 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:15:04 +09:00
e1ee0b47a0 fix: 특수 일정 ID에 연도 포함
- 생일: birthday-{year}-{name_en} (예: birthday-2025-saerom)
- 데뷔: debut-{year} (예: debut-2018)
- 주년: anniversary-{year} (예: anniversary-2026)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:07:39 +09:00
f8c73c5a0a docs: Meilisearch 동기화 정보 업데이트
- description 필드 제거 (schedules 테이블에 없음)
- 실시간 동기화 정보 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 11:27:31 +09:00
2fec6c552d feat: 봇 일정 추가 시 Meilisearch 실시간 동기화
- syncScheduleById 함수 추가: 개별 일정 동기화
- YouTube 봇: 영상 추가 시 Meilisearch 동기화
- X 봇: 트윗/유튜브 링크 추가 시 Meilisearch 동기화
- description 컬럼 제거 (schedules 테이블에 없음)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 11:26:32 +09:00
ea9922de00 refactor: 일정 정렬 로직을 백엔드로 이동
- 백엔드에서 특수 일정(기념일, 생일) 우선 정렬
- 프론트엔드의 중복 정렬 로직 제거
- 정렬 순서: 날짜 > 특수 일정 > 시간

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 18:30:51 +09:00
5b9d93b37f feat: 데뷔/주년 기념일 카드 및 축하 다이얼로그 추가
- DebutCard 컴포넌트 추가 (PC/모바일)
- DebutCelebrationDialog 축하 다이얼로그 추가
- Fromis9Logo SVG 컴포넌트 추가
- 기념일 카테고리 추가 (ID: 9)
- 데뷔일(2018.01.24) 및 주년 일정 자동 생성
- 폭죽 효과 추가 (fireDebutConfetti)
- 카테고리 정보 DB에서 동적 조회하도록 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:04:29 +09:00
a7bc2e9800 곡 상세 페이지 트랙 변경 시 스크롤/애니메이션 개선
- ScrollToTop: PC 레이아웃의 main 요소 스크롤 초기화 추가
- TrackDetail: key prop으로 트랙 변경 시 컴포넌트 리마운트
- TrackDetail: main 요소 스크롤 초기화 (PC는 main에서 스크롤)
- 수록곡 선택 시 Link 대신 button + navigate 사용

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 12:03:33 +09:00
eae56df146 곡 상세 페이지에서 다른 곡 선택 시 히스토리 교체
- 수록곡 목록에서 다른 곡 클릭 시 replace 옵션 사용
- 뒤로가기 시 앨범 상세 페이지로 바로 이동

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 10:56:47 +09:00
5415893f9d 트랙 영상 타입 구분 기능 추가
- DB: music_video_url을 video_url로 변경, video_type 컬럼 추가
- 백엔드: insertTracks에서 video_url, video_type 처리
- 관리자: 영상 타입 선택 드롭다운 추가 (뮤직비디오/스페셜 영상)
- CustomSelect: {value, label} 객체 옵션 및 size prop 지원
- 트랙 상세: video_type에 따른 라벨 동적 표시

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 10:56:12 +09:00
39 changed files with 894 additions and 317 deletions

View file

@ -3,7 +3,7 @@ export default [
id: 'meilisearch-sync', id: 'meilisearch-sync',
type: 'meilisearch', type: 'meilisearch',
name: 'Meilisearch 동기화', name: 'Meilisearch 동기화',
cron: '0 4 * * *', // 4시부터 5분간 버전 체크, 변경 시 동기화 cron: '0 12 * * *', // 매일 12시 전체 동기화
enabled: true, enabled: true,
}, },
{ {

View file

@ -3,6 +3,14 @@ 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,
}; };
// 필수 환경변수 검증 // 필수 환경변수 검증

View file

@ -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 { syncWithRetry, getVersion } from '../services/meilisearch/index.js'; import { syncAllSchedules } 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,121 +48,13 @@ 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 syncWithRetry(fastify.meilisearch, fastify.db); const count = await syncAllSchedules(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}`);
}
}
/** /**
* 동기화 결과 처리 (중복 코드 제거) * 동기화 결과 처리 (중복 코드 제거)
*/ */
@ -199,12 +91,6 @@ 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}`);

View file

@ -62,14 +62,17 @@ 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 표현식에서 간격 추출 (분 단위) // cron 표현식에서 간격 추출 (분 단위, 일일 스케줄은 1440분)
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분
} }
const botData = { result.push({
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,
@ -81,15 +84,7 @@ 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;

View file

@ -181,12 +181,13 @@ 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.music_video_url || null, track.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, music_video_url) (album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, video_url, video_type)
VALUES ?`, VALUES ?`,
[values] [values]
); );

View file

@ -178,7 +178,7 @@ function formatScheduleResponse(hit) {
} }
/** /**
* 일정 추가/업데이트 * 일정 추가/업데이트 (데이터 직접 전달)
*/ */
export async function addOrUpdateSchedule(meilisearch, schedule) { export async function addOrUpdateSchedule(meilisearch, schedule) {
try { try {
@ -187,7 +187,6 @@ 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,
@ -204,6 +203,59 @@ export async function addOrUpdateSchedule(meilisearch, schedule) {
} }
} }
/**
* 일정 ID로 DB에서 조회 Meilisearch에 동기화
*/
export async function syncScheduleById(meilisearch, db, scheduleId) {
try {
const [rows] = await db.query(`
SELECT
s.id,
s.title,
s.date,
s.time,
s.category_id,
c.name as category_name,
c.color as category_color,
sy.channel_name as source_name,
GROUP_CONCAT(DISTINCT m.name ORDER BY m.id SEPARATOR ',') as member_names
FROM schedules s
LEFT JOIN schedule_categories c ON s.category_id = c.id
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
LEFT JOIN members m ON sm.member_id = m.id AND m.is_former = 0
WHERE s.id = ?
GROUP BY s.id
`, [scheduleId]);
if (rows.length === 0) {
logger.warn(`일정을 찾을 수 없음: ${scheduleId}`);
return false;
}
const s = rows[0];
const document = {
id: s.id,
title: s.title,
date: s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date,
time: s.time || '',
category_id: s.category_id,
category_name: s.category_name || '',
category_color: s.category_color || '',
source_name: s.source_name || '',
member_names: s.member_names || '',
};
const index = meilisearch.index(INDEX_NAME);
await index.addDocuments([document]);
logger.info(`일정 동기화: ${scheduleId}`);
return true;
} catch (err) {
logger.error(`일정 동기화 오류 (${scheduleId}): ${err.message}`);
return false;
}
}
/** /**
* 일정 삭제 * 일정 삭제
*/ */
@ -233,7 +285,6 @@ export async function syncAllSchedules(meilisearch, db) {
SELECT 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,
@ -255,7 +306,6 @@ 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,
@ -310,7 +360,7 @@ async function recreateIndex(meilisearch) {
// 설정 복원 // 설정 복원
await index.updateSearchableAttributes([ await index.updateSearchableAttributes([
'title', 'member_names', 'description', 'source_name', 'category_name', 'title', 'member_names', 'source_name', 'category_name',
]); ]);
await index.updateFilterableAttributes(['category_id', 'date']); await index.updateFilterableAttributes(['category_id', 'date']);
await index.updateSortableAttributes(['date', 'time']); await index.updateSortableAttributes(['date', 'time']);

View file

@ -62,8 +62,9 @@ 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) {
@ -75,8 +76,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 = ? WHERE m.name = ? OR LOWER(m.name_en) = LOWER(?)
`, [name]); `, [name, name]);
if (members.length === 0) { if (members.length === 0) {
return null; return null;

View file

@ -2,7 +2,7 @@
* 스케줄 서비스 * 스케줄 서비스
* 스케줄 관련 비즈니스 로직 * 스케줄 관련 비즈니스 로직
*/ */
import config, { CATEGORY_IDS } from '../config/index.js'; import config, { CATEGORY_IDS, DEBUT_DATE } from '../config/index.js';
import { getOrSet, cacheKeys, TTL } from '../utils/cache.js'; import { getOrSet, cacheKeys, TTL } from '../utils/cache.js';
// ==================== 공통 포맷팅 함수 ==================== // ==================== 공통 포맷팅 함수 ====================
@ -308,6 +308,16 @@ export async function getMonthlySchedules(db, year, month) {
// 일정 포맷팅 // 일정 포맷팅
const schedules = formatSchedules(rawSchedules, memberMap); const 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,
@ -324,15 +334,11 @@ 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-${member.id}`, id: `birthday-${year}-${member.name_en.toLowerCase()}`,
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: { category: categoryMap[CATEGORY_IDS.BIRTHDAY],
id: CATEGORY_IDS.BIRTHDAY,
name: '생일',
color: '#f472b6',
},
source: null, source: null,
members: [member.name], members: [member.name],
is_birthday: true, is_birthday: true,
@ -340,8 +346,70 @@ export async function getMonthlySchedules(db, year, month) {
}); });
} }
// 날짜순 정렬 // 데뷔/주년 추가 (1월인 경우)
schedules.sort((a, b) => a.date.localeCompare(b.date)); if (month === DEBUT_DATE.month) {
const debutYear = DEBUT_DATE.year;
const anniversaryYear = year - debutYear;
if (year >= debutYear) {
const debutDate = new Date(year, DEBUT_DATE.month - 1, DEBUT_DATE.day);
if (year === debutYear) {
// 데뷔 당일
schedules.push({
id: `debut-${year}`,
title: '프로미스나인 데뷔',
date: debutDate.toISOString().split('T')[0],
time: null,
category: categoryMap[CATEGORY_IDS.DEBUT],
source: null,
members: ['프로미스나인'],
is_debut: true,
});
} else {
// N주년
schedules.push({
id: `anniversary-${year}`,
title: `프로미스나인 데뷔 ${anniversaryYear}주년`,
date: debutDate.toISOString().split('T')[0],
time: null,
category: categoryMap[CATEGORY_IDS.DEBUT],
source: null,
members: ['프로미스나인'],
is_anniversary: true,
anniversary_year: anniversaryYear,
});
}
}
}
// 날짜순 정렬 (같은 날짜 내에서 특수 일정을 먼저 배치)
schedules.sort((a, b) => {
// 날짜 비교
const dateCompare = a.date.localeCompare(b.date);
if (dateCompare !== 0) return dateCompare;
// 같은 날짜면 특수 일정(생일, 기념일)을 먼저
const aSpecial = a.is_birthday || a.is_debut || a.is_anniversary;
const bSpecial = b.is_birthday || b.is_debut || b.is_anniversary;
if (aSpecial && !bSpecial) return -1;
if (!aSpecial && bSpecial) return 1;
// 둘 다 특수 일정이면 기념일 > 생일 순서
if (aSpecial && bSpecial) {
const aDebut = a.is_debut || a.is_anniversary;
const bDebut = b.is_debut || b.is_anniversary;
if (aDebut && !bDebut) return -1;
if (!aDebut && bDebut) return 1;
}
// 시간순 정렬
if (a.time && b.time) return a.time.localeCompare(b.time);
if (a.time) return -1;
if (b.time) return 1;
return 0;
});
return { schedules }; return { schedules };
} }

View file

@ -4,6 +4,7 @@ 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;
@ -141,8 +142,12 @@ async function xBotPlugin(fastify, opts) {
// 관리 중인 채널이면 스킵 // 관리 중인 채널이면 스킵
if (managedChannels.includes(video.channelId)) continue; if (managedChannels.includes(video.channelId)) continue;
const saved = await saveYoutubeFromTweet(video); const scheduleId = await saveYoutubeFromTweet(video);
if (saved) addedCount++; if (scheduleId) {
// 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}`);
} }
@ -166,6 +171,8 @@ 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);
@ -187,6 +194,8 @@ 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);
} }

View file

@ -3,6 +3,7 @@ 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:';
@ -126,6 +127,8 @@ 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++;
} }
} }
@ -144,6 +147,8 @@ 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++;
} }
} }

View file

@ -29,6 +29,8 @@ 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:

View file

@ -19,9 +19,16 @@ Base URL: `/api`
### GET /members ### GET /members
멤버 목록 조회 멤버 목록 조회
### GET /members/:id ### GET /members/:name
멤버 상세 조회 멤버 상세 조회
**Parameters:**
- `name` - 멤버 이름 (한글 또는 영문, 대소문자 무관)
**예시:**
- `/members/박지원` - 한글명으로 조회
- `/members/jiwon` - 영문명으로 조회
--- ---
## 앨범 ## 앨범
@ -66,6 +73,11 @@ Base URL: `/api`
] ]
} }
``` ```
**특수 일정 ID 형식:**
- 생일: `birthday-{year}-{nameEn}` (예: `birthday-2026-jiwon`)
- 데뷔: `debut-{year}` (예: `debut-2018`)
- 주년: `anniversary-{year}` (예: `anniversary-2026`)
`time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환 `time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환
``` ```

View file

@ -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,7 +107,9 @@ 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 공개 컴포넌트
@ -119,6 +121,7 @@ fromis_9/
│ │ │ │ │ ├── Calendar.jsx │ │ │ │ │ ├── Calendar.jsx
│ │ │ │ │ ├── ScheduleCard.jsx │ │ │ │ │ ├── ScheduleCard.jsx
│ │ │ │ │ ├── BirthdayCard.jsx │ │ │ │ │ ├── BirthdayCard.jsx
│ │ │ │ │ ├── DebutCard.jsx # 데뷔/주년 카드
│ │ │ │ │ └── CategoryFilter.jsx │ │ │ │ │ └── CategoryFilter.jsx
│ │ │ │ │ │ │ │ │ │
│ │ │ │ └── admin/ # PC 관리자 컴포넌트 │ │ │ │ └── admin/ # PC 관리자 컴포넌트
@ -159,7 +162,8 @@ fromis_9/
│ │ │ ├── ScheduleCard.jsx │ │ │ ├── ScheduleCard.jsx
│ │ │ ├── ScheduleListCard.jsx │ │ │ ├── ScheduleListCard.jsx
│ │ │ ├── ScheduleSearchCard.jsx │ │ │ ├── ScheduleSearchCard.jsx
│ │ │ └── BirthdayCard.jsx │ │ │ ├── BirthdayCard.jsx
│ │ │ └── DebutCard.jsx # 데뷔/주년 카드
│ │ │ │ │ │
│ │ ├── pages/ │ │ ├── pages/
│ │ │ ├── pc/ │ │ │ ├── pc/
@ -321,6 +325,7 @@ fromis_9/
### 검색 인덱스 (Meilisearch) ### 검색 인덱스 (Meilisearch)
- `schedules` - 일정 검색용 인덱스 - `schedules` - 일정 검색용 인덱스
- 검색 필드: title, member_names, description, source_name, category_name - 검색 필드: title, member_names, source_name, category_name
- 필터: category_id, date - 필터: category_id, date
- 정렬: date, time - 정렬: date, time
- 동기화: 봇/수동 일정 추가/수정/삭제 시 실시간 동기화

View file

@ -0,0 +1,107 @@
import { memo, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X } from 'lucide-react';
import Fromis9Logo from './Fromis9Logo';
/**
* 데뷔/주년 축하 다이얼로그
* @param {boolean} isOpen - 다이얼로그 표시 여부
* @param {function} onClose - 닫기 핸들러
* @param {boolean} isDebut - 데뷔일 여부 (false면 주년)
* @param {number} anniversaryYear - 주년 (isDebut이 false일 )
*/
const DebutCelebrationDialog = memo(function DebutCelebrationDialog({
isOpen,
onClose,
isDebut = false,
anniversaryYear = 0,
}) {
// ESC
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [isOpen, onClose]);
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] flex items-center justify-center p-4"
onClick={onClose}
>
{/* 배경 오버레이 */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
{/* 다이얼로그 */}
<motion.div
initial={{ scale: 0.8, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.8, opacity: 0, y: 20 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
onClick={(e) => e.stopPropagation()}
className="relative w-full max-w-md overflow-hidden rounded-3xl bg-gradient-to-br from-[#7a99c8] via-[#98b0d8] to-[#b8c8e8] shadow-2xl"
>
{/* 닫기 버튼 */}
<button
onClick={onClose}
className="absolute top-4 right-4 z-10 p-2 rounded-full bg-white/20 hover:bg-white/30 transition-colors"
>
<X size={20} className="text-white" />
</button>
{/* 배경 장식 */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-4 left-8 text-yellow-200 text-2xl animate-pulse"></div>
<div className="absolute top-12 right-16 text-yellow-200/80 text-xl animate-pulse delay-100"></div>
<div className="absolute bottom-20 left-12 text-yellow-200/60 text-3xl animate-pulse delay-200"></div>
<div className="absolute bottom-12 right-8 text-yellow-200 text-xl animate-pulse delay-150"></div>
<div className="absolute top-1/3 left-4 text-white/40 text-lg animate-pulse delay-300"></div>
<div className="absolute top-1/2 right-4 text-white/30 text-sm animate-pulse delay-250"></div>
<div className="absolute -top-16 -left-16 w-48 h-48 bg-white/10 rounded-full" />
<div className="absolute -bottom-20 -right-20 w-56 h-56 bg-white/10 rounded-full" />
</div>
{/* 컨텐츠 */}
<div className="relative flex flex-col items-center py-12 px-8 text-center">
{/* 로고/숫자 */}
<div className="w-28 h-28 rounded-full bg-white/30 backdrop-blur-sm flex items-center justify-center shadow-lg border-4 border-white/30 mb-6">
{isDebut ? (
<Fromis9Logo size={56} fill="white" className="drop-shadow-lg" />
) : (
<div className="text-center" style={{ textShadow: '0 2px 4px rgba(0,0,0,0.2)' }}>
<div className="text-white font-black text-4xl leading-none">{anniversaryYear}</div>
<div className="text-white/80 text-xs font-bold tracking-wider">YEARS</div>
</div>
)}
</div>
{/* 텍스트 */}
<h2
className="text-white font-bold text-2xl mb-2"
style={{ textShadow: '0 2px 4px rgba(0,0,0,0.2)' }}
>
{isDebut ? '프로미스나인 데뷔' : `프로미스나인 데뷔 ${anniversaryYear}주년`}
</h2>
<p
className="text-white/80 text-base"
style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}
>
2018. 01. 24
</p>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
});
export default DebutCelebrationDialog;

View file

@ -0,0 +1,22 @@
/**
* fromis_9 로고 컴포넌트
* @param {string} className - 추가 클래스
* @param {string} fill - 채우기 색상 (기본: currentColor)
* @param {number} size - 크기 (기본: 24)
*/
function Fromis9Logo({ className = '', fill = 'currentColor', size = 24 }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1000 1000"
width={size}
height={size}
className={className}
fill={fill}
>
<path d="m330.8,82.15c48.81-14.33,102.1-13.34,150.34,2.79,49.01,16.79,93.21,49.01,122.21,92.14,36.07-5.57,73.13-9.03,109.3-1.96,80.03,13.24,152.43,65.03,191.02,136.34-29.07,16.59-59.76,30.16-89.46,45.59-23.99-32.72-54.75-61.02-91.31-79.07-24.55-12.04-52.56-14.87-79.5-11.88,9.39,49.94,5.71,102.69-14.63,149.58-16.99,40.02-45.23,74.62-79.47,101.17,75.29.56,150.57-.23,225.83.4.63,35.84.5,71.67.17,107.51-98.02.1-196,.23-294.02-.07-1.36,44.63-2.19,90.55-18.61,132.72-14.6,37.33-32.38,73.99-58.3,104.88-19.24,22.86-37.56,46.95-61.12,65.63-19.74-29.73-35.6-61.98-53.85-92.71,11.51-8.89,24.52-15.89,34.74-26.45,37.03-36.8,54.62-88.76,60.65-139.66,3.09-34.47,2.26-69.18,4.31-103.72-52.89-3.25-104.98-22.73-145.53-57.14-54.12-44.63-87.53-113.41-88.49-183.62-1.26-45.36,10.25-90.95,33.21-130.1,30.96-53.72,83.12-94.7,142.51-112.38m-33.08,116.33c-34.31,29.46-55.45,74.13-55.01,119.48-.5,33.65,10.62,67.16,30.43,94.27,27.27,37.66,71.87,62.88,118.49,64.9,1.66-34.51.23-69.61,9.79-103.19,16.62-65.93,59.49-124.3,116.43-161.19l3.55.83c-20.97-22.83-47.91-40.45-78.01-48.38-50.07-14.3-106.71-1.16-145.66,33.28m192.78,252.57c47.68-32.65,75.85-91.25,69.71-148.95-45.56,34.44-73.46,91.65-69.71,148.95Z" />
</svg>
);
}
export default Fromis9Logo;

View file

@ -16,6 +16,12 @@ 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;

View file

@ -7,3 +7,5 @@ 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';

View file

@ -0,0 +1,99 @@
import { memo } from 'react';
import { motion } from 'framer-motion';
import { Clover } from 'lucide-react';
import { dayjs } from '@/utils';
import { Fromis9Logo } from '@/components/common';
/**
* Mobile용 데뷔/주년 카드 컴포넌트
* @param {Object} schedule - 일정 데이터
* @param {boolean} showYear - 년도 표시 여부
* @param {number} delay - 애니메이션 딜레이 ()
*/
const DebutCard = memo(function DebutCard({ schedule, showYear = false, delay = 0 }) {
const scheduleDate = dayjs(schedule.date);
const formatted = {
year: scheduleDate.year(),
month: scheduleDate.month() + 1,
day: scheduleDate.date(),
};
const isDebut = schedule.is_debut;
const anniversaryYear = schedule.anniversary_year;
const CardContent = (
<div className="relative overflow-hidden bg-gradient-to-br from-[#7a99c8] via-[#98b0d8] to-[#b8c8e8] rounded-xl shadow-md">
{/* 배경 별 장식 */}
<div className="absolute inset-0 overflow-hidden">
{/* 반짝이는 별들 */}
<div className="absolute top-2 right-4 text-white/60 text-xs animate-pulse"></div>
<div className="absolute top-4 right-12 text-white/40 text-[10px] animate-pulse delay-100"></div>
<div className="absolute bottom-3 right-6 text-white/50 text-sm animate-pulse delay-200"></div>
<div className="absolute top-1/2 right-1/4 text-white/30 text-xs animate-pulse delay-300"></div>
<div className="absolute bottom-4 left-4 text-white/40 text-[10px] animate-pulse delay-150"></div>
{/* 원형 장식 */}
<div className="absolute -top-6 -left-6 w-20 h-20 bg-white/10 rounded-full" />
<div className="absolute -bottom-8 -right-8 w-24 h-24 bg-white/10 rounded-full" />
</div>
<div className="relative flex items-center p-4 gap-3">
{/* 아이콘 영역 */}
<div className="flex-shrink-0">
<div className="w-14 h-14 rounded-full bg-white/30 backdrop-blur-sm flex items-center justify-center shadow-inner">
{isDebut ? (
<div className="text-center" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}>
<div className="text-white font-black text-[11px] tracking-wider">DEBUT</div>
</div>
) : (
<div className="text-center" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}>
<div className="text-white font-black text-xl leading-none">{anniversaryYear}</div>
<div className="text-white/80 text-[8px] font-bold">YEARS</div>
</div>
)}
</div>
</div>
{/* 내용 */}
<div className="flex-1 text-white min-w-0">
<h3
className="font-bold text-base tracking-wide truncate flex items-center gap-1.5"
style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}
>
{isDebut ? (
<Fromis9Logo size={18} fill="white" className="flex-shrink-0 drop-shadow" />
) : (
<Clover size={18} className="flex-shrink-0 drop-shadow" strokeWidth={2.5} />
)}
{schedule.title}
</h3>
</div>
{/* 날짜 뱃지 */}
{showYear && (
<div className="flex-shrink-0 bg-white/25 backdrop-blur-sm rounded-lg px-3 py-1.5 text-center">
<div className="text-white/80 text-[10px] font-medium">{formatted.year}</div>
<div className="text-white/80 text-[10px] font-medium">{formatted.month}</div>
<div className="text-white text-xl font-bold">{formatted.day}</div>
</div>
)}
</div>
</div>
);
// delay motion
if (delay > 0) {
return (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay, type: 'spring', stiffness: 300, damping: 30 }}
>
{CardContent}
</motion.div>
);
}
return CardContent;
});
export default DebutCard;

View file

@ -3,3 +3,4 @@ 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';

View file

@ -4,6 +4,13 @@
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
@ -120,16 +127,26 @@ const TrackItem = memo(function TrackItem({ track, index, onUpdate, onRemove })
</div> </div>
</div> </div>
{/* MV URL */} {/* 비디오 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>
<input <div className="flex gap-2">
type="text" <input
value={track.music_video_url || ''} type="text"
onChange={(e) => onUpdate(index, 'music_video_url', e.target.value)} value={track.video_url || ''}
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" onChange={(e) => onUpdate(index, 'video_url', e.target.value)}
placeholder="https://youtube.com/watch?v=..." className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/> placeholder="https://youtube.com/watch?v=..."
/>
<CustomSelect
value={track.video_type || ''}
onChange={(value) => onUpdate(index, 'video_type', value)}
options={VIDEO_TYPE_OPTIONS}
placeholder="선택"
size="sm"
className="w-32"
/>
</div>
</div> </div>
{/* 가사 */} {/* 가사 */}

View file

@ -142,47 +142,22 @@ 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">
{bot.type === 'meilisearch' ? ( <div className="p-3 text-center">
<> <div className="text-lg font-bold text-gray-900">{bot.schedules_added || 0}</div>
<div className="p-3 text-center"> <div className="text-xs text-gray-400"> 추가</div>
<div className="text-lg font-bold text-gray-900"> </div>
{bot.last_added_count?.toLocaleString() || '-'} <div className="p-3 text-center">
</div> <div
<div className="text-xs text-gray-400">동기화 </div> className={`text-lg font-bold ${bot.last_added_count > 0 ? 'text-green-500' : 'text-gray-400'}`}
</div> >
<div className="p-3 text-center"> +{bot.last_added_count || 0}
<div className="text-lg font-bold text-gray-900"> </div>
{bot.last_sync_duration != null <div className="text-xs text-gray-400">마지막</div>
? `${(bot.last_sync_duration / 1000).toFixed(1)}` </div>
: '-'} <div className="p-3 text-center">
</div> <div className="text-lg font-bold text-gray-900">{formatInterval(bot.check_interval)}</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>
{/* 오류 메시지 */} {/* 오류 메시지 */}

View file

@ -9,10 +9,12 @@ 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 {string[]} props.options - 옵션 목록 * @param {Array<string|{value: string, label: string}>} props.options - 옵션 목록 (문자열 또는 {value, label} 객체)
* @param {string} props.placeholder - 플레이스홀더 * @param {string} props.placeholder - 플레이스홀더
* @param {string} props.className - 추가 클래스명
* @param {string} props.size - 크기 ('sm' | 'md')
*/ */
function CustomSelect({ value, onChange, options, placeholder }) { function CustomSelect({ value, onChange, options, placeholder, className = '', size = 'md' }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const ref = useRef(null); const ref = useRef(null);
@ -26,17 +28,29 @@ function CustomSelect({ value, onChange, options, placeholder }) {
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"> <div ref={ref} className={`relative ${className}`}>
<button <button
type="button" type="button"
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" className={`w-full ${sizeClasses} border border-gray-200 rounded-lg bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent`}
> >
<span className={value ? 'text-gray-900' : 'text-gray-400'}>{value || placeholder}</span> <span className={selectedLabel ? 'text-gray-900' : 'text-gray-400'}>
{selectedLabel || placeholder}
</span>
<ChevronDown <ChevronDown
size={18} size={size === 'sm' ? 16 : 18}
className={`text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} className={`text-gray-400 transition-transform flex-shrink-0 ml-2 ${isOpen ? 'rotate-180' : ''}`}
/> />
</button> </button>
@ -49,19 +63,19 @@ function CustomSelect({ value, onChange, options, placeholder }) {
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"
> >
{options.map((option) => ( {normalizedOptions.map((option) => (
<button <button
key={option} key={option.value}
type="button" type="button"
onClick={() => { onClick={() => {
onChange(option); onChange(option.value);
setIsOpen(false); setIsOpen(false);
}} }}
className={`w-full px-4 py-2.5 text-left hover:bg-gray-50 transition-colors ${ className={`w-full ${sizeClasses} text-left hover:bg-gray-50 transition-colors ${
value === option ? 'bg-primary/10 text-primary font-medium' : 'text-gray-700' value === option.value ? 'bg-primary/10 text-primary font-medium' : 'text-gray-700'
}`} }`}
> >
{option} {option.label}
</button> </button>
))} ))}
</motion.div> </motion.div>

View file

@ -0,0 +1,82 @@
import { memo } from 'react';
import { Clover } from 'lucide-react';
import { dayjs } from '@/utils';
import { Fromis9Logo } from '@/components/common';
/**
* PC용 데뷔/주년 카드 컴포넌트
*/
const DebutCard = memo(function DebutCard({ schedule, showYear = false }) {
const scheduleDate = dayjs(schedule.date);
const formatted = {
year: scheduleDate.year(),
month: scheduleDate.month() + 1,
day: scheduleDate.date(),
};
const isDebut = schedule.is_debut;
const anniversaryYear = schedule.anniversary_year;
return (
<div className="relative overflow-hidden bg-gradient-to-br from-[#7a99c8] via-[#98b0d8] to-[#b8c8e8] rounded-2xl shadow-lg">
{/* 배경 별 장식 */}
<div className="absolute inset-0 overflow-hidden">
{/* 반짝이는 별들 */}
<div className="absolute top-3 right-6 text-white/60 text-base animate-pulse"></div>
<div className="absolute top-6 right-16 text-white/40 text-sm animate-pulse delay-100"></div>
<div className="absolute bottom-4 right-10 text-white/50 text-lg animate-pulse delay-200"></div>
<div className="absolute top-1/2 right-1/3 text-white/30 text-sm animate-pulse delay-300"></div>
<div className="absolute bottom-6 left-6 text-white/40 text-sm animate-pulse delay-150"></div>
<div className="absolute top-4 left-1/4 text-white/30 text-xs animate-pulse delay-250"></div>
{/* 원형 장식 */}
<div className="absolute -top-8 -left-8 w-28 h-28 bg-white/10 rounded-full" />
<div className="absolute -bottom-10 -right-10 w-36 h-36 bg-white/10 rounded-full" />
<div className="absolute top-1/2 left-1/2 w-16 h-16 bg-white/5 rounded-full" />
</div>
<div className="relative flex items-center p-4 gap-4">
{/* 아이콘 영역 */}
<div className="flex-shrink-0">
<div className="w-20 h-20 rounded-full bg-white/30 backdrop-blur-sm flex items-center justify-center shadow-inner border-2 border-white/20">
{isDebut ? (
<div className="text-center" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}>
<div className="text-white font-black text-base tracking-wider">DEBUT</div>
</div>
) : (
<div className="text-center" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}>
<div className="text-white font-black text-3xl leading-none">{anniversaryYear}</div>
<div className="text-white/80 text-[10px] font-bold tracking-wider">YEARS</div>
</div>
)}
</div>
</div>
{/* 내용 */}
<div className="flex-1 text-white flex flex-col justify-center">
<h3
className="font-bold text-2xl tracking-wide flex items-center gap-2"
style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}
>
{isDebut ? (
<Fromis9Logo size={28} fill="white" className="flex-shrink-0 drop-shadow" />
) : (
<Clover size={28} className="flex-shrink-0 drop-shadow" strokeWidth={2.5} />
)}
{schedule.title}
</h3>
</div>
{/* 날짜 뱃지 */}
<div className="flex-shrink-0 bg-white/25 backdrop-blur-sm rounded-xl px-4 py-2 text-center">
{showYear && (
<div className="text-white/80 text-xs font-medium">{formatted.year}</div>
)}
<div className="text-white/80 text-xs font-medium">{formatted.month}</div>
<div className="text-white text-2xl font-bold">{formatted.day}</div>
</div>
</div>
</div>
);
});
export default DebutCard;

View file

@ -1,4 +1,5 @@
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';

View file

@ -1,4 +1,4 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect, useLayoutEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom'; import { 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,7 +37,13 @@ function MobileTrackDetail() {
enabled: !!albumName && !!trackTitle, enabled: !!albumName && !!trackTitle,
}); });
const youtubeVideoId = useMemo(() => getYoutubeVideoId(track?.music_video_url), [track?.music_video_url]); const youtubeVideoId = useMemo(() => getYoutubeVideoId(track?.video_url), [track?.video_url]);
const videoLabel = track?.video_type === 'special' ? '스페셜 영상' : '뮤직비디오';
//
useLayoutEffect(() => {
window.scrollTo({ top: 0, behavior: 'instant' });
}, [trackTitle]);
// //
const [showFullLyrics, setShowFullLyrics] = useState(false); const [showFullLyrics, setShowFullLyrics] = useState(false);
@ -92,7 +98,7 @@ function MobileTrackDetail() {
} }
return ( return (
<div className="pb-4"> <div key={trackTitle} className="pb-4">
{/* 트랙 정보 헤더 */} {/* 트랙 정보 헤더 */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@ -140,12 +146,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} 뮤직비디오`} title={`${track.title} ${videoLabel}`}
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

View file

@ -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 { Calendar, X, Instagram } from 'lucide-react'; import { Cake, 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">
<Calendar size={14} /> <Cake size={14} />
<span> <span>
{selectedMember.birth_date?.slice(0, 10).replaceAll('-', '.')} {selectedMember.birth_date?.slice(0, 10).replaceAll('-', '.')}
</span> </span>

View file

@ -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, Calendar, ChevronRight } from 'lucide-react'; import { Instagram, Cake, 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">
<Calendar size={16} className="text-white/70" /> <Cake size={16} className="text-white/70" />
<span className="text-sm">{member.birth_date?.slice(0, 10).replaceAll('-', '.')}</span> <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">
<Calendar size={16} /> <Cake 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">
<Calendar size={16} /> <Cake 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">

View file

@ -1,4 +1,4 @@
import { useParams, Link } from 'react-router-dom'; import { 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,25 +6,23 @@ import { fetchApi } from '@/api';
/** /**
* Mobile 생일 페이지 * Mobile 생일 페이지
* @param {object} props
* @param {string} props.year - 연도
* @param {string} props.nameEn - 멤버 영문 이름 (소문자)
*/ */
function MobileBirthday() { function MobileBirthday({ year, nameEn }) {
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', decodedMemberName], queryKey: ['member', nameEn],
queryFn: () => fetchApi(`/members/${encodeURIComponent(decodedMemberName)}`), queryFn: () => fetchApi(`/members/${encodeURIComponent(nameEn)}`),
enabled: !!decodedMemberName, enabled: !!nameEn,
}); });
if (!decodedMemberName || error) { if (!nameEn || 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">
@ -128,7 +126,7 @@ function MobileBirthday() {
<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} {decodedMemberName} 생일카페 정보가 준비 중입니다 {year} {member?.name} 생일카페 정보가 준비 중입니다
</p> </p>
<p className="text-gray-400 text-xs mt-1">생일카페 정보가 등록되면 이곳에 표시됩니다</p> <p className="text-gray-400 text-xs mt-1">생일카페 정보가 등록되면 이곳에 표시됩니다</p>
</div> </div>

View file

@ -15,8 +15,10 @@ 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 { fireBirthdayConfetti } from '@/utils'; import { DebutCelebrationDialog } from '@/components/common';
import { fireBirthdayConfetti, fireDebutConfetti } from '@/utils';
/** /**
* 모바일 일정 페이지 * 모바일 일정 페이지
@ -51,6 +53,8 @@ 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 = () => {
@ -198,6 +202,35 @@ 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);
@ -303,15 +336,8 @@ 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 // ( )
.filter((s) => s.date.split('T')[0] === dateStr) return schedules.filter((s) => s.date.split('T')[0] === dateStr);
.sort((a, b) => {
const aIsBirthday = a.is_birthday || String(a.id).startsWith('birthday-');
const bIsBirthday = b.is_birthday || String(b.id).startsWith('birthday-');
if (aIsBirthday && !bIsBirthday) return -1;
if (!aIsBirthday && bIsBirthday) return 1;
return 0;
});
}, [schedules, selectedDate]); }, [schedules, selectedDate]);
// //
@ -743,6 +769,7 @@ 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 (
@ -750,11 +777,17 @@ function MobileSchedule() {
key={schedule.id} key={schedule.id}
schedule={schedule} schedule={schedule}
delay={index * 0.05} delay={index * 0.05}
onClick={() => { onClick={() => navigate(`/schedule/${schedule.id}`)}
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}
/> />
); );
} }
@ -772,6 +805,14 @@ function MobileSchedule() {
)} )}
</div> </div>
</div> </div>
{/* 데뷔/주년 축하 다이얼로그 */}
<DebutCelebrationDialog
isOpen={showDebutDialog}
onClose={() => setShowDebutDialog(false)}
isDebut={debutDialogInfo.isDebut}
anniversaryYear={debutDialogInfo.anniversaryYear}
/>
</> </>
); );
} }

View file

@ -6,6 +6,34 @@ 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;
}
/** /**
* 전체화면 자동 가로 회전 (숏츠가 아닐 때만) * 전체화면 자동 가로 회전 (숏츠가 아닐 때만)
@ -393,6 +421,12 @@ 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');

View file

@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { useMemo, useLayoutEffect } 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,7 +37,16 @@ function PCTrackDetail() {
enabled: !!albumName && !!trackTitle, enabled: !!albumName && !!trackTitle,
}); });
const youtubeVideoId = useMemo(() => getYoutubeVideoId(track?.music_video_url), [track?.music_video_url]); const youtubeVideoId = useMemo(() => getYoutubeVideoId(track?.video_url), [track?.video_url]);
const videoLabel = track?.video_type === 'special' ? '스페셜 영상' : '뮤직비디오';
//
useLayoutEffect(() => {
const main = document.querySelector('main');
if (main) {
main.scrollTop = 0;
}
}, [trackTitle]);
if (loading) { if (loading) {
return ( return (
@ -60,7 +69,13 @@ function PCTrackDetail() {
} }
return ( return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }} className="py-12"> <motion.div
key={trackTitle}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="py-12"
>
<div className="max-w-7xl mx-auto px-6"> <div className="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">
@ -134,12 +149,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} 뮤직비디오`} title={`${track.title} ${videoLabel}`}
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
@ -217,10 +232,18 @@ function PCTrackDetail() {
{track.otherTracks?.map((t) => { {track.otherTracks?.map((t) => {
const isCurrent = t.title === track.title; const isCurrent = t.title === track.title;
return ( return (
<Link <button
key={t.id} key={t.id}
to={`/album/${encodeURIComponent(track.album?.title || albumName)}/track/${encodeURIComponent(t.title)}`} type="button"
className={`group flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all ${ onClick={() => {
if (!isCurrent) {
navigate(
`/album/${encodeURIComponent(track.album?.title || albumName)}/track/${encodeURIComponent(t.title)}`,
{ replace: true }
);
}
}}
className={`w-full group flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all ${
isCurrent ? 'bg-primary text-white' : 'hover:bg-gray-50' isCurrent ? 'bg-primary text-white' : 'hover:bg-gray-50'
}`} }`}
> >
@ -257,7 +280,7 @@ function PCTrackDetail() {
<span className={`text-xs tabular-nums ${isCurrent ? 'text-white/70' : 'text-gray-400'}`}> <span className={`text-xs tabular-nums ${isCurrent ? 'text-white/70' : 'text-gray-400'}`}>
{t.duration || ''} {t.duration || ''}
</span> </span>
</Link> </button>
); );
})} })}
</div> </div>

View file

@ -1,5 +1,5 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Instagram, Calendar } from 'lucide-react'; import { Instagram, Cake } 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">
<Calendar size={14} /> <Cake 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">
<Calendar size={14} /> <Cake size={14} />
<span> <span>
{formatDate(member.birth_date, 'YYYY.MM.DD')} {formatDate(member.birth_date, 'YYYY.MM.DD')}
</span> </span>

View file

@ -1,4 +1,4 @@
import { useParams, Link } from 'react-router-dom'; import { 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,25 +6,23 @@ import { fetchApi } from '@/api';
/** /**
* PC 생일 페이지 * PC 생일 페이지
* @param {object} props
* @param {string} props.year - 연도
* @param {string} props.nameEn - 멤버 영문 이름 (소문자)
*/ */
function PCBirthday() { function PCBirthday({ year, nameEn }) {
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', decodedMemberName], queryKey: ['member', nameEn],
queryFn: () => fetchApi(`/members/${encodeURIComponent(decodedMemberName)}`), queryFn: () => fetchApi(`/members/${encodeURIComponent(nameEn)}`),
enabled: !!decodedMemberName, enabled: !!nameEn,
}); });
if (!decodedMemberName || error) { if (!nameEn || 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">
@ -125,7 +123,7 @@ function PCBirthday() {
<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} {decodedMemberName} 생일카페 정보가 준비 중입니다 {year} {member?.name} 생일카페 정보가 준비 중입니다
</p> </p>
<p className="text-gray-400 text-sm mt-2">생일카페 정보가 등록되면 이곳에 표시됩니다</p> <p className="text-gray-400 text-sm mt-2">생일카페 정보가 등록되면 이곳에 표시됩니다</p>
</div> </div>

View file

@ -11,8 +11,10 @@ import {
CategoryFilter, CategoryFilter,
ScheduleCard, ScheduleCard,
BirthdayCard, BirthdayCard,
DebutCard,
} from '@/components/pc/public'; } from '@/components/pc/public';
import { fireBirthdayConfetti } from '@/utils'; import { DebutCelebrationDialog } from '@/components/common';
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';
@ -53,6 +55,8 @@ 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();
@ -135,6 +139,27 @@ 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) => {
@ -213,37 +238,18 @@ 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 sortWithBirthdayFirst(searchResults); if (selectedCategories.length === 0) return searchResults;
return sortWithBirthdayFirst(searchResults.filter((s) => selectedCategories.includes(s.category_id))); return searchResults.filter((s) => selectedCategories.includes(s.category_id));
} }
const filtered = schedules return schedules.filter((s) => {
.filter((s) => { const matchesDate = selectedDate ? s.date === selectedDate : s.date?.startsWith(currentYearMonth);
const matchesDate = selectedDate ? s.date === selectedDate : s.date?.startsWith(currentYearMonth); const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(s.category_id);
const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(s.category_id); return matchesDate && matchesCategory;
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]);
// //
@ -256,15 +262,17 @@ function PCSchedule() {
// //
const handleScheduleClick = (schedule) => { const handleScheduleClick = (schedule) => {
if (schedule.is_birthday || String(schedule.id).startsWith('birthday-')) { // , ,
const scheduleYear = new Date(schedule.date).getFullYear(); if (schedule.is_birthday || schedule.is_debut || schedule.is_anniversary) {
navigate(`/birthday/${encodeURIComponent(schedule.member_names)}/${scheduleYear}`); navigate(`/schedule/${schedule.id}`);
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 {
@ -535,6 +543,8 @@ 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)} />
)} )}
@ -564,6 +574,8 @@ 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)} />
)} )}
@ -587,6 +599,14 @@ function PCSchedule() {
</div> </div>
</div> </div>
</div> </div>
{/* 데뷔/주년 축하 다이얼로그 */}
<DebutCelebrationDialog
isOpen={showDebutDialog}
onClose={() => setShowDebutDialog(false)}
isDebut={debutDialogInfo.isDebut}
anniversaryYear={debutDialogInfo.anniversaryYear}
/>
</div> </div>
); );
} }

View file

@ -6,6 +6,34 @@ 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 일정 상세 페이지
@ -13,6 +41,12 @@ import { YoutubeSection, XSection, DefaultSection, decodeHtmlEntities } from './
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,

View file

@ -9,7 +9,6 @@ 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';
@ -55,7 +54,6 @@ 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={

View file

@ -8,7 +8,6 @@ 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';
@ -30,7 +29,6 @@ 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 />} />

View file

@ -1,5 +1,64 @@
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 공용

View file

@ -48,4 +48,4 @@ export {
} from './schedule'; } from './schedule';
// 애니메이션 관련 // 애니메이션 관련
export { fireBirthdayConfetti } from './confetti'; export { fireBirthdayConfetti, fireDebutConfetti } from './confetti';