Compare commits

..

No commits in common. "f483f2cf533e1cb8a515a50a435049bba18c80da" and "2d469739b7c66c3a874deb86665127f92f58f964" have entirely different histories.

63 changed files with 1937 additions and 3327 deletions

View file

@ -1,121 +0,0 @@
/**
* 기존 X 트윗의 content를 Nitter에서 다시 가져와서 원본 URL로 업데이트
*/
import mysql from 'mysql2/promise';
const NITTER_URL = process.env.NITTER_URL || 'http://nitter:8080';
const USERNAME = 'realfromis_9';
// DB 연결
const db = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
});
/**
* 트윗 HTML 컨텐츠에서 텍스트 추출 (링크는 원본 URL 사용)
*/
function extractTextFromHtml(html) {
return html
.replace(/<br\s*\/?>/g, '\n')
.replace(/<a[^>]*href="([^"]*)"[^>]*>([^<]*)<\/a>/g, (match, href, text) => {
if (href.startsWith('/')) {
return text;
}
return href;
})
.replace(/<[^>]+>/g, '')
.trim();
}
/**
* Nitter에서 단일 트윗 조회
*/
async function fetchTweetContent(postId) {
const url = `${NITTER_URL}/${USERNAME}/status/${postId}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`트윗을 찾을 수 없습니다 (${res.status})`);
}
const html = await res.text();
const mainTweetMatch = html.match(/<div id="m" class="main-tweet">([\s\S]*?)<div id="r" class="replies">/);
if (!mainTweetMatch) {
throw new Error('트윗 내용을 파싱할 수 없습니다');
}
const container = mainTweetMatch[1];
const contentMatch = container.match(/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/);
if (!contentMatch) {
throw new Error('트윗 컨텐츠를 찾을 수 없습니다');
}
return extractTextFromHtml(contentMatch[1]);
}
// 메인 실행
async function main() {
console.log('X 트윗 content 업데이트 시작...\n');
// schedule_x에서 모든 트윗 가져오기
const [rows] = await db.query(`
SELECT sx.schedule_id, sx.post_id, sx.content
FROM schedule_x sx
ORDER BY sx.schedule_id DESC
`);
console.log(`${rows.length}개의 트윗을 확인합니다.\n`);
let updated = 0;
let skipped = 0;
let errors = 0;
for (const row of rows) {
const { schedule_id, post_id, content } = row;
// 축약된 URL이 있는지 확인 (…로 끝나는 패턴)
if (!content || !content.includes('…')) {
skipped++;
continue;
}
console.log(`[${schedule_id}] post_id: ${post_id} - 업데이트 중...`);
try {
const newContent = await fetchTweetContent(post_id);
// content가 변경되었는지 확인
if (newContent !== content) {
await db.query(
'UPDATE schedule_x SET content = ? WHERE schedule_id = ?',
[newContent, schedule_id]
);
console.log(` ✓ 업데이트 완료`);
updated++;
} else {
console.log(` - 변경 없음`);
skipped++;
}
// Rate limiting
await new Promise(r => setTimeout(r, 500));
} catch (err) {
console.log(` ✗ 오류: ${err.message}`);
errors++;
}
}
console.log(`\n완료!`);
console.log(` 업데이트: ${updated}`);
console.log(` 스킵: ${skipped}`);
console.log(` 오류: ${errors}`);
await db.end();
}
main().catch(console.error);

View file

@ -7,7 +7,6 @@ import fastifySwagger from '@fastify/swagger';
import scalarApiReference from '@scalar/fastify-api-reference'; import scalarApiReference from '@scalar/fastify-api-reference';
import multipart from '@fastify/multipart'; import multipart from '@fastify/multipart';
import config from './config/index.js'; import config from './config/index.js';
import * as schemas from './schemas/index.js';
// 플러그인 // 플러그인
import dbPlugin from './plugins/db.js'; import dbPlugin from './plugins/db.js';
@ -51,14 +50,6 @@ export async function buildApp(opts = {}) {
await fastify.register(xBotPlugin); await fastify.register(xBotPlugin);
await fastify.register(schedulerPlugin); await fastify.register(schedulerPlugin);
// 공유 스키마 등록 (라우트에서 $ref로 참조 가능)
fastify.addSchema({ $id: 'Album', ...schemas.albumResponse });
fastify.addSchema({ $id: 'AlbumTrack', ...schemas.albumTrack });
fastify.addSchema({ $id: 'Schedule', ...schemas.scheduleResponse });
fastify.addSchema({ $id: 'ScheduleCategory', ...schemas.scheduleCategory });
fastify.addSchema({ $id: 'Member', ...schemas.memberResponse });
fastify.addSchema({ $id: 'Photo', ...schemas.photoResponse });
// Swagger (OpenAPI) 설정 // Swagger (OpenAPI) 설정
await fastify.register(fastifySwagger, { await fastify.register(fastifySwagger, {
openapi: { openapi: {
@ -75,9 +66,6 @@ export async function buildApp(opts = {}) {
{ name: 'members', description: '멤버 API' }, { name: 'members', description: '멤버 API' },
{ name: 'albums', description: '앨범 API' }, { name: 'albums', description: '앨범 API' },
{ name: 'schedules', description: '일정 API' }, { name: 'schedules', description: '일정 API' },
{ name: 'admin/youtube', description: 'YouTube 관리 API' },
{ name: 'admin/x', description: 'X (Twitter) 관리 API' },
{ name: 'admin/bots', description: '봇 관리 API' },
{ name: 'stats', description: '통계 API' }, { name: 'stats', description: '통계 API' },
], ],
components: { components: {

View file

@ -1,22 +1,8 @@
// 카테고리 ID 상수
export const CATEGORY_IDS = {
YOUTUBE: 2,
X: 3,
BIRTHDAY: 8,
};
export default { export default {
server: { server: {
port: parseInt(process.env.PORT) || 80, port: parseInt(process.env.PORT) || 80,
host: '0.0.0.0', host: '0.0.0.0',
}, },
image: {
medium: { width: 800, quality: 85 },
thumb: { width: 400, quality: 80 },
},
x: {
defaultUsername: 'realfromis_9',
},
db: { db: {
host: process.env.DB_HOST || 'mariadb', host: process.env.DB_HOST || 'mariadb',
port: parseInt(process.env.DB_PORT) || 3306, port: parseInt(process.env.DB_PORT) || 3306,
@ -47,6 +33,5 @@ export default {
meilisearch: { meilisearch: {
host: process.env.MEILI_HOST || 'http://fromis9-meilisearch:7700', host: process.env.MEILI_HOST || 'http://fromis9-meilisearch:7700',
apiKey: process.env.MEILI_MASTER_KEY, apiKey: process.env.MEILI_MASTER_KEY,
minScore: 0.5,
}, },
}; };

View file

@ -46,27 +46,6 @@ async function schedulerPlugin(fastify, opts) {
return null; return null;
} }
/**
* 동기화 결과 처리 (중복 코드 제거)
*/
async function handleSyncResult(botId, result, options = {}) {
const { setRunningStatus = false, setErrorOnFail = false } = options;
const status = await getStatus(botId);
const updateData = {
lastCheckAt: new Date().toISOString(),
totalAdded: (status.totalAdded || 0) + result.addedCount,
};
if (setRunningStatus) {
updateData.status = 'running';
updateData.errorMessage = null;
}
if (result.addedCount > 0) {
updateData.lastAddedCount = result.addedCount;
}
await updateStatus(botId, updateData);
return result.addedCount;
}
/** /**
* 시작 * 시작
*/ */
@ -92,8 +71,19 @@ async function schedulerPlugin(fastify, opts) {
fastify.log.info(`[${botId}] 동기화 시작`); fastify.log.info(`[${botId}] 동기화 시작`);
try { try {
const result = await syncFn(bot); const result = await syncFn(bot);
const addedCount = await handleSyncResult(botId, result, { setRunningStatus: true }); const status = await getStatus(botId);
fastify.log.info(`[${botId}] 동기화 완료: ${addedCount}개 추가`); const updateData = {
status: 'running',
lastCheckAt: new Date().toISOString(),
totalAdded: (status.totalAdded || 0) + result.addedCount,
errorMessage: null,
};
// 실제로 추가된 경우에만 lastAddedCount 업데이트
if (result.addedCount > 0) {
updateData.lastAddedCount = result.addedCount;
}
await updateStatus(botId, updateData);
fastify.log.info(`[${botId}] 동기화 완료: ${result.addedCount}개 추가`);
} catch (err) { } catch (err) {
await updateStatus(botId, { await updateStatus(botId, {
status: 'error', status: 'error',
@ -111,8 +101,17 @@ async function schedulerPlugin(fastify, opts) {
// 즉시 1회 실행 // 즉시 1회 실행
try { try {
const result = await syncFn(bot); const result = await syncFn(bot);
const addedCount = await handleSyncResult(botId, result); const status = await getStatus(botId);
fastify.log.info(`[${botId}] 초기 동기화 완료: ${addedCount}개 추가`); const updateData = {
lastCheckAt: new Date().toISOString(),
totalAdded: (status.totalAdded || 0) + result.addedCount,
};
// 실제로 추가된 경우에만 lastAddedCount 업데이트
if (result.addedCount > 0) {
updateData.lastAddedCount = result.addedCount;
}
await updateStatus(botId, updateData);
fastify.log.info(`[${botId}] 초기 동기화 완료: ${result.addedCount}개 추가`);
} catch (err) { } catch (err) {
fastify.log.error(`[${botId}] 초기 동기화 오류: ${err.message}`); fastify.log.error(`[${botId}] 초기 동기화 오류: ${err.message}`);
} }

View file

@ -1,30 +1,4 @@
import bots from '../../config/bots.js'; import bots from '../../config/bots.js';
import { errorResponse } from '../../schemas/index.js';
// 봇 관련 스키마
const botResponse = {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
type: { type: 'string', enum: ['youtube', 'x'] },
status: { type: 'string', enum: ['running', 'stopped', 'error'] },
last_check_at: { type: 'string', format: 'date-time' },
last_added_count: { type: 'integer' },
schedules_added: { type: 'integer' },
check_interval: { type: 'integer' },
error_message: { type: 'string' },
enabled: { type: 'boolean' },
},
};
const botIdParam = {
type: 'object',
properties: {
id: { type: 'string', description: '봇 ID' },
},
required: ['id'],
};
/** /**
* 관리 라우트 * 관리 라우트
@ -42,14 +16,7 @@ export default async function botsRoutes(fastify) {
schema: { schema: {
tags: ['admin/bots'], tags: ['admin/bots'],
summary: '봇 목록 조회', summary: '봇 목록 조회',
description: '등록된 모든 봇(YouTube, X)의 상태를 조회합니다.',
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
response: {
200: {
type: 'array',
items: botResponse,
},
},
}, },
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
@ -90,19 +57,7 @@ export default async function botsRoutes(fastify) {
schema: { schema: {
tags: ['admin/bots'], tags: ['admin/bots'],
summary: '봇 시작', summary: '봇 시작',
description: '지정된 봇의 스케줄러를 시작합니다.',
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
params: botIdParam,
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
},
},
400: errorResponse,
},
}, },
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
@ -124,19 +79,7 @@ export default async function botsRoutes(fastify) {
schema: { schema: {
tags: ['admin/bots'], tags: ['admin/bots'],
summary: '봇 정지', summary: '봇 정지',
description: '지정된 봇의 스케줄러를 정지합니다.',
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
params: botIdParam,
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
},
},
400: errorResponse,
},
}, },
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
@ -158,22 +101,7 @@ export default async function botsRoutes(fastify) {
schema: { schema: {
tags: ['admin/bots'], tags: ['admin/bots'],
summary: '봇 전체 동기화', summary: '봇 전체 동기화',
description: '봇이 관리하는 모든 콘텐츠를 다시 동기화합니다.',
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
params: botIdParam,
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
addedCount: { type: 'integer', description: '추가된 일정 수' },
total: { type: 'integer', description: '총 처리 수' },
},
},
400: errorResponse,
404: errorResponse,
500: errorResponse,
},
}, },
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
@ -223,18 +151,7 @@ export default async function botsRoutes(fastify) {
schema: { schema: {
tags: ['admin/bots'], tags: ['admin/bots'],
summary: '할당량 경고 조회', summary: '할당량 경고 조회',
description: 'YouTube API 할당량 경고 상태를 조회합니다.',
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
response: {
200: {
type: 'object',
properties: {
active: { type: 'boolean' },
message: { type: 'string' },
timestamp: { type: 'string', format: 'date-time' },
},
},
},
}, },
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
@ -253,16 +170,7 @@ export default async function botsRoutes(fastify) {
schema: { schema: {
tags: ['admin/bots'], tags: ['admin/bots'],
summary: '할당량 경고 해제', summary: '할당량 경고 해제',
description: 'YouTube API 할당량 경고를 해제합니다.',
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
},
},
},
}, },
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {

View file

@ -1,16 +1,11 @@
import { fetchSingleTweet, extractTitle } from '../../services/x/scraper.js'; import { fetchSingleTweet, extractTitle } from '../../services/x/scraper.js';
import { addOrUpdateSchedule } from '../../services/meilisearch/index.js'; import { addOrUpdateSchedule } from '../../services/meilisearch/index.js';
import { formatDate, formatTime } from '../../utils/date.js'; import { formatDate, formatTime } from '../../utils/date.js';
import config, { CATEGORY_IDS } from '../../config/index.js'; import config from '../../config/index.js';
import {
errorResponse,
xPostInfoQuery,
xScheduleCreate,
} from '../../schemas/index.js';
const X_CATEGORY_ID = CATEGORY_IDS.X; const X_CATEGORY_ID = 3;
const NITTER_URL = config.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080'; const NITTER_URL = config.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080';
const DEFAULT_USERNAME = config.x.defaultUsername; const DEFAULT_USERNAME = 'realfromis_9';
/** /**
* X(Twitter) 관련 관리자 라우트 * X(Twitter) 관련 관리자 라우트
@ -26,33 +21,14 @@ export default async function xRoutes(fastify) {
schema: { schema: {
tags: ['admin/x'], tags: ['admin/x'],
summary: 'X 게시글 정보 조회', summary: 'X 게시글 정보 조회',
description: 'X(Twitter) 게시글 ID로 정보를 조회합니다.',
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
querystring: xPostInfoQuery, querystring: {
response: { type: 'object',
200: { properties: {
type: 'object', postId: { type: 'string', description: '게시글 ID' },
properties: { username: { type: 'string', description: '사용자명 (기본: realfromis_9)' },
postId: { type: 'string' },
username: { type: 'string' },
text: { type: 'string' },
title: { type: 'string' },
imageUrls: { type: 'array', items: { type: 'string' } },
date: { type: 'string' },
time: { type: 'string' },
postUrl: { type: 'string' },
profile: {
type: 'object',
properties: {
username: { type: 'string' },
displayName: { type: 'string' },
avatarUrl: { type: 'string' },
},
},
},
}, },
400: errorResponse, required: ['postId'],
500: errorResponse,
}, },
}, },
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
@ -92,19 +68,18 @@ export default async function xRoutes(fastify) {
schema: { schema: {
tags: ['admin/x'], tags: ['admin/x'],
summary: 'X 일정 저장', summary: 'X 일정 저장',
description: 'X(Twitter) 게시글을 일정으로 등록합니다.',
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
body: xScheduleCreate, body: {
response: { type: 'object',
200: { properties: {
type: 'object', postId: { type: 'string' },
properties: { title: { type: 'string' },
success: { type: 'boolean' }, content: { type: 'string' },
scheduleId: { type: 'integer' }, imageUrls: { type: 'array', items: { type: 'string' } },
}, date: { type: 'string' },
time: { type: 'string' },
}, },
409: errorResponse, required: ['postId', 'title', 'date'],
500: errorResponse,
}, },
}, },
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],

View file

@ -1,15 +1,7 @@
import { fetchVideoInfo } from '../../services/youtube/api.js'; import { fetchVideoInfo } from '../../services/youtube/api.js';
import { addOrUpdateSchedule } from '../../services/meilisearch/index.js'; import { addOrUpdateSchedule } from '../../services/meilisearch/index.js';
import { CATEGORY_IDS } from '../../config/index.js';
import {
errorResponse,
youtubeVideoInfo,
youtubeScheduleCreate,
youtubeScheduleUpdate,
idParam,
} from '../../schemas/index.js';
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE; const YOUTUBE_CATEGORY_ID = 2;
/** /**
* YouTube 관련 관리자 라우트 * YouTube 관련 관리자 라우트
@ -24,27 +16,13 @@ export default async function youtubeRoutes(fastify) {
schema: { schema: {
tags: ['admin/youtube'], tags: ['admin/youtube'],
summary: 'YouTube 영상 정보 조회', summary: 'YouTube 영상 정보 조회',
description: 'YouTube URL에서 영상 정보를 추출합니다.',
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
querystring: youtubeVideoInfo, querystring: {
response: { type: 'object',
200: { properties: {
type: 'object', url: { type: 'string', description: 'YouTube URL' },
properties: {
videoId: { type: 'string' },
title: { type: 'string' },
channelId: { type: 'string' },
channelName: { type: 'string' },
publishedAt: { type: 'string' },
date: { type: 'string' },
time: { type: 'string' },
videoType: { type: 'string' },
videoUrl: { type: 'string' },
},
}, },
400: errorResponse, required: ['url'],
404: errorResponse,
500: errorResponse,
}, },
}, },
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
@ -88,19 +66,19 @@ export default async function youtubeRoutes(fastify) {
schema: { schema: {
tags: ['admin/youtube'], tags: ['admin/youtube'],
summary: 'YouTube 일정 저장', summary: 'YouTube 일정 저장',
description: 'YouTube 영상을 일정으로 등록합니다.',
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
body: youtubeScheduleCreate, body: {
response: { type: 'object',
200: { properties: {
type: 'object', videoId: { type: 'string' },
properties: { title: { type: 'string' },
success: { type: 'boolean' }, channelId: { type: 'string' },
scheduleId: { type: 'integer' }, channelName: { type: 'string' },
}, date: { type: 'string' },
time: { type: 'string' },
videoType: { type: 'string' },
}, },
409: errorResponse, required: ['videoId', 'title', 'date'],
500: errorResponse,
}, },
}, },
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
@ -154,109 +132,6 @@ export default async function youtubeRoutes(fastify) {
return reply.code(500).send({ error: err.message }); return reply.code(500).send({ error: err.message });
} }
}); });
/**
* PUT /api/admin/youtube/schedule/:id
* YouTube 일정 수정 (멤버, 영상 유형 수정 가능)
*/
fastify.put('/schedule/:id', {
schema: {
tags: ['admin/youtube'],
summary: 'YouTube 일정 수정',
description: 'YouTube 일정의 멤버와 영상 유형을 수정합니다.',
security: [{ bearerAuth: [] }],
params: idParam,
body: youtubeScheduleUpdate,
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
},
},
404: errorResponse,
500: errorResponse,
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params;
const { memberIds = [], videoType } = request.body;
try {
// 일정 존재 확인
const [schedules] = await db.query(
'SELECT id, title, date, time FROM schedules WHERE id = ? AND category_id = ?',
[id, YOUTUBE_CATEGORY_ID]
);
if (schedules.length === 0) {
return reply.code(404).send({ error: 'YouTube 일정을 찾을 수 없습니다.' });
}
// 영상 유형 수정
if (videoType) {
await db.query(
'UPDATE schedule_youtube SET video_type = ? WHERE schedule_id = ?',
[videoType, id]
);
}
// 기존 멤버 삭제
await db.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]);
// 새 멤버 추가
if (memberIds.length > 0) {
const values = memberIds.map(memberId => [id, memberId]);
await db.query(
'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?',
[values]
);
}
// 멤버 이름 조회 (Meilisearch 동기화용)
let memberNames = '';
if (memberIds.length > 0) {
const [members] = await db.query(
'SELECT name FROM members WHERE id IN (?) ORDER BY id',
[memberIds]
);
memberNames = members.map(m => m.name).join(',');
}
// YouTube 채널 정보 조회
const [youtubeInfo] = await db.query(
'SELECT channel_name FROM schedule_youtube WHERE schedule_id = ?',
[id]
);
const channelName = youtubeInfo[0]?.channel_name || '';
// 카테고리 정보 조회
const [categoryRows] = await db.query(
'SELECT name, color FROM schedule_categories WHERE id = ?',
[YOUTUBE_CATEGORY_ID]
);
const category = categoryRows[0] || {};
// Meilisearch 동기화
const schedule = schedules[0];
await addOrUpdateSchedule(meilisearch, {
id: schedule.id,
title: schedule.title,
date: schedule.date,
time: schedule.time || '',
category_id: YOUTUBE_CATEGORY_ID,
category_name: category.name || '',
category_color: category.color || '',
member_names: memberNames,
source_name: channelName,
});
return { success: true };
} catch (err) {
fastify.log.error(`YouTube 일정 수정 오류: ${err.message}`);
return reply.code(500).send({ error: err.message });
}
});
} }
/** /**

View file

@ -1,13 +1,9 @@
import { import {
getAlbumDetails, uploadAlbumCover,
getAlbumsWithTracks, deleteAlbumCover,
createAlbum, } from '../../services/image.js';
updateAlbum,
deleteAlbum,
} from '../../services/album.js';
import photosRoutes from './photos.js'; import photosRoutes from './photos.js';
import teasersRoutes from './teasers.js'; import teasersRoutes from './teasers.js';
import { errorResponse, successResponse, idParam } from '../../schemas/index.js';
/** /**
* 앨범 라우트 * 앨범 라우트
@ -20,6 +16,60 @@ export default async function albumsRoutes(fastify) {
fastify.register(photosRoutes); fastify.register(photosRoutes);
fastify.register(teasersRoutes); fastify.register(teasersRoutes);
/**
* 앨범 상세 정보 조회 헬퍼 함수 (트랙, 티저, 컨셉포토 포함)
*/
async function getAlbumDetails(album) {
const [tracks] = await db.query(
'SELECT * FROM album_tracks WHERE album_id = ? ORDER BY track_number',
[album.id]
);
album.tracks = tracks;
const [teasers] = await db.query(
`SELECT original_url, medium_url, thumb_url, video_url, media_type
FROM album_teasers WHERE album_id = ? ORDER BY sort_order`,
[album.id]
);
album.teasers = teasers;
const [photos] = await db.query(
`SELECT
p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name, p.sort_order,
p.width, p.height,
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ', ') as members
FROM album_photos p
LEFT JOIN album_photo_members pm ON p.id = pm.photo_id
LEFT JOIN members m ON pm.member_id = m.id
WHERE p.album_id = ?
GROUP BY p.id
ORDER BY p.sort_order`,
[album.id]
);
const conceptPhotos = {};
for (const photo of photos) {
const concept = photo.concept_name || 'Default';
if (!conceptPhotos[concept]) {
conceptPhotos[concept] = [];
}
conceptPhotos[concept].push({
id: photo.id,
original_url: photo.original_url,
medium_url: photo.medium_url,
thumb_url: photo.thumb_url,
width: photo.width,
height: photo.height,
type: photo.photo_type,
members: photo.members,
sortOrder: photo.sort_order,
});
}
album.conceptPhotos = conceptPhotos;
return album;
}
// ==================== GET (공개) ==================== // ==================== GET (공개) ====================
/** /**
@ -29,13 +79,25 @@ export default async function albumsRoutes(fastify) {
schema: { schema: {
tags: ['albums'], tags: ['albums'],
summary: '전체 앨범 목록 조회', summary: '전체 앨범 목록 조회',
description: '모든 앨범과 트랙 목록을 조회합니다.',
response: {
200: { type: 'array', items: { type: 'object', additionalProperties: true } },
},
}, },
}, async () => { }, async () => {
return await getAlbumsWithTracks(db); const [albums] = await db.query(`
SELECT id, title, folder_name, album_type, album_type_short, release_date,
cover_original_url, cover_medium_url, cover_thumb_url, description
FROM albums
ORDER BY release_date DESC
`);
for (const album of albums) {
const [tracks] = await db.query(
`SELECT id, track_number, title, is_title_track, duration, lyricist, composer, arranger
FROM album_tracks WHERE album_id = ? ORDER BY track_number`,
[album.id]
);
album.tracks = tracks;
}
return albums;
}); });
/** /**
@ -45,18 +107,6 @@ export default async function albumsRoutes(fastify) {
schema: { schema: {
tags: ['albums'], tags: ['albums'],
summary: '앨범명과 트랙명으로 트랙 조회', summary: '앨범명과 트랙명으로 트랙 조회',
description: '앨범명(또는 폴더명)과 트랙명으로 트랙 상세 정보를 조회합니다.',
params: {
type: 'object',
properties: {
albumName: { type: 'string', description: '앨범명 또는 폴더명' },
trackTitle: { type: 'string', description: '트랙 제목' },
},
required: ['albumName', 'trackTitle'],
},
response: {
404: errorResponse,
},
}, },
}, async (request, reply) => { }, async (request, reply) => {
const albumName = decodeURIComponent(request.params.albumName); const albumName = decodeURIComponent(request.params.albumName);
@ -111,17 +161,6 @@ export default async function albumsRoutes(fastify) {
schema: { schema: {
tags: ['albums'], tags: ['albums'],
summary: '앨범명으로 앨범 조회', summary: '앨범명으로 앨범 조회',
description: '앨범명(또는 폴더명)으로 앨범 상세 정보를 조회합니다.',
params: {
type: 'object',
properties: {
name: { type: 'string', description: '앨범명 또는 폴더명' },
},
required: ['name'],
},
response: {
200: { type: 'object', additionalProperties: true },
},
}, },
}, async (request, reply) => { }, async (request, reply) => {
const name = decodeURIComponent(request.params.name); const name = decodeURIComponent(request.params.name);
@ -135,7 +174,7 @@ export default async function albumsRoutes(fastify) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
} }
return getAlbumDetails(db, albums[0]); return getAlbumDetails(albums[0]);
}); });
/** /**
@ -145,11 +184,6 @@ export default async function albumsRoutes(fastify) {
schema: { schema: {
tags: ['albums'], tags: ['albums'],
summary: 'ID로 앨범 조회', summary: 'ID로 앨범 조회',
description: '앨범 ID로 상세 정보(트랙, 티저, 컨셉포토 포함)를 조회합니다.',
params: idParam,
response: {
200: { type: 'object', additionalProperties: true },
},
}, },
}, async (request, reply) => { }, async (request, reply) => {
const [albums] = await db.query('SELECT * FROM albums WHERE id = ?', [ const [albums] = await db.query('SELECT * FROM albums WHERE id = ?', [
@ -160,7 +194,7 @@ export default async function albumsRoutes(fastify) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
} }
return getAlbumDetails(db, albums[0]); return getAlbumDetails(albums[0]);
}); });
// ==================== POST/PUT/DELETE (인증 필요) ==================== // ==================== POST/PUT/DELETE (인증 필요) ====================
@ -172,19 +206,7 @@ export default async function albumsRoutes(fastify) {
schema: { schema: {
tags: ['albums'], tags: ['albums'],
summary: '앨범 생성', summary: '앨범 생성',
description: 'multipart/form-data로 앨범을 생성합니다. data 필드에 JSON, cover 필드에 이미지 파일.',
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
consumes: ['multipart/form-data'],
response: {
200: {
type: 'object',
properties: {
message: { type: 'string' },
albumId: { type: 'integer' },
},
},
400: errorResponse,
},
}, },
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
@ -204,13 +226,59 @@ export default async function albumsRoutes(fastify) {
return reply.code(400).send({ error: '데이터가 필요합니다.' }); return reply.code(400).send({ error: '데이터가 필요합니다.' });
} }
const { title, album_type, release_date, folder_name } = data; const { title, album_type, album_type_short, release_date, folder_name, description, tracks } = data;
if (!title || !album_type || !release_date || !folder_name) { if (!title || !album_type || !release_date || !folder_name) {
return reply.code(400).send({ error: '필수 필드를 모두 입력해주세요.' }); return reply.code(400).send({ error: '필수 필드를 모두 입력해주세요.' });
} }
return await createAlbum(db, data, coverBuffer); const connection = await db.getConnection();
try {
await connection.beginTransaction();
let coverOriginalUrl = null;
let coverMediumUrl = null;
let coverThumbUrl = null;
if (coverBuffer) {
const urls = await uploadAlbumCover(folder_name, coverBuffer);
coverOriginalUrl = urls.originalUrl;
coverMediumUrl = urls.mediumUrl;
coverThumbUrl = urls.thumbUrl;
}
const [albumResult] = await connection.query(
`INSERT INTO albums (title, album_type, album_type_short, release_date, folder_name,
cover_original_url, cover_medium_url, cover_thumb_url, description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[title, album_type, album_type_short || null, release_date, folder_name,
coverOriginalUrl, coverMediumUrl, coverThumbUrl, description || null]
);
const albumId = albumResult.insertId;
if (tracks && tracks.length > 0) {
for (const track of tracks) {
await connection.query(
`INSERT INTO album_tracks (album_id, track_number, title, duration, is_title_track,
lyricist, composer, arranger, lyrics, music_video_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[albumId, track.track_number, track.title, track.duration || null,
track.is_title_track ? 1 : 0, track.lyricist || null, track.composer || null,
track.arranger || null, track.lyrics || null, track.music_video_url || null]
);
}
}
await connection.commit();
return { message: '앨범이 생성되었습니다.', albumId };
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}); });
/** /**
@ -220,15 +288,7 @@ export default async function albumsRoutes(fastify) {
schema: { schema: {
tags: ['albums'], tags: ['albums'],
summary: '앨범 수정', summary: '앨범 수정',
description: 'multipart/form-data로 앨범을 수정합니다.',
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
consumes: ['multipart/form-data'],
params: idParam,
response: {
200: successResponse,
400: errorResponse,
404: errorResponse,
},
}, },
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
@ -249,11 +309,62 @@ export default async function albumsRoutes(fastify) {
return reply.code(400).send({ error: '데이터가 필요합니다.' }); return reply.code(400).send({ error: '데이터가 필요합니다.' });
} }
const result = await updateAlbum(db, id, data, coverBuffer); const { title, album_type, album_type_short, release_date, folder_name, description, tracks } = data;
if (!result) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); const connection = await db.getConnection();
try {
await connection.beginTransaction();
const [existingAlbums] = await connection.query('SELECT * FROM albums WHERE id = ?', [id]);
if (existingAlbums.length === 0) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
}
const existing = existingAlbums[0];
let coverOriginalUrl = existing.cover_original_url;
let coverMediumUrl = existing.cover_medium_url;
let coverThumbUrl = existing.cover_thumb_url;
if (coverBuffer) {
const urls = await uploadAlbumCover(folder_name, coverBuffer);
coverOriginalUrl = urls.originalUrl;
coverMediumUrl = urls.mediumUrl;
coverThumbUrl = urls.thumbUrl;
}
await connection.query(
`UPDATE albums SET title = ?, album_type = ?, album_type_short = ?, release_date = ?,
folder_name = ?, cover_original_url = ?, cover_medium_url = ?,
cover_thumb_url = ?, description = ?
WHERE id = ?`,
[title, album_type, album_type_short || null, release_date, folder_name,
coverOriginalUrl, coverMediumUrl, coverThumbUrl, description || null, id]
);
await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]);
if (tracks && tracks.length > 0) {
for (const track of tracks) {
await connection.query(
`INSERT INTO album_tracks (album_id, track_number, title, duration, is_title_track,
lyricist, composer, arranger, lyrics, music_video_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[id, track.track_number, track.title, track.duration || null,
track.is_title_track ? 1 : 0, track.lyricist || null, track.composer || null,
track.arranger || null, track.lyrics || null, track.music_video_url || null]
);
}
}
await connection.commit();
return { message: '앨범이 수정되었습니다.' };
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
} }
return result;
}); });
/** /**
@ -263,21 +374,37 @@ export default async function albumsRoutes(fastify) {
schema: { schema: {
tags: ['albums'], tags: ['albums'],
summary: '앨범 삭제', summary: '앨범 삭제',
description: '앨범과 관련 데이터(트랙, 커버 이미지)를 삭제합니다.',
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
params: idParam,
response: {
200: successResponse,
404: errorResponse,
},
}, },
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
const { id } = request.params; const { id } = request.params;
const result = await deleteAlbum(db, id); const connection = await db.getConnection();
if (!result) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); try {
await connection.beginTransaction();
const [existingAlbums] = await connection.query('SELECT * FROM albums WHERE id = ?', [id]);
if (existingAlbums.length === 0) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
}
const album = existingAlbums[0];
if (album.cover_original_url && album.folder_name) {
await deleteAlbumCover(album.folder_name);
}
await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]);
await connection.query('DELETE FROM albums WHERE id = ?', [id]);
await connection.commit();
return { message: '앨범이 삭제되었습니다.' };
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
} }
return result;
}); });
} }

View file

@ -3,7 +3,6 @@ import {
deleteAlbumPhoto, deleteAlbumPhoto,
uploadAlbumVideo, uploadAlbumVideo,
} from '../../services/image.js'; } from '../../services/image.js';
import { withTransaction } from '../../utils/transaction.js';
/** /**
* 앨범 사진 라우트 * 앨범 사진 라우트
@ -167,11 +166,12 @@ export default async function photosRoutes(fastify) {
photoId = result.insertId; photoId = result.insertId;
if (meta.members && meta.members.length > 0) { if (meta.members && meta.members.length > 0) {
const values = meta.members.map(memberId => [photoId, memberId]); for (const memberId of meta.members) {
await connection.query( await connection.query(
'INSERT INTO album_photo_members (photo_id, member_id) VALUES ?', 'INSERT INTO album_photo_members (photo_id, member_id) VALUES (?, ?)',
[values] [photoId, memberId]
); );
}
} }
} }
@ -196,7 +196,7 @@ export default async function photosRoutes(fastify) {
reply.raw.end(); reply.raw.end();
} catch (error) { } catch (error) {
await connection.rollback(); await connection.rollback();
fastify.log.error(`사진 업로드 오류: ${error.message}`); console.error('사진 업로드 오류:', error);
reply.raw.write(`data: ${JSON.stringify({ error: '사진 업로드 중 오류가 발생했습니다.' })}\n\n`); reply.raw.write(`data: ${JSON.stringify({ error: '사진 업로드 중 오류가 발생했습니다.' })}\n\n`);
reply.raw.end(); reply.raw.end();
} finally { } finally {
@ -216,29 +216,37 @@ export default async function photosRoutes(fastify) {
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
const { albumId, photoId } = request.params; const { albumId, photoId } = request.params;
const connection = await db.getConnection();
// 사진 존재 여부 먼저 확인 try {
const [photos] = await db.query( await connection.beginTransaction();
`SELECT p.*, a.folder_name
FROM album_photos p
JOIN albums a ON p.album_id = a.id
WHERE p.id = ? AND p.album_id = ?`,
[photoId, albumId]
);
if (photos.length === 0) { const [photos] = await connection.query(
return reply.code(404).send({ error: '사진을 찾을 수 없습니다.' }); `SELECT p.*, a.folder_name
} FROM album_photos p
JOIN albums a ON p.album_id = a.id
WHERE p.id = ? AND p.album_id = ?`,
[photoId, albumId]
);
const photo = photos[0]; if (photos.length === 0) {
const filename = photo.original_url.split('/').pop(); return reply.code(404).send({ error: '사진을 찾을 수 없습니다.' });
}
const photo = photos[0];
const filename = photo.original_url.split('/').pop();
return withTransaction(db, async (connection) => {
await deleteAlbumPhoto(photo.folder_name, 'photo', filename); await deleteAlbumPhoto(photo.folder_name, 'photo', filename);
await connection.query('DELETE FROM album_photo_members WHERE photo_id = ?', [photoId]); await connection.query('DELETE FROM album_photo_members WHERE photo_id = ?', [photoId]);
await connection.query('DELETE FROM album_photos WHERE id = ?', [photoId]); await connection.query('DELETE FROM album_photos WHERE id = ?', [photoId]);
await connection.commit();
return { message: '사진이 삭제되었습니다.' }; return { message: '사진이 삭제되었습니다.' };
}); } catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}); });
} }

View file

@ -2,7 +2,6 @@ import {
deleteAlbumPhoto, deleteAlbumPhoto,
deleteAlbumVideo, deleteAlbumVideo,
} from '../../services/image.js'; } from '../../services/image.js';
import { withTransaction } from '../../utils/transaction.js';
/** /**
* 앨범 티저 라우트 * 앨범 티저 라우트
@ -50,24 +49,26 @@ export default async function teasersRoutes(fastify) {
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
const { albumId, teaserId } = request.params; const { albumId, teaserId } = request.params;
const connection = await db.getConnection();
// 티저 존재 여부 먼저 확인 try {
const [teasers] = await db.query( await connection.beginTransaction();
`SELECT t.*, a.folder_name
FROM album_teasers t
JOIN albums a ON t.album_id = a.id
WHERE t.id = ? AND t.album_id = ?`,
[teaserId, albumId]
);
if (teasers.length === 0) { const [teasers] = await connection.query(
return reply.code(404).send({ error: '티저를 찾을 수 없습니다.' }); `SELECT t.*, a.folder_name
} FROM album_teasers t
JOIN albums a ON t.album_id = a.id
WHERE t.id = ? AND t.album_id = ?`,
[teaserId, albumId]
);
const teaser = teasers[0]; if (teasers.length === 0) {
const filename = teaser.original_url.split('/').pop(); return reply.code(404).send({ error: '티저를 찾을 수 없습니다.' });
}
const teaser = teasers[0];
const filename = teaser.original_url.split('/').pop();
return withTransaction(db, async (connection) => {
await deleteAlbumPhoto(teaser.folder_name, 'teaser', filename); await deleteAlbumPhoto(teaser.folder_name, 'teaser', filename);
if (teaser.video_url) { if (teaser.video_url) {
@ -77,7 +78,13 @@ export default async function teasersRoutes(fastify) {
await connection.query('DELETE FROM album_teasers WHERE id = ?', [teaserId]); await connection.query('DELETE FROM album_teasers WHERE id = ?', [teaserId]);
await connection.commit();
return { message: '티저가 삭제되었습니다.' }; return { message: '티저가 삭제되었습니다.' };
}); } catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}); });
} }

View file

@ -42,7 +42,7 @@ export default async function authRoutes(fastify, opts) {
const { username, password } = request.body || {}; const { username, password } = request.body || {};
if (!username || !password) { if (!username || !password) {
return reply.code(400).send({ error: '아이디와 비밀번호를 입력해주세요.' }); return reply.status(400).send({ error: '아이디와 비밀번호를 입력해주세요.' });
} }
try { try {
@ -52,14 +52,14 @@ export default async function authRoutes(fastify, opts) {
); );
if (users.length === 0) { if (users.length === 0) {
return reply.code(401).send({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' }); return reply.status(401).send({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' });
} }
const user = users[0]; const user = users[0];
const isValidPassword = await bcrypt.compare(password, user.password_hash); const isValidPassword = await bcrypt.compare(password, user.password_hash);
if (!isValidPassword) { if (!isValidPassword) {
return reply.code(401).send({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' }); return reply.status(401).send({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' });
} }
// JWT 토큰 생성 // JWT 토큰 생성
@ -75,7 +75,7 @@ export default async function authRoutes(fastify, opts) {
}; };
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
return reply.code(500).send({ error: '로그인 처리 중 오류가 발생했습니다.' }); return reply.status(500).send({ error: '로그인 처리 중 오류가 발생했습니다.' });
} }
}); });

View file

@ -53,7 +53,7 @@ export default async function membersRoutes(fastify, opts) {
return result; return result;
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
return reply.code(500).send({ error: '멤버 목록 조회 실패' }); return reply.status(500).send({ error: '멤버 목록 조회 실패' });
} }
}); });
@ -88,7 +88,7 @@ export default async function membersRoutes(fastify, opts) {
`, [decodeURIComponent(name)]); `, [decodeURIComponent(name)]);
if (members.length === 0) { if (members.length === 0) {
return reply.code(404).send({ error: '멤버를 찾을 수 없습니다' }); return reply.status(404).send({ error: '멤버를 찾을 수 없습니다' });
} }
const member = members[0]; const member = members[0];
@ -106,7 +106,7 @@ export default async function membersRoutes(fastify, opts) {
}; };
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
return reply.code(500).send({ error: '멤버 조회 실패' }); return reply.status(500).send({ error: '멤버 조회 실패' });
} }
}); });
@ -140,7 +140,7 @@ export default async function membersRoutes(fastify, opts) {
); );
if (existing.length === 0) { if (existing.length === 0) {
return reply.code(404).send({ error: '멤버를 찾을 수 없습니다' }); return reply.status(404).send({ error: '멤버를 찾을 수 없습니다' });
} }
const memberId = existing[0].id; const memberId = existing[0].id;
@ -218,7 +218,7 @@ export default async function membersRoutes(fastify, opts) {
return { message: '멤버 정보가 수정되었습니다', id: memberId }; return { message: '멤버 정보가 수정되었습니다', id: memberId };
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
return reply.code(500).send({ error: '멤버 수정 실패: ' + err.message }); return reply.status(500).send({ error: '멤버 수정 실패: ' + err.message });
} }
}); });
} }

View file

@ -4,14 +4,6 @@
*/ */
import suggestionsRoutes from './suggestions.js'; import suggestionsRoutes from './suggestions.js';
import { searchSchedules, syncAllSchedules } from '../../services/meilisearch/index.js'; import { searchSchedules, syncAllSchedules } from '../../services/meilisearch/index.js';
import config, { CATEGORY_IDS } from '../../config/index.js';
import { getMonthlySchedules, getUpcomingSchedules } from '../../services/schedule.js';
import {
errorResponse,
scheduleSearchQuery,
scheduleSearchResponse,
idParam,
} from '../../schemas/index.js';
export default async function schedulesRoutes(fastify) { export default async function schedulesRoutes(fastify) {
const { db, meilisearch, redis } = fastify; const { db, meilisearch, redis } = fastify;
@ -27,10 +19,6 @@ export default async function schedulesRoutes(fastify) {
schema: { schema: {
tags: ['schedules'], tags: ['schedules'],
summary: '카테고리 목록 조회', summary: '카테고리 목록 조회',
description: '일정 카테고리 목록을 조회합니다.',
response: {
200: { type: 'array', items: { type: 'object', additionalProperties: true } },
},
}, },
}, async (request, reply) => { }, async (request, reply) => {
const [categories] = await db.query( const [categories] = await db.query(
@ -48,31 +36,31 @@ export default async function schedulesRoutes(fastify) {
schema: { schema: {
tags: ['schedules'], tags: ['schedules'],
summary: '일정 조회 (검색 또는 월별)', summary: '일정 조회 (검색 또는 월별)',
description: 'search 파라미터로 검색, year/month로 월별 조회, startDate로 다가오는 일정 조회', querystring: {
querystring: scheduleSearchQuery, type: 'object',
response: { properties: {
200: { type: 'object', additionalProperties: true }, search: { type: 'string', description: '검색어' },
year: { type: 'integer', description: '년도' },
month: { type: 'integer', minimum: 1, maximum: 12, description: '월' },
offset: { type: 'integer', default: 0, description: '페이지 오프셋' },
limit: { type: 'integer', default: 100, description: '결과 개수' },
},
}, },
}, },
}, async (request, reply) => { }, async (request, reply) => {
const { search, year, month, startDate, offset = 0, limit = 100 } = request.query; const { search, year, month, offset = 0, limit = 100 } = request.query;
// 검색 모드 // 검색 모드
if (search && search.trim()) { if (search && search.trim()) {
return await handleSearch(fastify, search.trim(), parseInt(offset), parseInt(limit)); return await handleSearch(fastify, search.trim(), parseInt(offset), parseInt(limit));
} }
// 다가오는 일정 조회 (startDate부터)
if (startDate) {
return await getUpcomingSchedules(db, startDate, parseInt(limit));
}
// 월별 조회 모드 // 월별 조회 모드
if (!year || !month) { if (!year || !month) {
return reply.code(400).send({ error: 'search, startDate, 또는 year/month는 필수입니다.' }); return reply.code(400).send({ error: 'search 또는 year/month는 필수입니다.' });
} }
return await getMonthlySchedules(db, parseInt(year), parseInt(month)); return await handleMonthlySchedules(db, parseInt(year), parseInt(month));
}); });
/** /**
@ -83,17 +71,7 @@ export default async function schedulesRoutes(fastify) {
schema: { schema: {
tags: ['schedules'], tags: ['schedules'],
summary: 'Meilisearch 전체 동기화', summary: 'Meilisearch 전체 동기화',
description: 'DB의 모든 일정을 Meilisearch에 동기화합니다.',
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
synced: { type: 'integer', description: '동기화된 일정 수' },
},
},
},
}, },
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
@ -103,17 +81,12 @@ export default async function schedulesRoutes(fastify) {
/** /**
* GET /api/schedules/:id * GET /api/schedules/:id
* 일정 상세 조회 (카테고리별 다른 형식 반환) * 일정 상세 조회
*/ */
fastify.get('/:id', { fastify.get('/:id', {
schema: { schema: {
tags: ['schedules'], tags: ['schedules'],
summary: '일정 상세 조회', summary: '일정 상세 조회',
description: '일정 ID로 상세 정보를 조회합니다. 카테고리에 따라 추가 정보(YouTube/X)가 포함됩니다.',
params: idParam,
response: {
200: { type: 'object', additionalProperties: true },
},
}, },
}, async (request, reply) => { }, async (request, reply) => {
const { id } = request.params; const { id } = request.params;
@ -126,9 +99,7 @@ export default async function schedulesRoutes(fastify) {
sy.channel_name as youtube_channel, sy.channel_name as youtube_channel,
sy.video_id as youtube_video_id, sy.video_id as youtube_video_id,
sy.video_type as youtube_video_type, sy.video_type as youtube_video_type,
sx.post_id as x_post_id, sx.post_id as x_post_id
sx.content as x_content,
sx.image_urls as x_image_urls
FROM schedules s FROM schedules s
LEFT JOIN schedule_categories c ON s.category_id = c.id 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_youtube sy ON s.id = sy.schedule_id
@ -141,62 +112,34 @@ export default async function schedulesRoutes(fastify) {
} }
const s = schedules[0]; const s = schedules[0];
// 멤버 정보 조회
const [members] = await db.query(`
SELECT m.id, m.name
FROM schedule_members sm
JOIN members m ON sm.member_id = m.id
WHERE sm.schedule_id = ?
ORDER BY m.id
`, [id]);
// datetime 생성 (date + time)
const dateStr = s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date?.split('T')[0];
const timeStr = s.time ? s.time.slice(0, 5) : null;
const datetime = timeStr ? `${dateStr} ${timeStr}` : dateStr;
// 공통 필드
const result = { const result = {
id: s.id, id: s.id,
title: s.title, title: s.title,
datetime, date: s.date,
time: s.time,
category: { category: {
id: s.category_id, id: s.category_id,
name: s.category_name, name: s.category_name,
color: s.category_color, color: s.category_color,
}, },
members, created_at: s.created_at,
createdAt: s.created_at, updated_at: s.updated_at,
updatedAt: s.updated_at,
}; };
// 카테고리별 추가 필드 // source 정보 추가 (YouTube: 2, X: 3)
if (s.category_id === CATEGORY_IDS.YOUTUBE && s.youtube_video_id) { if (s.category_id === 2 && s.youtube_video_id) {
// YouTube const videoUrl = s.youtube_video_type === 'shorts'
result.videoId = s.youtube_video_id;
result.videoType = s.youtube_video_type;
result.channelName = s.youtube_channel;
result.videoUrl = s.youtube_video_type === 'shorts'
? `https://www.youtube.com/shorts/${s.youtube_video_id}` ? `https://www.youtube.com/shorts/${s.youtube_video_id}`
: `https://www.youtube.com/watch?v=${s.youtube_video_id}`; : `https://www.youtube.com/watch?v=${s.youtube_video_id}`;
} else if (s.category_id === CATEGORY_IDS.X && s.x_post_id) { result.source = {
// X (Twitter) name: s.youtube_channel || 'YouTube',
const username = config.x.defaultUsername; url: videoUrl,
result.postId = s.x_post_id; };
result.content = s.x_content || null; } else if (s.category_id === 3 && s.x_post_id) {
result.imageUrls = s.x_image_urls ? JSON.parse(s.x_image_urls) : []; result.source = {
result.postUrl = `https://x.com/${username}/status/${s.x_post_id}`; name: '',
url: `https://x.com/realfromis_9/status/${s.x_post_id}`,
// 프로필 정보 (Redis 캐시 → DB) };
const profile = await fastify.xBot.getProfile(username);
if (profile) {
result.profile = {
username: profile.username,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
};
}
} }
return result; return result;
@ -210,18 +153,7 @@ export default async function schedulesRoutes(fastify) {
schema: { schema: {
tags: ['schedules'], tags: ['schedules'],
summary: '일정 삭제', summary: '일정 삭제',
description: '일정과 관련 데이터(YouTube/X 정보, 멤버, 이미지)를 삭제합니다.',
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
params: idParam,
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
},
},
404: errorResponse,
},
}, },
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
@ -291,6 +223,155 @@ async function saveSearchQueryAsync(fastify, query) {
const service = new SuggestionService(fastify.db, fastify.redis); const service = new SuggestionService(fastify.db, fastify.redis);
await service.saveSearchQuery(query); await service.saveSearchQuery(query);
} catch (err) { } catch (err) {
fastify.log.error(`[Search] 검색어 저장 실패: ${err.message}`); console.error('[Search] 검색어 저장 실패:', err.message);
} }
} }
/**
* 월별 일정 조회 (생일 포함)
*/
async function handleMonthlySchedules(db, year, month) {
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
const endDate = new Date(year, month, 0).toISOString().split('T')[0];
// 일정 조회 (YouTube, X 소스 정보 포함)
const [schedules] = 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 youtube_channel,
sy.video_id as youtube_video_id,
sy.video_type as youtube_video_type,
sx.post_id as x_post_id
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_x sx ON s.id = sx.schedule_id
WHERE s.date BETWEEN ? AND ?
ORDER BY s.date ASC, s.time ASC
`, [startDate, endDate]);
// 생일 조회
const [birthdays] = await db.query(`
SELECT m.id, m.name, m.name_en, m.birth_date,
i.thumb_url as image_url
FROM members m
LEFT JOIN images i ON m.image_id = i.id
WHERE m.is_former = 0 AND MONTH(m.birth_date) = ?
`, [month]);
// 날짜별로 그룹화
const grouped = {};
// 일정 추가
for (const s of schedules) {
const dateKey = s.date instanceof Date
? s.date.toISOString().split('T')[0]
: s.date;
if (!grouped[dateKey]) {
grouped[dateKey] = {
categories: [],
schedules: [],
};
}
const schedule = {
id: s.id,
title: s.title,
time: s.time,
category: {
id: s.category_id,
name: s.category_name,
color: s.category_color,
},
};
// source 정보 추가 (YouTube: 2, X: 3)
if (s.category_id === 2 && s.youtube_video_id) {
const videoUrl = s.youtube_video_type === 'shorts'
? `https://www.youtube.com/shorts/${s.youtube_video_id}`
: `https://www.youtube.com/watch?v=${s.youtube_video_id}`;
schedule.source = {
name: s.youtube_channel || 'YouTube',
url: videoUrl,
};
} else if (s.category_id === 3 && s.x_post_id) {
schedule.source = {
name: '',
url: `https://x.com/realfromis_9/status/${s.x_post_id}`,
};
}
grouped[dateKey].schedules.push(schedule);
// 카테고리 카운트
const existingCategory = grouped[dateKey].categories.find(c => c.id === s.category_id);
if (existingCategory) {
existingCategory.count++;
} else {
grouped[dateKey].categories.push({
id: s.category_id,
name: s.category_name,
color: s.category_color,
count: 1,
});
}
}
// 생일 일정 추가
for (const member of birthdays) {
const birthDate = new Date(member.birth_date);
const birthYear = birthDate.getFullYear();
// 조회 연도가 생년보다 이전이면 스킵
if (year < birthYear) continue;
const birthdayThisYear = new Date(year, birthDate.getMonth(), birthDate.getDate());
const dateKey = birthdayThisYear.toISOString().split('T')[0];
if (!grouped[dateKey]) {
grouped[dateKey] = {
categories: [],
schedules: [],
};
}
// 생일 카테고리 (id: 8)
const BIRTHDAY_CATEGORY = {
id: 8,
name: '생일',
color: '#f472b6',
};
const birthdaySchedule = {
id: `birthday-${member.id}`,
title: `HAPPY ${member.name_en} DAY`,
time: null,
category: BIRTHDAY_CATEGORY,
is_birthday: true,
member_name: member.name,
member_image: member.image_url,
};
grouped[dateKey].schedules.push(birthdaySchedule);
// 생일 카테고리 카운트
const existingBirthdayCategory = grouped[dateKey].categories.find(c => c.id === 8);
if (existingBirthdayCategory) {
existingBirthdayCategory.count++;
} else {
grouped[dateKey].categories.push({
...BIRTHDAY_CATEGORY,
count: 1,
});
}
}
return grouped;
}

View file

@ -15,7 +15,7 @@ export default async function suggestionsRoutes(fastify) {
suggestionService = new SuggestionService(db, redis); suggestionService = new SuggestionService(db, redis);
// 비동기 초기화 (형태소 분석기 로드) // 비동기 초기화 (형태소 분석기 로드)
suggestionService.initialize().catch(err => { suggestionService.initialize().catch(err => {
fastify.log.error(`[Suggestions] 서비스 초기화 실패: ${err.message}`); console.error('[Suggestions] 서비스 초기화 실패:', err.message);
}); });
} }
@ -109,11 +109,11 @@ export default async function suggestionsRoutes(fastify) {
const { query } = request.body; const { query } = request.body;
if (!query || query.trim().length === 0) { if (!query || query.trim().length === 0) {
return reply.code(400).send({ error: '검색어가 필요합니다.' }); return { success: false };
} }
await suggestionService.saveSearchQuery(query); await suggestionService.saveSearchQuery(query);
return { message: '검색어가 저장되었습니다.' }; return { success: true };
}); });
/** /**
@ -168,6 +168,7 @@ export default async function suggestionsRoutes(fastify) {
200: { 200: {
type: 'object', type: 'object',
properties: { properties: {
success: { type: 'boolean' },
message: { type: 'string' }, message: { type: 'string' },
}, },
}, },
@ -184,10 +185,13 @@ export default async function suggestionsRoutes(fastify) {
// 형태소 분석기 리로드 // 형태소 분석기 리로드
await reloadMorpheme(); await reloadMorpheme();
return { message: '사전이 저장되었습니다.' }; return { success: true, message: '사전이 저장되었습니다.' };
} catch (error) { } catch (error) {
fastify.log.error(`[Suggestions] 사전 저장 오류: ${error.message}`); console.error('[Suggestions] 사전 저장 오류:', error.message);
return reply.code(500).send({ error: '사전 저장 중 오류가 발생했습니다.' }); return reply.code(500).send({
success: false,
message: '사전 저장 중 오류가 발생했습니다.',
});
} }
}); });
} }

View file

@ -70,7 +70,7 @@ export default async function statsRoutes(fastify, opts) {
}; };
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
return reply.code(500).send({ error: '통계 조회 실패' }); return reply.status(500).send({ error: '통계 조회 실패' });
} }
}); });
} }

View file

@ -1,59 +0,0 @@
/**
* 관리자 API 스키마 (YouTube, X)
*/
// ==================== YouTube ====================
export const youtubeVideoInfo = {
type: 'object',
properties: {
url: { type: 'string', description: 'YouTube URL' },
},
required: ['url'],
};
export const youtubeScheduleCreate = {
type: 'object',
properties: {
videoId: { type: 'string', minLength: 11, maxLength: 11, description: 'YouTube 영상 ID' },
title: { type: 'string', minLength: 1, maxLength: 500, description: '제목' },
channelId: { type: 'string', description: '채널 ID' },
channelName: { type: 'string', maxLength: 200, description: '채널명' },
date: { type: 'string', format: 'date', description: '날짜 (YYYY-MM-DD)' },
time: { type: 'string', pattern: '^\\d{2}:\\d{2}(:\\d{2})?$', description: '시간 (HH:MM 또는 HH:MM:SS)' },
videoType: { type: 'string', enum: ['video', 'shorts'], default: 'video', description: '영상 유형' },
},
required: ['videoId', 'title', 'date'],
};
export const youtubeScheduleUpdate = {
type: 'object',
properties: {
memberIds: { type: 'array', items: { type: 'integer' }, description: '멤버 ID 목록' },
videoType: { type: 'string', enum: ['video', 'shorts'], description: '영상 유형' },
},
};
// ==================== X (Twitter) ====================
export const xPostInfoQuery = {
type: 'object',
properties: {
postId: { type: 'string', pattern: '^\\d+$', description: '게시글 ID' },
username: { type: 'string', default: 'realfromis_9', description: '사용자명' },
},
required: ['postId'],
};
export const xScheduleCreate = {
type: 'object',
properties: {
postId: { type: 'string', pattern: '^\\d+$', description: '게시글 ID' },
title: { type: 'string', minLength: 1, maxLength: 500, description: '제목' },
content: { type: 'string', maxLength: 5000, description: '게시글 내용' },
imageUrls: { type: 'array', items: { type: 'string', format: 'uri' }, description: '이미지 URL 목록' },
date: { type: 'string', format: 'date', description: '날짜 (YYYY-MM-DD)' },
time: { type: 'string', pattern: '^\\d{2}:\\d{2}(:\\d{2})?$', description: '시간' },
},
required: ['postId', 'title', 'date'],
};

View file

@ -1,75 +0,0 @@
/**
* 앨범 스키마
*/
export const albumTrack = {
type: 'object',
properties: {
track_number: { type: 'integer', minimum: 1, description: '트랙 번호' },
title: { type: 'string', minLength: 1, maxLength: 200, description: '트랙 제목' },
duration: { type: 'string', pattern: '^\\d{1,2}:\\d{2}$', description: '재생 시간 (M:SS 또는 MM:SS)' },
is_title_track: { type: 'boolean', description: '타이틀곡 여부' },
lyricist: { type: 'string', maxLength: 500, description: '작사가' },
composer: { type: 'string', maxLength: 500, description: '작곡가' },
arranger: { type: 'string', maxLength: 500, description: '편곡가' },
lyrics: { type: 'string', description: '가사' },
music_video_url: { type: 'string', format: 'uri', description: '뮤직비디오 URL' },
},
required: ['track_number', 'title'],
};
export const albumCreate = {
type: 'object',
properties: {
title: { type: 'string', minLength: 1, maxLength: 200, description: '앨범 제목' },
album_type: { type: 'string', description: '앨범 유형 (정규, 미니, 싱글 등)' },
album_type_short: { type: 'string', maxLength: 20, description: '앨범 유형 약자' },
release_date: { type: 'string', format: 'date', description: '발매일 (YYYY-MM-DD)' },
folder_name: { type: 'string', pattern: '^[a-zA-Z0-9_-]+$', description: '폴더명 (영문, 숫자, -, _만 허용)' },
description: { type: 'string', maxLength: 2000, description: '앨범 설명' },
tracks: { type: 'array', items: albumTrack, description: '트랙 목록' },
},
required: ['title', 'album_type', 'release_date', 'folder_name'],
};
export const albumResponse = {
type: 'object',
properties: {
id: { type: 'integer' },
title: { type: 'string' },
album_type: { type: 'string' },
album_type_short: { type: 'string' },
release_date: { type: 'string' },
folder_name: { type: 'string' },
cover_original_url: { type: 'string' },
cover_medium_url: { type: 'string' },
cover_thumb_url: { type: 'string' },
description: { type: 'string' },
tracks: { type: 'array', items: albumTrack },
},
};
export const photoMetadata = {
type: 'object',
properties: {
conceptName: { type: 'string', maxLength: 100, description: '컨셉 이름' },
groupType: { type: 'string', enum: ['group', 'unit', 'solo'], description: '사진 유형' },
members: { type: 'array', items: { type: 'integer' }, description: '멤버 ID 목록' },
},
};
export const photoResponse = {
type: 'object',
properties: {
id: { type: 'integer' },
original_url: { type: 'string' },
medium_url: { type: 'string' },
thumb_url: { type: 'string' },
photo_type: { type: 'string' },
concept_name: { type: 'string' },
sort_order: { type: 'integer' },
width: { type: 'integer' },
height: { type: 'integer' },
members: { type: 'array', items: { type: 'integer' } },
},
};

View file

@ -1,20 +0,0 @@
/**
* 인증 스키마
*/
export const loginRequest = {
type: 'object',
properties: {
username: { type: 'string', minLength: 1, maxLength: 50, description: '사용자명' },
password: { type: 'string', minLength: 1, maxLength: 100, description: '비밀번호' },
},
required: ['username', 'password'],
};
export const loginResponse = {
type: 'object',
properties: {
token: { type: 'string', description: 'JWT 토큰' },
expiresAt: { type: 'string', format: 'date-time', description: '만료 시간' },
},
};

View file

@ -1,34 +0,0 @@
/**
* 공통 스키마
*/
export const errorResponse = {
type: 'object',
properties: {
error: { type: 'string', description: '에러 메시지' },
},
required: ['error'],
};
export const successResponse = {
type: 'object',
properties: {
message: { type: 'string', description: '성공 메시지' },
},
};
export const paginationQuery = {
type: 'object',
properties: {
offset: { type: 'integer', default: 0, minimum: 0, description: '페이지 오프셋' },
limit: { type: 'integer', default: 20, minimum: 1, maximum: 100, description: '결과 개수' },
},
};
export const idParam = {
type: 'object',
properties: {
id: { type: 'integer', minimum: 1, description: 'ID' },
},
required: ['id'],
};

View file

@ -1,11 +0,0 @@
/**
* JSON Schema 정의
* 입력 검증 Swagger 문서화에 사용
*/
export * from './common.js';
export * from './album.js';
export * from './schedule.js';
export * from './admin.js';
export * from './member.js';
export * from './auth.js';

View file

@ -1,15 +0,0 @@
/**
* 멤버 스키마
*/
export const memberResponse = {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
name_en: { type: 'string' },
birth_date: { type: 'string' },
position: { type: 'string' },
is_former: { type: 'boolean' },
},
};

View file

@ -1,57 +0,0 @@
/**
* 일정 스키마
*/
export const scheduleCategory = {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
color: { type: 'string' },
sort_order: { type: 'integer' },
},
};
export const scheduleMember = {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
},
};
export const scheduleResponse = {
type: 'object',
properties: {
id: { type: 'integer' },
title: { type: 'string' },
datetime: { type: 'string' },
category: scheduleCategory,
members: { type: 'array', items: scheduleMember },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
};
export const scheduleSearchQuery = {
type: 'object',
properties: {
search: { type: 'string', description: '검색어' },
year: { type: 'integer', minimum: 2000, maximum: 2100, description: '년도' },
month: { type: 'integer', minimum: 1, maximum: 12, description: '월' },
startDate: { type: 'string', format: 'date', description: '시작 날짜' },
offset: { type: 'integer', default: 0, minimum: 0, description: '페이지 오프셋' },
limit: { type: 'integer', default: 100, minimum: 1, maximum: 1000, description: '결과 개수' },
},
};
export const scheduleSearchResponse = {
type: 'object',
properties: {
schedules: { type: 'array', items: scheduleResponse },
total: { type: 'integer' },
offset: { type: 'integer' },
limit: { type: 'integer' },
hasMore: { type: 'boolean' },
},
};

View file

@ -1,255 +0,0 @@
/**
* 앨범 서비스
* 앨범 관련 비즈니스 로직
*/
import { uploadAlbumCover, deleteAlbumCover } from './image.js';
import { withTransaction } from '../utils/transaction.js';
/**
* 앨범 상세 정보 조회 (트랙, 티저, 컨셉포토 포함)
* @param {object} db - 데이터베이스 연결
* @param {object} album - 앨범 기본 정보
* @returns {object} 상세 정보가 포함된 앨범
*/
export async function getAlbumDetails(db, album) {
// 트랙, 티저, 포토 병렬 조회
const [[tracks], [teasers], [photos]] = await Promise.all([
db.query(
'SELECT * FROM album_tracks WHERE album_id = ? ORDER BY track_number',
[album.id]
),
db.query(
`SELECT original_url, medium_url, thumb_url, video_url, media_type
FROM album_teasers WHERE album_id = ? ORDER BY sort_order`,
[album.id]
),
db.query(
`SELECT
p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name, p.sort_order,
p.width, p.height,
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ', ') as members
FROM album_photos p
LEFT JOIN album_photo_members pm ON p.id = pm.photo_id
LEFT JOIN members m ON pm.member_id = m.id
WHERE p.album_id = ?
GROUP BY p.id
ORDER BY p.sort_order`,
[album.id]
),
]);
album.tracks = tracks;
album.teasers = teasers;
const conceptPhotos = {};
for (const photo of photos) {
const concept = photo.concept_name || 'Default';
if (!conceptPhotos[concept]) {
conceptPhotos[concept] = [];
}
conceptPhotos[concept].push({
id: photo.id,
original_url: photo.original_url,
medium_url: photo.medium_url,
thumb_url: photo.thumb_url,
width: photo.width,
height: photo.height,
type: photo.photo_type,
members: photo.members,
sortOrder: photo.sort_order,
});
}
album.conceptPhotos = conceptPhotos;
return album;
}
/**
* 앨범 목록과 트랙 조회 (N+1 최적화)
* @param {object} db - 데이터베이스 연결
* @returns {array} 트랙 포함된 앨범 목록
*/
export async function getAlbumsWithTracks(db) {
const [albums] = await db.query(`
SELECT id, title, folder_name, album_type, album_type_short, release_date,
cover_original_url, cover_medium_url, cover_thumb_url, description
FROM albums
ORDER BY release_date DESC
`);
if (albums.length === 0) return albums;
// 모든 트랙을 한 번에 조회
const albumIds = albums.map(a => a.id);
const [allTracks] = await db.query(
`SELECT id, album_id, track_number, title, is_title_track, duration, lyricist, composer, arranger
FROM album_tracks WHERE album_id IN (?) ORDER BY album_id, track_number`,
[albumIds]
);
// 앨범 ID별로 트랙 그룹화
const tracksByAlbum = {};
for (const track of allTracks) {
if (!tracksByAlbum[track.album_id]) {
tracksByAlbum[track.album_id] = [];
}
tracksByAlbum[track.album_id].push(track);
}
// 각 앨범에 트랙 할당
for (const album of albums) {
album.tracks = tracksByAlbum[album.id] || [];
}
return albums;
}
/**
* 트랙 일괄 삽입
* @param {object} connection - DB 연결
* @param {number} albumId - 앨범 ID
* @param {array} tracks - 트랙 목록
*/
async function insertTracks(connection, albumId, tracks) {
if (!tracks || tracks.length === 0) return;
const values = tracks.map(track => [
albumId,
track.track_number,
track.title,
track.duration || null,
track.is_title_track ? 1 : 0,
track.lyricist || null,
track.composer || null,
track.arranger || null,
track.lyrics || null,
track.music_video_url || null,
]);
await connection.query(
`INSERT INTO album_tracks
(album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, music_video_url)
VALUES ?`,
[values]
);
}
/**
* 앨범 생성
* @param {object} db - 데이터베이스 연결
* @param {object} data - 앨범 데이터
* @param {Buffer|null} coverBuffer - 커버 이미지 버퍼
* @returns {object} 결과 메시지와 앨범 ID
*/
export async function createAlbum(db, data, coverBuffer) {
const { title, album_type, album_type_short, release_date, folder_name, description, tracks } = data;
return withTransaction(db, async (connection) => {
// 커버 이미지 업로드
let coverOriginalUrl = null;
let coverMediumUrl = null;
let coverThumbUrl = null;
if (coverBuffer) {
const urls = await uploadAlbumCover(folder_name, coverBuffer);
coverOriginalUrl = urls.originalUrl;
coverMediumUrl = urls.mediumUrl;
coverThumbUrl = urls.thumbUrl;
}
// 앨범 생성
const [albumResult] = await connection.query(
`INSERT INTO albums (title, album_type, album_type_short, release_date, folder_name,
cover_original_url, cover_medium_url, cover_thumb_url, description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[title, album_type, album_type_short || null, release_date, folder_name,
coverOriginalUrl, coverMediumUrl, coverThumbUrl, description || null]
);
const albumId = albumResult.insertId;
// 트랙 일괄 삽입
await insertTracks(connection, albumId, tracks);
return { message: '앨범이 생성되었습니다.', albumId };
});
}
/**
* 앨범 수정
* @param {object} db - 데이터베이스 연결
* @param {number} id - 앨범 ID
* @param {object} data - 앨범 데이터
* @param {Buffer|null} coverBuffer - 커버 이미지 버퍼
* @returns {object|null} 결과 메시지 또는 null(앨범 없음)
*/
export async function updateAlbum(db, id, data, coverBuffer) {
const { title, album_type, album_type_short, release_date, folder_name, description, tracks } = data;
// 앨범 존재 여부 먼저 확인 (트랜잭션 외부)
const [existingAlbums] = await db.query('SELECT * FROM albums WHERE id = ?', [id]);
if (existingAlbums.length === 0) {
return null;
}
const existing = existingAlbums[0];
return withTransaction(db, async (connection) => {
// 커버 이미지 처리
let coverOriginalUrl = existing.cover_original_url;
let coverMediumUrl = existing.cover_medium_url;
let coverThumbUrl = existing.cover_thumb_url;
if (coverBuffer) {
const urls = await uploadAlbumCover(folder_name, coverBuffer);
coverOriginalUrl = urls.originalUrl;
coverMediumUrl = urls.mediumUrl;
coverThumbUrl = urls.thumbUrl;
}
// 앨범 수정
await connection.query(
`UPDATE albums SET title = ?, album_type = ?, album_type_short = ?, release_date = ?,
folder_name = ?, cover_original_url = ?, cover_medium_url = ?,
cover_thumb_url = ?, description = ?
WHERE id = ?`,
[title, album_type, album_type_short || null, release_date, folder_name,
coverOriginalUrl, coverMediumUrl, coverThumbUrl, description || null, id]
);
// 기존 트랙 삭제 후 새로 삽입
await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]);
await insertTracks(connection, id, tracks);
return { message: '앨범이 수정되었습니다.' };
});
}
/**
* 앨범 삭제
* @param {object} db - 데이터베이스 연결
* @param {number} id - 앨범 ID
* @returns {object|null} 결과 메시지 또는 null(앨범 없음)
*/
export async function deleteAlbum(db, id) {
// 앨범 존재 여부 먼저 확인 (트랜잭션 외부)
const [existingAlbums] = await db.query('SELECT * FROM albums WHERE id = ?', [id]);
if (existingAlbums.length === 0) {
return null;
}
const album = existingAlbums[0];
return withTransaction(db, async (connection) => {
// 커버 이미지 삭제
if (album.cover_original_url && album.folder_name) {
await deleteAlbumCover(album.folder_name);
}
// 관련 데이터 삭제
await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]);
await connection.query('DELETE FROM albums WHERE id = ?', [id]);
return { message: '앨범이 삭제되었습니다.' };
});
}

View file

@ -1,9 +1,6 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import sharp from 'sharp'; import sharp from 'sharp';
import config from '../config/index.js'; import config from '../config/index.js';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('S3');
// S3 클라이언트 생성 // S3 클라이언트 생성
const s3Client = new S3Client({ const s3Client = new S3Client({
@ -19,9 +16,6 @@ const s3Client = new S3Client({
const BUCKET = config.s3.bucket; const BUCKET = config.s3.bucket;
const PUBLIC_URL = config.s3.publicUrl; const PUBLIC_URL = config.s3.publicUrl;
// 이미지 처리 설정
const { medium, thumb } = config.image;
/** /**
* 이미지를 3가지 해상도로 변환 * 이미지를 3가지 해상도로 변환
*/ */
@ -29,12 +23,12 @@ async function processImage(buffer) {
const [originalBuffer, mediumBuffer, thumbBuffer] = await Promise.all([ const [originalBuffer, mediumBuffer, thumbBuffer] = await Promise.all([
sharp(buffer).webp({ lossless: true }).toBuffer(), sharp(buffer).webp({ lossless: true }).toBuffer(),
sharp(buffer) sharp(buffer)
.resize(medium.width, null, { withoutEnlargement: true }) .resize(800, null, { withoutEnlargement: true })
.webp({ quality: medium.quality }) .webp({ quality: 85 })
.toBuffer(), .toBuffer(),
sharp(buffer) sharp(buffer)
.resize(thumb.width, null, { withoutEnlargement: true }) .resize(400, null, { withoutEnlargement: true })
.webp({ quality: thumb.quality }) .webp({ quality: 80 })
.toBuffer(), .toBuffer(),
]); ]);
@ -64,7 +58,7 @@ async function deleteFromS3(key) {
Key: key, Key: key,
})); }));
} catch (err) { } catch (err) {
logger.error(`삭제 오류 (${key}): ${err.message}`); console.error(`S3 삭제 오류 (${key}):`, err.message);
} }
} }

View file

@ -6,13 +6,9 @@
* - 일정 동기화 * - 일정 동기화
*/ */
import Inko from 'inko'; import Inko from 'inko';
import config, { CATEGORY_IDS } from '../../config/index.js';
import { createLogger } from '../../utils/logger.js';
const inko = new Inko(); const inko = new Inko();
const logger = createLogger('Meilisearch');
const INDEX_NAME = 'schedules'; const INDEX_NAME = 'schedules';
const MIN_SCORE = config.meilisearch.minScore;
/** /**
* 영문 자판으로 입력된 검색어인지 확인 * 영문 자판으로 입력된 검색어인지 확인
@ -90,9 +86,9 @@ export async function searchSchedules(meilisearch, db, query, options = {}) {
} }
} }
// 유사도 필터링 // 유사도 0.5 미만 필터링
let filteredHits = Array.from(allHits.values()) let filteredHits = Array.from(allHits.values())
.filter(hit => hit._rankingScore >= MIN_SCORE); .filter(hit => hit._rankingScore >= 0.5);
// 유사도 순 정렬 // 유사도 순 정렬
filteredHits.sort((a, b) => (b._rankingScore || 0) - (a._rankingScore || 0)); filteredHits.sort((a, b) => (b._rankingScore || 0) - (a._rankingScore || 0));
@ -113,7 +109,7 @@ export async function searchSchedules(meilisearch, db, query, options = {}) {
hasMore: offset + paginatedHits.length < total, hasMore: offset + paginatedHits.length < total,
}; };
} catch (err) { } catch (err) {
logger.error(`검색 오류: ${err.message}`); console.error('[Meilisearch] 검색 오류:', err.message);
return { hits: [], total: 0, offset: 0, limit, hasMore: false }; return { hits: [], total: 0, offset: 0, limit, hasMore: false };
} }
} }
@ -143,9 +139,11 @@ function formatScheduleResponse(hit) {
// source 객체 구성 (X는 name 비움) // source 객체 구성 (X는 name 비움)
let source = null; let source = null;
if (hit.category_id === CATEGORY_IDS.YOUTUBE && hit.source_name) { if (hit.category_id === 2 && hit.source_name) {
// YouTube
source = { name: hit.source_name, url: null }; source = { name: hit.source_name, url: null };
} else if (hit.category_id === CATEGORY_IDS.X) { } else if (hit.category_id === 3) {
// X (name 비움)
source = { name: '', url: null }; source = { name: '', url: null };
} }
@ -185,9 +183,9 @@ export async function addOrUpdateSchedule(meilisearch, schedule) {
}; };
await index.addDocuments([document]); await index.addDocuments([document]);
logger.info(`일정 추가/업데이트: ${schedule.id}`); console.log(`[Meilisearch] 일정 추가/업데이트: ${schedule.id}`);
} catch (err) { } catch (err) {
logger.error(`문서 추가 오류: ${err.message}`); console.error('[Meilisearch] 문서 추가 오류:', err.message);
} }
} }
@ -198,9 +196,9 @@ export async function deleteSchedule(meilisearch, scheduleId) {
try { try {
const index = meilisearch.index(INDEX_NAME); const index = meilisearch.index(INDEX_NAME);
await index.deleteDocument(scheduleId); await index.deleteDocument(scheduleId);
logger.info(`일정 삭제: ${scheduleId}`); console.log(`[Meilisearch] 일정 삭제: ${scheduleId}`);
} catch (err) { } catch (err) {
logger.error(`문서 삭제 오류: ${err.message}`); console.error('[Meilisearch] 문서 삭제 오류:', err.message);
} }
} }
@ -251,11 +249,11 @@ export async function syncAllSchedules(meilisearch, db) {
// 일괄 추가 // 일괄 추가
await index.addDocuments(documents); await index.addDocuments(documents);
logger.info(`${documents.length}개 일정 동기화 완료`); console.log(`[Meilisearch] ${documents.length}개 일정 동기화 완료`);
return documents.length; return documents.length;
} catch (err) { } catch (err) {
logger.error(`동기화 오류: ${err.message}`); console.error('[Meilisearch] 동기화 오류:', err.message);
return 0; return 0;
} }
} }

View file

@ -1,251 +0,0 @@
/**
* 스케줄 서비스
* 스케줄 관련 비즈니스 로직
*/
import config, { CATEGORY_IDS } from '../config/index.js';
/**
* 월별 일정 조회 (생일 포함)
* @param {object} db - 데이터베이스 연결
* @param {number} year - 연도
* @param {number} month -
* @returns {object} 날짜별로 그룹화된 일정
*/
export async function getMonthlySchedules(db, year, month) {
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
const endDate = new Date(year, month, 0).toISOString().split('T')[0];
// 일정 조회 (YouTube, X 소스 정보 포함)
const [schedules] = 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 youtube_channel,
sy.video_id as youtube_video_id,
sy.video_type as youtube_video_type,
sx.post_id as x_post_id
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_x sx ON s.id = sx.schedule_id
WHERE s.date BETWEEN ? AND ?
ORDER BY s.date ASC, s.time ASC
`, [startDate, endDate]);
// 일정 멤버 조회
const scheduleIds = schedules.map(s => s.id);
let memberMap = {};
if (scheduleIds.length > 0) {
const [scheduleMembers] = await db.query(`
SELECT sm.schedule_id, m.name
FROM schedule_members sm
JOIN members m ON sm.member_id = m.id
WHERE sm.schedule_id IN (?)
ORDER BY m.id
`, [scheduleIds]);
for (const sm of scheduleMembers) {
if (!memberMap[sm.schedule_id]) {
memberMap[sm.schedule_id] = [];
}
memberMap[sm.schedule_id].push({ name: sm.name });
}
}
// 생일 조회
const [birthdays] = await db.query(`
SELECT m.id, m.name, m.name_en, m.birth_date,
i.thumb_url as image_url
FROM members m
LEFT JOIN images i ON m.image_id = i.id
WHERE m.is_former = 0 AND MONTH(m.birth_date) = ?
`, [month]);
// 날짜별로 그룹화
const grouped = {};
// 일정 추가
for (const s of schedules) {
const dateKey = s.date instanceof Date
? s.date.toISOString().split('T')[0]
: s.date;
if (!grouped[dateKey]) {
grouped[dateKey] = {
categories: [],
schedules: [],
};
}
// 멤버 정보 (5명 이상이면 프로미스나인)
const scheduleMembers = memberMap[s.id] || [];
const members = scheduleMembers.length >= 5
? [{ name: '프로미스나인' }]
: scheduleMembers;
const schedule = {
id: s.id,
title: s.title,
time: s.time,
category: {
id: s.category_id,
name: s.category_name,
color: s.category_color,
},
members,
};
// source 정보 추가
if (s.category_id === CATEGORY_IDS.YOUTUBE && s.youtube_video_id) {
const videoUrl = s.youtube_video_type === 'shorts'
? `https://www.youtube.com/shorts/${s.youtube_video_id}`
: `https://www.youtube.com/watch?v=${s.youtube_video_id}`;
schedule.source = {
name: s.youtube_channel || 'YouTube',
url: videoUrl,
};
} else if (s.category_id === CATEGORY_IDS.X && s.x_post_id) {
schedule.source = {
name: '',
url: `https://x.com/${config.x.defaultUsername}/status/${s.x_post_id}`,
};
}
grouped[dateKey].schedules.push(schedule);
// 카테고리 카운트
const existingCategory = grouped[dateKey].categories.find(c => c.id === s.category_id);
if (existingCategory) {
existingCategory.count++;
} else {
grouped[dateKey].categories.push({
id: s.category_id,
name: s.category_name,
color: s.category_color,
count: 1,
});
}
}
// 생일 일정 추가
for (const member of birthdays) {
const birthDate = new Date(member.birth_date);
const birthYear = birthDate.getFullYear();
// 조회 연도가 생년보다 이전이면 스킵
if (year < birthYear) continue;
const birthdayThisYear = new Date(year, birthDate.getMonth(), birthDate.getDate());
const dateKey = birthdayThisYear.toISOString().split('T')[0];
if (!grouped[dateKey]) {
grouped[dateKey] = {
categories: [],
schedules: [],
};
}
// 생일 카테고리
const BIRTHDAY_CATEGORY = {
id: CATEGORY_IDS.BIRTHDAY,
name: '생일',
color: '#f472b6',
};
const birthdaySchedule = {
id: `birthday-${member.id}`,
title: `HAPPY ${member.name_en} DAY`,
time: null,
category: BIRTHDAY_CATEGORY,
is_birthday: true,
member_name: member.name,
member_image: member.image_url,
};
grouped[dateKey].schedules.push(birthdaySchedule);
// 생일 카테고리 카운트
const existingBirthdayCategory = grouped[dateKey].categories.find(c => c.id === CATEGORY_IDS.BIRTHDAY);
if (existingBirthdayCategory) {
existingBirthdayCategory.count++;
} else {
grouped[dateKey].categories.push({
...BIRTHDAY_CATEGORY,
count: 1,
});
}
}
return grouped;
}
/**
* 다가오는 일정 조회 (startDate부터 limit개)
* @param {object} db - 데이터베이스 연결
* @param {string} startDate - 시작 날짜
* @param {number} limit - 조회 개수
* @returns {array} 일정 목록
*/
export async function getUpcomingSchedules(db, startDate, limit) {
const [schedules] = 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
FROM schedules s
LEFT JOIN schedule_categories c ON s.category_id = c.id
WHERE s.date >= ?
ORDER BY s.date ASC, s.time ASC
LIMIT ?
`, [startDate, limit]);
// 멤버 정보 조회
const scheduleIds = schedules.map(s => s.id);
let memberMap = {};
if (scheduleIds.length > 0) {
const [scheduleMembers] = await db.query(`
SELECT sm.schedule_id, m.name
FROM schedule_members sm
JOIN members m ON sm.member_id = m.id
WHERE sm.schedule_id IN (?)
ORDER BY m.id
`, [scheduleIds]);
for (const sm of scheduleMembers) {
if (!memberMap[sm.schedule_id]) {
memberMap[sm.schedule_id] = [];
}
memberMap[sm.schedule_id].push({ name: sm.name });
}
}
// 결과 포맷팅
return schedules.map(s => {
const scheduleMembers = memberMap[s.id] || [];
const members = scheduleMembers.length >= 5
? [{ name: '프로미스나인' }]
: scheduleMembers;
return {
id: s.id,
title: s.title,
date: s.date,
time: s.time,
category_id: s.category_id,
category_name: s.category_name,
category_color: s.category_color,
members,
};
});
}

View file

@ -8,10 +8,8 @@
import Inko from 'inko'; import Inko from 'inko';
import { extractNouns, initMorpheme, isReady } from './morpheme.js'; import { extractNouns, initMorpheme, isReady } from './morpheme.js';
import { getChosung, isChosungOnly, isChosungMatch } from './chosung.js'; import { getChosung, isChosungOnly, isChosungMatch } from './chosung.js';
import { createLogger } from '../../utils/logger.js';
const inko = new Inko(); const inko = new Inko();
const logger = createLogger('Suggestion');
// 설정 // 설정
const CONFIG = { const CONFIG = {
@ -44,9 +42,9 @@ export class SuggestionService {
async initialize() { async initialize() {
try { try {
await initMorpheme(); await initMorpheme();
logger.info('서비스 초기화 완료'); console.log('[Suggestion] 서비스 초기화 완료');
} catch (error) { } catch (error) {
logger.error(`서비스 초기화 실패: ${error.message}`); console.error('[Suggestion] 서비스 초기화 실패:', error.message);
} }
} }
@ -81,7 +79,7 @@ export class SuggestionService {
if (this.isEnglishOnly(normalizedQuery)) { if (this.isEnglishOnly(normalizedQuery)) {
const korean = this.convertEnglishToKorean(normalizedQuery); const korean = this.convertEnglishToKorean(normalizedQuery);
if (korean) { if (korean) {
logger.debug(`한글 변환: "${normalizedQuery}" → "${korean}"`); console.log(`[Suggestion] 한글 변환: "${normalizedQuery}" → "${korean}"`);
normalizedQuery = korean; normalizedQuery = korean;
} }
} }
@ -133,9 +131,9 @@ export class SuggestionService {
} }
} }
logger.debug(`저장: "${normalizedQuery}" → 명사: [${nouns.join(', ')}]`); console.log(`[Suggestion] 저장: "${normalizedQuery}" → 명사: [${nouns.join(', ')}]`);
} catch (error) { } catch (error) {
logger.error(`저장 오류: ${error.message}`); console.error('[Suggestion] 저장 오류:', error.message);
} }
} }
@ -173,7 +171,7 @@ export class SuggestionService {
return await this.getPrefixSuggestions(searchQuery.trim(), koreanQuery?.trim(), limit); return await this.getPrefixSuggestions(searchQuery.trim(), koreanQuery?.trim(), limit);
} }
} catch (error) { } catch (error) {
logger.error(`조회 오류: ${error.message}`); console.error('[Suggestion] 조회 오류:', error.message);
return []; return [];
} }
} }
@ -202,7 +200,7 @@ export class SuggestionService {
return rows.map(r => `${prefix} ${r.word2}`); return rows.map(r => `${prefix} ${r.word2}`);
} catch (error) { } catch (error) {
logger.error(`Bi-gram 조회 오류: ${error.message}`); console.error('[Suggestion] Bi-gram 조회 오류:', error.message);
return []; return [];
} }
} }
@ -238,7 +236,7 @@ export class SuggestionService {
return rows.map(r => r.query); return rows.map(r => r.query);
} catch (error) { } catch (error) {
logger.error(`Prefix 조회 오류: ${error.message}`); console.error('[Suggestion] Prefix 조회 오류:', error.message);
return []; return [];
} }
} }
@ -260,7 +258,7 @@ export class SuggestionService {
return rows.map(r => r.word); return rows.map(r => r.word);
} catch (error) { } catch (error) {
logger.error(`초성 검색 오류: ${error.message}`); console.error('[Suggestion] 초성 검색 오류:', error.message);
return []; return [];
} }
} }
@ -295,7 +293,7 @@ export class SuggestionService {
return result; return result;
} catch (error) { } catch (error) {
logger.error(`인기 검색어 조회 오류: ${error.message}`); console.error('[Suggestion] 인기 검색어 조회 오류:', error.message);
return []; return [];
} }
} }

View file

@ -5,9 +5,6 @@
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { createLogger } from '../../utils/logger.js';
const logger = createLogger('Morpheme');
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@ -50,7 +47,7 @@ export async function initMorpheme() {
initPromise = (async () => { initPromise = (async () => {
try { try {
logger.info('kiwi-nlp 초기화 시작...'); console.log('[Morpheme] kiwi-nlp 초기화 시작...');
// kiwi-nlp 동적 import (ESM) // kiwi-nlp 동적 import (ESM)
const { KiwiBuilder } = await import('kiwi-nlp'); const { KiwiBuilder } = await import('kiwi-nlp');
@ -72,7 +69,7 @@ export async function initMorpheme() {
try { try {
modelFiles[filename] = new Uint8Array(readFileSync(filepath)); modelFiles[filename] = new Uint8Array(readFileSync(filepath));
} catch (err) { } catch (err) {
logger.warn(`모델 파일 로드 실패: ${filename}`); console.warn(`[Morpheme] 모델 파일 로드 실패: ${filename}`);
} }
} }
@ -81,18 +78,18 @@ export async function initMorpheme() {
try { try {
modelFiles[USER_DICT] = new Uint8Array(readFileSync(userDictPath)); modelFiles[USER_DICT] = new Uint8Array(readFileSync(userDictPath));
userDicts = [USER_DICT]; userDicts = [USER_DICT];
logger.info('사용자 사전 로드 완료'); console.log('[Morpheme] 사용자 사전 로드 완료');
} catch (err) { } catch (err) {
logger.warn('사용자 사전 없음, 기본 사전만 사용'); console.warn('[Morpheme] 사용자 사전 없음, 기본 사전만 사용');
} }
// Kiwi 인스턴스 생성 // Kiwi 인스턴스 생성
kiwi = await builder.build({ modelFiles, userDicts }); kiwi = await builder.build({ modelFiles, userDicts });
isInitialized = true; isInitialized = true;
logger.info('kiwi-nlp 초기화 완료'); console.log('[Morpheme] kiwi-nlp 초기화 완료');
} catch (error) { } catch (error) {
logger.error(`초기화 실패: ${error.message}`); console.error('[Morpheme] 초기화 실패:', error.message);
// 초기화 실패해도 서비스는 계속 동작 (fallback 사용) // 초기화 실패해도 서비스는 계속 동작 (fallback 사용)
} }
})(); })();
@ -117,7 +114,7 @@ export async function extractNouns(text) {
// kiwi가 초기화되지 않았으면 fallback // kiwi가 초기화되지 않았으면 fallback
if (!kiwi) { if (!kiwi) {
logger.warn('kiwi 미초기화, fallback 사용'); console.warn('[Morpheme] kiwi 미초기화, fallback 사용');
return fallbackExtract(text); return fallbackExtract(text);
} }
@ -144,7 +141,7 @@ export async function extractNouns(text) {
return nouns.length > 0 ? nouns : fallbackExtract(text); return nouns.length > 0 ? nouns : fallbackExtract(text);
} catch (error) { } catch (error) {
logger.error(`형태소 분석 오류: ${error.message}`); console.error('[Morpheme] 형태소 분석 오류:', error.message);
return fallbackExtract(text); return fallbackExtract(text);
} }
} }
@ -170,12 +167,12 @@ export function isReady() {
* 형태소 분석기 리로드 (사전 변경 호출) * 형태소 분석기 리로드 (사전 변경 호출)
*/ */
export async function reloadMorpheme() { export async function reloadMorpheme() {
logger.info('리로드 시작...'); console.log('[Morpheme] 리로드 시작...');
isInitialized = false; isInitialized = false;
kiwi = null; kiwi = null;
initPromise = null; initPromise = null;
await initMorpheme(); await initMorpheme();
logger.info('리로드 완료'); console.log('[Morpheme] 리로드 완료');
} }
/** /**

View file

@ -77,25 +77,6 @@ export function extractProfile(html) {
return profile; return profile;
} }
/**
* 트윗 HTML 컨텐츠에서 텍스트 추출 (링크는 원본 URL 사용)
*/
function extractTextFromHtml(html) {
return html
.replace(/<br\s*\/?>/g, '\n')
// <a> 태그: href에서 원본 URL 추출 (외부 링크만)
.replace(/<a[^>]*href="([^"]*)"[^>]*>([^<]*)<\/a>/g, (match, href, text) => {
// Nitter 내부 링크 (/search, /hashtag 등)는 표시 텍스트 사용
if (href.startsWith('/')) {
return text;
}
// 외부 링크는 href의 원본 URL 사용
return href;
})
.replace(/<[^>]+>/g, '')
.trim();
}
/** /**
* HTML에서 트윗 목록 파싱 * HTML에서 트윗 목록 파싱
*/ */
@ -125,7 +106,11 @@ export function parseTweets(html, username) {
const contentMatch = container.match(/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/); const contentMatch = container.match(/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/);
let text = ''; let text = '';
if (contentMatch) { if (contentMatch) {
text = extractTextFromHtml(contentMatch[1]); text = contentMatch[1]
.replace(/<br\s*\/?>/g, '\n')
.replace(/<a[^>]*>([^<]*)<\/a>/g, '$1')
.replace(/<[^>]+>/g, '')
.trim();
} }
// 이미지 // 이미지
@ -172,7 +157,11 @@ export async function fetchSingleTweet(nitterUrl, username, postId) {
const contentMatch = container.match(/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/); const contentMatch = container.match(/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/);
let text = ''; let text = '';
if (contentMatch) { if (contentMatch) {
text = extractTextFromHtml(contentMatch[1]); text = contentMatch[1]
.replace(/<br\s*\/?>/g, '\n')
.replace(/<a[^>]*>([^<]*)<\/a>/g, '$1')
.replace(/<[^>]+>/g, '')
.trim();
} }
// 이미지 // 이미지

View file

@ -1,9 +1,8 @@
import fp from 'fastify-plugin'; import fp from 'fastify-plugin';
import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.js'; import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.js';
import bots from '../../config/bots.js'; import bots from '../../config/bots.js';
import { CATEGORY_IDS } from '../../config/index.js';
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE; const YOUTUBE_CATEGORY_ID = 2;
const PLAYLIST_CACHE_PREFIX = 'yt_uploads:'; const PLAYLIST_CACHE_PREFIX = 'yt_uploads:';
async function youtubeBotPlugin(fastify, opts) { async function youtubeBotPlugin(fastify, opts) {

View file

@ -1,50 +0,0 @@
/**
* 에러 응답 유틸리티
* 일관된 에러 응답 형식 제공
*/
/**
* 에러 응답 전송
* @param {object} reply - Fastify reply 객체
* @param {number} statusCode - HTTP 상태 코드
* @param {string} message - 에러 메시지
* @returns {object} 에러 응답
*/
export function sendError(reply, statusCode, message) {
return reply.code(statusCode).send({ error: message });
}
/**
* 400 Bad Request
*/
export function badRequest(reply, message = '잘못된 요청입니다.') {
return sendError(reply, 400, message);
}
/**
* 401 Unauthorized
*/
export function unauthorized(reply, message = '인증이 필요합니다.') {
return sendError(reply, 401, message);
}
/**
* 404 Not Found
*/
export function notFound(reply, message = '리소스를 찾을 수 없습니다.') {
return sendError(reply, 404, message);
}
/**
* 409 Conflict
*/
export function conflict(reply, message = '이미 존재하는 리소스입니다.') {
return sendError(reply, 409, message);
}
/**
* 500 Internal Server Error
*/
export function serverError(reply, message = '서버 오류가 발생했습니다.') {
return sendError(reply, 500, message);
}

View file

@ -1,43 +0,0 @@
/**
* 로거 유틸리티
* 서비스 레이어에서 사용할 있는 간단한 로깅 유틸리티
*/
const PREFIX = {
info: '[INFO]',
warn: '[WARN]',
error: '[ERROR]',
debug: '[DEBUG]',
};
function formatMessage(level, context, message) {
const timestamp = new Date().toISOString();
return `${timestamp} ${PREFIX[level]} [${context}] ${message}`;
}
/**
* 로거 생성
* @param {string} context - 로깅 컨텍스트 (: 'Meilisearch', 'Suggestions')
* @returns {object} 로거 객체
*/
export function createLogger(context) {
return {
info: (message, ...args) => {
console.log(formatMessage('info', context, message), ...args);
},
warn: (message, ...args) => {
console.warn(formatMessage('warn', context, message), ...args);
},
error: (message, ...args) => {
console.error(formatMessage('error', context, message), ...args);
},
debug: (message, ...args) => {
if (process.env.DEBUG) {
console.debug(formatMessage('debug', context, message), ...args);
}
},
};
}
// 기본 로거 (컨텍스트 없음)
export default createLogger('App');

View file

@ -1,34 +0,0 @@
/**
* 트랜잭션 헬퍼 유틸리티
* 반복되는 트랜잭션 패턴 추상화
*/
/**
* 트랜잭션 래퍼 함수
* @param {object} db - 데이터베이스 연결
* @param {function} callback - 트랜잭션 내에서 실행할 함수 (connection을 인자로 받음)
* @returns {Promise<any>} callback의 반환값
* @throws callback에서 발생한 에러 (자동 롤백 재throw)
*
* @example
* const result = await withTransaction(db, async (connection) => {
* await connection.query('INSERT INTO ...');
* await connection.query('UPDATE ...');
* return { success: true };
* });
*/
export async function withTransaction(db, callback) {
const connection = await db.getConnection();
try {
await connection.beginTransaction();
const result = await callback(connection);
await connection.commit();
return result;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}

View file

@ -7,8 +7,8 @@ Base URL: `/api`
### POST /auth/login ### POST /auth/login
로그인 (JWT 토큰 발급) 로그인 (JWT 토큰 발급)
### GET /auth/verify ### GET /auth/me
토큰 검증 및 사용자 정보 (인증 필요) 현재 사용자 정보 (인증 필요)
--- ---
@ -38,13 +38,10 @@ Base URL: `/api`
일정 조회 일정 조회
**Query Parameters:** **Query Parameters:**
- `year`, `month` - 월별 조회 - `year`, `month` - 월별 조회 (필수, search 없을 때)
- `startDate` - 시작 날짜 (YYYY-MM-DD), 다가오는 일정 조회
- `search` - 검색어 (Meilisearch 사용) - `search` - 검색어 (Meilisearch 사용)
- `offset`, `limit` - 페이징 - `offset`, `limit` - 페이징
`search`, `startDate`, `year/month` 중 하나는 필수
**월별 조회 응답:** **월별 조회 응답:**
```json ```json
{ {
@ -73,23 +70,6 @@ Base URL: `/api`
- X (category_id=3): `{ name: "", url: "https://x.com/realfromis_9/status/..." }` (name 빈 문자열) - X (category_id=3): `{ name: "", url: "https://x.com/realfromis_9/status/..." }` (name 빈 문자열)
- 기타 카테고리: source 없음 - 기타 카테고리: source 없음
**다가오는 일정 응답 (startDate):**
```json
[
{
"id": 123,
"title": "...",
"date": "2026-01-18",
"time": "19:00:00",
"category_id": 2,
"category_name": "유튜브",
"category_color": "#ff0033",
"members": [{ "name": "송하영" }]
}
]
```
※ 멤버가 5명 이상이면 `[{ "name": "프로미스나인" }]` 반환
**검색 응답:** **검색 응답:**
```json ```json
{ {
@ -149,49 +129,6 @@ Meilisearch 전체 동기화 (인증 필요)
} }
``` ```
### GET /schedules/suggestions/popular
인기 검색어 조회
**Query Parameters:**
- `limit` - 결과 개수 (기본 10)
**응답:**
```json
{
"queries": ["프로미스나인", "송하영", "이서연"]
}
```
### POST /schedules/suggestions/save
검색어 저장 (검색 실행 시 호출)
**Request Body:**
```json
{
"query": "검색어"
}
```
### GET /schedules/suggestions/dict
사용자 사전 조회 (인증 필요)
**응답:**
```json
{
"content": "프로미스나인\t프로미스나인\tNNP\n..."
}
```
### PUT /schedules/suggestions/dict
사용자 사전 저장 (인증 필요)
**Request Body:**
```json
{
"content": "프로미스나인\t프로미스나인\tNNP\n..."
}
```
--- ---
## 관리자 - 봇 관리 (인증 필요) ## 관리자 - 봇 관리 (인증 필요)
@ -290,18 +227,6 @@ YouTube 일정 저장
} }
``` ```
### PUT /admin/youtube/schedule/:id
YouTube 일정 수정 (멤버, 영상 유형)
**Request Body:**
```json
{
"memberIds": [1, 2, 3],
"videoType": "video"
}
```
`videoType`: "video" 또는 "shorts"
--- ---
## 관리자 - X (인증 필요) ## 관리자 - X (인증 필요)

View file

@ -16,22 +16,12 @@ fromis_9/
│ │ │ ├── meilisearch.js # 검색 엔진 │ │ │ ├── meilisearch.js # 검색 엔진
│ │ │ └── scheduler.js # 봇 스케줄러 │ │ │ └── scheduler.js # 봇 스케줄러
│ │ ├── routes/ # API 라우트 │ │ ├── routes/ # API 라우트
│ │ │ ├── admin/ # 관리자 API │ │ │ ├── auth/
│ │ │ │ ├── bots.js # 봇 관리
│ │ │ │ ├── youtube.js # YouTube 일정 관리
│ │ │ │ └── x.js # X 일정 관리
│ │ │ ├── albums/
│ │ │ │ ├── index.js # 앨범 CRUD
│ │ │ │ ├── photos.js # 앨범 사진 관리
│ │ │ │ └── teasers.js # 앨범 티저 관리
│ │ │ ├── auth.js # 인증 (로그인, 토큰 검증)
│ │ │ ├── members/ │ │ │ ├── members/
│ │ │ │ └── index.js # 멤버 조회/수정 │ │ │ ├── albums/
│ │ │ ├── schedules/ │ │ │ ├── schedules/
│ │ │ │ ├── index.js # 일정 조회/검색/삭제 │ │ │ │ ├── index.js # 일정 조회/검색
│ │ │ │ └── suggestions.js # 추천 검색어 │ │ │ │ └── suggestions.js
│ │ │ ├── stats/
│ │ │ │ └── index.js # 통계 조회
│ │ │ └── index.js # 라우트 등록 │ │ │ └── index.js # 라우트 등록
│ │ ├── services/ # 비즈니스 로직 │ │ ├── services/ # 비즈니스 로직
│ │ │ ├── youtube/ # YouTube 봇 │ │ │ ├── youtube/ # YouTube 봇
@ -50,28 +40,12 @@ fromis_9/
│ │ ├── api/ # API 클라이언트 │ │ ├── api/ # API 클라이언트
│ │ │ ├── index.js # fetchApi 유틸 │ │ │ ├── index.js # fetchApi 유틸
│ │ │ ├── public/ # 공개 API │ │ │ ├── public/ # 공개 API
│ │ │ │ ├── albums.js
│ │ │ │ ├── members.js
│ │ │ │ └── schedules.js
│ │ │ └── admin/ # 어드민 API │ │ │ └── admin/ # 어드민 API
│ │ │ ├── albums.js
│ │ │ ├── auth.js
│ │ │ ├── bots.js
│ │ │ ├── categories.js
│ │ │ ├── members.js
│ │ │ ├── schedules.js
│ │ │ ├── stats.js
│ │ │ └── suggestions.js
│ │ ├── components/ # 공통 컴포넌트 │ │ ├── components/ # 공통 컴포넌트
│ │ │ └── common/
│ │ │ ├── Lightbox.jsx # 이미지 라이트박스 (PC)
│ │ │ └── LightboxIndicator.jsx
│ │ ├── pages/ │ │ ├── pages/
│ │ │ ├── pc/ # PC 페이지 │ │ │ ├── pc/ # PC 페이지
│ │ │ └── mobile/ # 모바일 페이지 │ │ │ └── mobile/ # 모바일 페이지
│ │ ├── stores/ # Zustand 스토어 │ │ ├── stores/ # Zustand 스토어
│ │ ├── utils/
│ │ │ └── date.js # dayjs 기반 날짜 유틸리티
│ │ └── App.jsx │ │ └── App.jsx
│ ├── vite.config.js │ ├── vite.config.js
│ ├── Dockerfile # 프론트엔드 컨테이너 │ ├── Dockerfile # 프론트엔드 컨테이너

View file

@ -164,82 +164,6 @@ docker exec caddy caddy reload --config /etc/caddy/Caddyfile
--- ---
## 프론트엔드 개발 가이드
### API 클라이언트 구조
```
src/api/
├── index.js # fetchApi 유틸 (에러 처리, 토큰 주입)
├── public/ # 공개 API (인증 불필요)
│ ├── albums.js # getAlbums, getAlbumByName, getTrack
│ ├── members.js # getMembers
│ └── schedules.js # getSchedules, getSchedule, getCategories
└── admin/ # 관리자 API (인증 필요)
├── auth.js # login, verifyToken
├── albums.js # createAlbum, updateAlbum, deleteAlbum, ...
├── bots.js # getBots, startBot, stopBot, syncBot
├── categories.js # getCategories
├── members.js # updateMember
├── schedules.js # getYoutubeInfo, saveYoutube, getXInfo, saveX, ...
├── stats.js # getStats
└── suggestions.js # getDict, saveDict
```
**사용 예시:**
```jsx
// 공개 API
import { getSchedules, getSchedule } from '@/api/public/schedules';
// 관리자 API
import { getBots, startBot } from '@/api/admin/bots';
```
### React Query 사용 (데이터 페칭)
데이터 페칭 시 `useEffect` 대신 `useQuery`를 사용합니다.
**이유:**
- `useEffect`는 React StrictMode에서 2번 실행됨 (개발 모드)
- `useQuery`는 자동 캐싱, 중복 요청 방지, 에러/로딩 상태 관리 제공
**예시:**
```jsx
// ❌ Bad - useEffect 사용
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(data => setData(data))
.finally(() => setLoading(false));
}, []);
// ✅ Good - useQuery 사용
import { useQuery } from '@tanstack/react-query';
const { data, isLoading } = useQuery({
queryKey: ['data'],
queryFn: () => fetch('/api/data').then(res => res.json()),
});
```
**캐시 무효화:**
```jsx
import { useQueryClient } from '@tanstack/react-query';
const queryClient = useQueryClient();
// 특정 쿼리 무효화
queryClient.invalidateQueries({ queryKey: ['schedules'] });
// 모든 쿼리 무효화
queryClient.invalidateQueries();
```
---
## 유용한 명령어 ## 유용한 명령어
```bash ```bash

View file

@ -2,7 +2,7 @@
## 개요 ## 개요
`backend-backup/` (Express) → `backend/` (Fastify)로 마이그레이션 완료 `backend-backup/` (Express) → `backend/` (Fastify)로 마이그레이션 진행 중
## 완료된 작업 ## 완료된 작업
@ -18,7 +18,7 @@
### API 라우트 (`src/routes/`) ### API 라우트 (`src/routes/`)
- [x] 인증 (`/api/auth`) - [x] 인증 (`/api/auth`)
- POST /login - 로그인 - POST /login - 로그인
- GET /verify - 토큰 검증 - GET /me - 현재 사용자 정보
- [x] 멤버 (`/api/members`) - [x] 멤버 (`/api/members`)
- GET / - 목록 조회 - GET / - 목록 조회
- GET /:name - 상세 조회 - GET /:name - 상세 조회
@ -40,37 +40,19 @@
- GET / - 목록 - GET / - 목록
- POST / - 업로드 - POST / - 업로드
- DELETE /:teaserId - 삭제 - DELETE /:teaserId - 삭제
- [x] 일정 (`/api/schedules`) - [x] 일정 (`/api/schedules`) - 조회만
- GET / - 월별 조회 (생일 포함) - GET / - 월별 조회 (생일 포함)
- GET /?search= - Meilisearch 검색 - GET /?search= - Meilisearch 검색
- GET /:id - 상세 조회 - GET /:id - 상세 조회
- DELETE /:id - 삭제
- POST /sync-search - Meilisearch 동기화 - POST /sync-search - Meilisearch 동기화
- [x] 추천 검색어 (`/api/schedules/suggestions`) - [x] 추천 검색어 (`/api/schedules/suggestions`)
- GET / - 추천 검색어 조회 - GET / - 추천 검색어 조회
- GET /popular - 인기 검색어 조회 - kiwi-nlp 형태소 분석
- POST /save - 검색어 저장 - bi-gram 자동완성
- GET /dict - 사용자 사전 조회 (관리자) - 초성 검색
- PUT /dict - 사용자 사전 저장 (관리자)
- [x] 통계 (`/api/stats`) - [x] 통계 (`/api/stats`)
- GET / - 대시보드 통계 - GET / - 대시보드 통계
### 관리자 API (`src/routes/admin/`)
- [x] 봇 관리 (`/api/admin/bots`)
- GET / - 봇 목록
- POST /:id/start - 봇 시작
- POST /:id/stop - 봇 정지
- POST /:id/sync-all - 전체 동기화
- GET /quota-warning - 할당량 경고 조회
- DELETE /quota-warning - 할당량 경고 해제
- [x] YouTube 관리 (`/api/admin/youtube`)
- GET /video-info - 영상 정보 조회
- POST /schedule - 일정 저장
- PUT /schedule/:id - 일정 수정
- [x] X 관리 (`/api/admin/x`)
- GET /post-info - 게시글 정보 조회
- POST /schedule - 일정 저장
### 서비스 (`src/services/`) ### 서비스 (`src/services/`)
- [x] YouTube 봇 (`services/youtube/`) - [x] YouTube 봇 (`services/youtube/`)
- 영상 자동 수집 - 영상 자동 수집
@ -82,33 +64,41 @@
- 일정 검색 - 일정 검색
- 전체 동기화 - 전체 동기화
- [x] 추천 검색어 (`services/suggestions/`) - [x] 추천 검색어 (`services/suggestions/`)
- 형태소 분석 (kiwi-nlp) - 형태소 분석
- bi-gram 빈도 - bi-gram 빈도
- 초성 검색
- 사용자 사전 관리
- [x] 이미지 업로드 (`services/image.js`) - [x] 이미지 업로드 (`services/image.js`)
- 앨범 커버 - 앨범 커버
- 멤버 이미지 - 멤버 이미지
- 앨범 사진/티저 - 앨범 사진/티저
## 남은 작업 (미구현) ## 남은 작업
### 일반 일정 CRUD ### 관리자 API (admin.js에서 마이그레이션 필요)
- [ ] POST /api/schedules - 일정 생성 (일반) - [ ] 일정 CRUD
- [ ] PUT /api/schedules/:id - 일정 수정 (일반) - POST /api/schedules - 생성
- PUT /api/schedules/:id - 수정
- DELETE /api/schedules/:id - 삭제
- [ ] 일정 카테고리 CRUD
- GET /api/schedule-categories - 목록
- POST /api/schedule-categories - 생성
- PUT /api/schedule-categories/:id - 수정
- DELETE /api/schedule-categories/:id - 삭제
- PUT /api/schedule-categories-order - 순서 변경
- [ ] 봇 관리 API
- GET /api/bots - 봇 목록
- POST /api/bots/:id/start - 봇 시작
- POST /api/bots/:id/stop - 봇 정지
- POST /api/bots/:id/sync-all - 전체 동기화
- [ ] 카카오 장소 검색 프록시
- GET /api/kakao/places - 장소 검색
- [ ] YouTube 할당량 관리
- POST /api/quota-alert - Webhook 수신
- GET /api/quota-warning - 경고 상태 조회
- DELETE /api/quota-warning - 경고 해제
※ 현재는 YouTube/X 전용 일정 생성 API만 구현됨 ### 기타 기능
- [ ] X 프로필 조회 (`/api/schedules/x-profile/:username`)
### 카테고리 관리 - [ ] 어드민 사전 관리 (형태소 분석용 사전)
- [ ] POST /api/schedule-categories - 생성
- [ ] PUT /api/schedule-categories/:id - 수정
- [ ] DELETE /api/schedule-categories/:id - 삭제
- [ ] PUT /api/schedule-categories-order - 순서 변경
※ GET은 구현됨 (목록 조회)
### 기타
- [ ] GET /api/kakao/places - 카카오 장소 검색 프록시
## 파일 비교표 ## 파일 비교표
@ -118,17 +108,14 @@
| routes/admin.js (앨범 CRUD) | routes/albums/index.js | 완료 | | routes/admin.js (앨범 CRUD) | routes/albums/index.js | 완료 |
| routes/admin.js (사진/티저) | routes/albums/photos.js, teasers.js | 완료 | | routes/admin.js (사진/티저) | routes/albums/photos.js, teasers.js | 완료 |
| routes/admin.js (멤버 수정) | routes/members/index.js | 완료 | | routes/admin.js (멤버 수정) | routes/members/index.js | 완료 |
| routes/admin.js (일정 삭제) | routes/schedules/index.js | 완료 | | routes/admin.js (일정 CRUD) | - | 미완료 |
| routes/admin.js (일정 생성/수정) | - | 미완료 | | routes/admin.js (카테고리) | - | 미완료 |
| routes/admin.js (카테고리 CUD) | - | 미완료 | | routes/admin.js (봇 관리) | - | 미완료 |
| routes/admin.js (봇 관리) | routes/admin/bots.js | 완료 |
| routes/admin.js (할당량) | routes/admin/bots.js | 완료 |
| routes/admin.js (카카오) | - | 미완료 | | routes/admin.js (카카오) | - | 미완료 |
| - | routes/admin/youtube.js | 신규 | | routes/admin.js (할당량) | - | 미완료 |
| - | routes/admin/x.js | 신규 |
| routes/albums.js | routes/albums/index.js | 완료 | | routes/albums.js | routes/albums/index.js | 완료 |
| routes/members.js | routes/members/index.js | 완료 | | routes/members.js | routes/members/index.js | 완료 |
| routes/schedules.js | routes/schedules/index.js | 완료 | | routes/schedules.js | routes/schedules/index.js | 부분 완료 |
| routes/stats.js | routes/stats/index.js | 완료 | | routes/stats.js | routes/stats/index.js | 완료 |
| services/youtube-bot.js | services/youtube/ | 완료 | | services/youtube-bot.js | services/youtube/ | 완료 |
| services/youtube-scheduler.js | plugins/scheduler.js | 완료 | | services/youtube-scheduler.js | plugins/scheduler.js | 완료 |

View file

@ -1,193 +0,0 @@
# Backend Refactoring Plan
백엔드 코드 품질 개선을 위한 리팩토링 계획서
## 완료된 작업
### 1단계: 설정 통합 (config 정리) ✅ 완료
- [x] 카테고리 ID 상수 통합 (`CATEGORY_IDS`)
**수정된 파일:**
- `src/config/index.js` - `CATEGORY_IDS` 상수 추가
- `src/routes/admin/youtube.js` - config에서 import
- `src/routes/admin/x.js` - config에서 import
- `src/routes/schedules/index.js` - 하드코딩된 2, 3, 8 → 상수로 변경
---
### 2단계: N+1 쿼리 최적화 ✅ 완료
- [x] 앨범 목록 조회 시 트랙 한 번에 조회로 변경
- [x] 스케줄 멤버 조회 - 이미 최적화됨 (확인 완료)
**수정된 파일:**
- `src/routes/albums/index.js` - GET /api/albums에서 트랙 조회 최적화
---
### 3단계: 서비스 레이어 분리 ✅ 완료
- [x] `src/services/album.js` 생성
- [x] `src/services/schedule.js` 생성
- [x] 라우트에서 서비스 호출로 변경
---
### 4단계: 에러 처리 통일 ✅ 완료
- [x] 에러 응답 유틸리티 생성 (`src/utils/error.js`)
- [x] `reply.status()``reply.code()` 통일
---
### 5단계: 중복 코드 제거 ✅ 완료
- [x] 스케줄러 상태 업데이트 로직 통합 (handleSyncResult 함수)
- [x] youtube/index.js 하드코딩된 카테고리 ID → config 사용
---
## 추가 작업 목록
### 6단계: 매직 넘버 config 이동 ✅ 완료
- [x] 이미지 크기/품질 설정 (`services/image.js`)
- [x] X 기본 사용자명 (`routes/admin/x.js`, `routes/schedules/index.js`, `services/schedule.js`)
- [x] Meilisearch 최소 점수 (`services/meilisearch/index.js`)
**수정된 파일:**
- `src/config/index.js` - `image`, `x`, `meilisearch.minScore` 추가
- `src/services/image.js` - config에서 이미지 크기/품질 참조
- `src/services/meilisearch/index.js` - config에서 minScore 참조
- `src/routes/admin/x.js` - config에서 defaultUsername 참조
- `src/routes/schedules/index.js` - config에서 defaultUsername 참조
- `src/services/schedule.js` - config에서 defaultUsername 참조
---
### 7단계: 순차 쿼리 → 병렬 처리 ✅ 완료
- [x] `services/album.js` getAlbumDetails - tracks, teasers, photos 병렬 조회
- [x] `routes/albums/photos.js` - 멤버 INSERT 배치 처리
**수정된 파일:**
- `src/services/album.js` - Promise.all로 3개 쿼리 병렬 실행
- `src/routes/albums/photos.js` - for loop → VALUES ? 배치 INSERT
---
### 8단계: meilisearch 카테고리 ID 상수화 ✅ 완료
- [x] `services/meilisearch/index.js` - 하드코딩된 2, 3 → CATEGORY_IDS 사용
**수정된 파일:**
- `src/services/meilisearch/index.js` - CATEGORY_IDS.YOUTUBE, CATEGORY_IDS.X 사용
---
### 9단계: 응답 형식 통일 ✅ 완료
- [x] `routes/schedules/suggestions.js` - `{success, message}``{error}` 또는 `{message}` 형식으로 통일
**수정된 파일:**
- `src/routes/schedules/suggestions.js` - 응답 형식 통일
---
### 10단계: 로거 통일 ✅ 완료
- [x] `src/utils/logger.js` 생성
- [x] 모든 `console.error/log` → logger 또는 fastify.log 사용
**수정된 파일:**
- `src/utils/logger.js` - 로거 유틸리티 생성 (createLogger)
- `src/services/image.js` - logger 사용
- `src/services/meilisearch/index.js` - logger 사용
- `src/services/suggestions/index.js` - logger 사용
- `src/services/suggestions/morpheme.js` - logger 사용
- `src/routes/albums/photos.js` - fastify.log 사용
- `src/routes/schedules/index.js` - fastify.log 사용
- `src/routes/schedules/suggestions.js` - fastify.log 사용
---
### 11단계: 대형 핸들러 분리 ✅ 완료
- [x] `routes/albums/index.js` POST/PUT/DELETE → 서비스 함수로 분리
- [ ] `routes/albums/photos.js` POST - SSE 스트리밍으로 인해 분리 보류
**수정된 파일:**
- `src/services/album.js` - createAlbum, updateAlbum, deleteAlbum, insertTracks 추가
- `src/routes/albums/index.js` - 서비스 함수 호출로 변경 (80줄 감소)
---
### 12단계: 트랜잭션 헬퍼 추상화 ✅ 완료
- [x] `src/utils/transaction.js` 생성 - withTransaction 함수
- [x] 반복되는 트랜잭션 패턴 추상화 적용
**수정된 파일:**
- `src/utils/transaction.js` - 트랜잭션 헬퍼 유틸리티 생성
- `src/services/album.js` - createAlbum, updateAlbum, deleteAlbum에 withTransaction 적용
- `src/routes/albums/photos.js` - DELETE 핸들러에 withTransaction 적용
- `src/routes/albums/teasers.js` - DELETE 핸들러에 withTransaction 적용
---
### 13단계: Swagger/OpenAPI 문서화 개선 ✅ 완료
- [x] `src/schemas/index.js` 생성 - 공통 스키마 정의
- [x] `src/app.js` - Swagger components에 스키마 등록
- [x] 태그 추가 (admin/youtube, admin/x, admin/bots)
**수정된 파일:**
- `src/schemas/index.js` - JSON Schema 정의 (Error, Success, Album, Schedule 등)
- `src/app.js` - Swagger 설정에 스키마 컴포넌트 추가
---
### 14단계: 입력 검증 강화 (JSON Schema) ✅ 완료
- [x] 라우트에 params, querystring, body, response 스키마 추가
- [x] 상세 description 추가로 API 문서 품질 향상
**수정된 파일:**
- `src/routes/albums/index.js` - GET/POST/PUT/DELETE 스키마 추가
- `src/routes/schedules/index.js` - 검색/조회/삭제 스키마 추가
- `src/routes/admin/youtube.js` - 영상 조회/일정 등록/수정 스키마 추가
- `src/routes/admin/x.js` - 게시글 조회/일정 등록 스키마 추가
- `src/routes/admin/bots.js` - 봇 관리 스키마 추가
---
### 15단계: 스키마 파일 분리 ✅ 완료
- [x] 단일 스키마 파일을 도메인별로 분리
- [x] Re-export 패턴으로 기존 import 호환성 유지
**생성된 파일:**
- `src/schemas/common.js` - 공통 스키마 (errorResponse, successResponse, paginationQuery, idParam)
- `src/schemas/album.js` - 앨범 관련 스키마
- `src/schemas/schedule.js` - 일정 관련 스키마
- `src/schemas/admin.js` - 관리자 API 스키마 (YouTube, X)
- `src/schemas/member.js` - 멤버 스키마
- `src/schemas/auth.js` - 인증 스키마
- `src/schemas/index.js` - 모든 스키마 re-export
---
## 진행 상황
| 단계 | 작업 | 상태 |
|------|------|------|
| 1단계 | 설정 통합 | ✅ 완료 |
| 2단계 | N+1 쿼리 최적화 | ✅ 완료 |
| 3단계 | 서비스 레이어 분리 | ✅ 완료 |
| 4단계 | 에러 처리 통일 | ✅ 완료 |
| 5단계 | 중복 코드 제거 | ✅ 완료 |
| 6단계 | 매직 넘버 config 이동 | ✅ 완료 |
| 7단계 | 순차→병렬 쿼리 | ✅ 완료 |
| 8단계 | meilisearch 카테고리 ID | ✅ 완료 |
| 9단계 | 응답 형식 통일 | ✅ 완료 |
| 10단계 | 로거 통일 | ✅ 완료 |
| 11단계 | 대형 핸들러 분리 | ✅ 완료 |
| 12단계 | 트랜잭션 헬퍼 추상화 | ✅ 완료 |
| 13단계 | Swagger/OpenAPI 문서화 | ✅ 완료 |
| 14단계 | 입력 검증 강화 (JSON Schema) | ✅ 완료 |
| 15단계 | 스키마 파일 분리 | ✅ 완료 |
---
## 참고사항
- 각 단계별로 커밋 후 다음 단계 진행
- 기존 API 응답 형식은 유지
- 프론트엔드 수정 불필요하도록 진행
- API 문서는 `/docs`에서 확인 가능 (Scalar API Reference)

View file

@ -8,7 +8,6 @@
"name": "fromis9-frontend", "name": "fromis9-frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.6",
"@tanstack/react-query": "^5.90.16", "@tanstack/react-query": "^5.90.16",
"@tanstack/react-virtual": "^3.13.18", "@tanstack/react-virtual": "^3.13.18",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
@ -23,7 +22,6 @@
"react-infinite-scroll-component": "^6.1.1", "react-infinite-scroll-component": "^6.1.1",
"react-intersection-observer": "^10.0.0", "react-intersection-observer": "^10.0.0",
"react-ios-time-picker": "^0.2.2", "react-ios-time-picker": "^0.2.2",
"react-linkify": "^1.0.0-alpha",
"react-photo-album": "^3.4.0", "react-photo-album": "^3.4.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-window": "^2.2.3", "react-window": "^2.2.3",
@ -287,15 +285,6 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@ -344,25 +333,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@emotion/is-prop-valid": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
"integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emotion/memoize": "^0.9.0"
}
},
"node_modules/@emotion/memoize": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@ -1313,18 +1283,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "25.0.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz",
"integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@ -2028,15 +1986,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/linkify-it": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz",
"integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==",
"license": "MIT",
"dependencies": {
"uc.micro": "^1.0.1"
}
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -2569,16 +2518,6 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-linkify": {
"version": "1.0.0-alpha",
"resolved": "https://registry.npmjs.org/react-linkify/-/react-linkify-1.0.0-alpha.tgz",
"integrity": "sha512-7gcIUvJkAXXttt1fmBK9cwn+1jTa4hbKLGCZ9J1U6EOkyb2/+LKL1Z28d9rtDLMnpvImlNlLPdTPooorl5cpmg==",
"license": "MIT",
"dependencies": {
"linkify-it": "^2.0.3",
"tlds": "^1.199.0"
}
},
"node_modules/react-photo-album": { "node_modules/react-photo-album": {
"version": "3.4.0", "version": "3.4.0",
"resolved": "https://registry.npmjs.org/react-photo-album/-/react-photo-album-3.4.0.tgz", "resolved": "https://registry.npmjs.org/react-photo-album/-/react-photo-album-3.4.0.tgz",
@ -2991,15 +2930,6 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/tlds": {
"version": "1.261.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
"integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
"license": "MIT",
"bin": {
"tlds": "bin.js"
}
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -3052,21 +2982,6 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/uc.micro": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",

View file

@ -9,7 +9,6 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.6",
"@tanstack/react-query": "^5.90.16", "@tanstack/react-query": "^5.90.16",
"@tanstack/react-virtual": "^3.13.18", "@tanstack/react-virtual": "^3.13.18",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
@ -24,7 +23,6 @@
"react-infinite-scroll-component": "^6.1.1", "react-infinite-scroll-component": "^6.1.1",
"react-intersection-observer": "^10.0.0", "react-intersection-observer": "^10.0.0",
"react-ios-time-picker": "^0.2.2", "react-ios-time-picker": "^0.2.2",
"react-linkify": "^1.0.0-alpha",
"react-photo-album": "^3.4.0", "react-photo-album": "^3.4.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-window": "^2.2.3", "react-window": "^2.2.3",

View file

@ -42,7 +42,6 @@ import ScheduleFormPage from './pages/pc/admin/schedule/form';
import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory'; import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory';
import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots'; import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots';
import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict'; import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict';
import YouTubeEditForm from './pages/pc/admin/schedule/edit/YouTubeEditForm';
// //
import PCLayout from './components/pc/Layout'; import PCLayout from './components/pc/Layout';
@ -77,7 +76,6 @@ function App() {
<Route path="/admin/schedule/new" element={<ScheduleFormPage />} /> <Route path="/admin/schedule/new" element={<ScheduleFormPage />} />
<Route path="/admin/schedule/new-legacy" element={<AdminScheduleForm />} /> <Route path="/admin/schedule/new-legacy" element={<AdminScheduleForm />} />
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} /> <Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
<Route path="/admin/schedule/:id/edit/youtube" element={<YouTubeEditForm />} />
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} /> <Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} /> <Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
<Route path="/admin/schedule/dict" element={<AdminScheduleDict />} /> <Route path="/admin/schedule/dict" element={<AdminScheduleDict />} />

View file

@ -176,10 +176,8 @@ function MobileHome() {
const isCurrentYear = scheduleYear === currentYear; const isCurrentYear = scheduleYear === currentYear;
const isCurrentMonth = isCurrentYear && scheduleMonth === currentMonth; const isCurrentMonth = isCurrentYear && scheduleMonth === currentMonth;
// // (5 )
const memberList = schedule.member_names const memberList = schedule.member_names ? schedule.member_names.split(',').map(n => n.trim()).filter(Boolean) : [];
? schedule.member_names.split(',').map(n => n.trim()).filter(Boolean)
: schedule.members?.map(m => m.name) || [];
return ( return (
<motion.div <motion.div
@ -239,7 +237,7 @@ function MobileHome() {
{/* 멤버 */} {/* 멤버 */}
{memberList.length > 0 && ( {memberList.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2"> <div className="flex flex-wrap gap-1 mt-2">
{memberList.map((name, i) => ( {(memberList.length >= 5 ? ['프로미스나인'] : memberList).map((name, i) => (
<span <span
key={i} key={i}
className="px-2 py-0.5 bg-primary/10 text-primary text-[10px] rounded-full font-medium" className="px-2 py-0.5 bg-primary/10 text-primary text-[10px] rounded-full font-medium"

View file

@ -178,9 +178,16 @@ function MobileMembers() {
{member.name} {member.name}
</h2> </h2>
{/* 포지션 */}
{member.position && (
<p className="mt-2 text-base text-white/90 font-medium">
{member.position}
</p>
)}
{/* 생일 정보 */} {/* 생일 정보 */}
{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-3 text-white/80">
<Calendar size={16} className="text-white/70" /> <Calendar size={16} className="text-white/70" />
<span className="text-sm"> <span className="text-sm">
{member.birth_date?.slice(0, 10).replaceAll('-', '.')} {member.birth_date?.slice(0, 10).replaceAll('-', '.')}

View file

@ -324,13 +324,8 @@ function MobileSchedule() {
} }
}, [schedules, loading]); }, [schedules, loading]);
// 2017 1
const canGoPrevMonth = !(selectedDate.getFullYear() === 2017 && selectedDate.getMonth() === 0);
// //
const changeMonth = (delta) => { const changeMonth = (delta) => {
if (delta < 0 && !canGoPrevMonth) return;
const newDate = new Date(selectedDate); const newDate = new Date(selectedDate);
newDate.setMonth(newDate.getMonth() + delta); newDate.setMonth(newDate.getMonth() + delta);
@ -648,11 +643,7 @@ function MobileSchedule() {
> >
<Calendar size={20} className="text-gray-600" /> <Calendar size={20} className="text-gray-600" />
</button> </button>
<button <button onClick={() => changeMonth(-1)} className="p-2">
onClick={() => changeMonth(-1)}
disabled={!canGoPrevMonth}
className={`p-2 ${!canGoPrevMonth ? 'opacity-30' : ''}`}
>
<ChevronLeft size={20} /> <ChevronLeft size={20} />
</button> </button>
</div> </div>
@ -1021,14 +1012,20 @@ function ScheduleCard({ schedule, categoryColor, categories, delay = 0, onClick
{/* 멤버 */} {/* 멤버 */}
{memberList.length > 0 && ( {memberList.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-gray-100"> <div className="flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-gray-100">
{memberList.map((name, i) => ( {memberList.length >= 5 ? (
<span <span className="px-2.5 py-1 bg-gradient-to-r from-primary to-primary-dark text-white text-xs rounded-lg font-semibold shadow-sm">
key={i} 프로미스나인
className="px-2.5 py-1 bg-gradient-to-r from-primary to-primary-dark text-white text-xs rounded-lg font-semibold shadow-sm"
>
{name.trim()}
</span> </span>
))} ) : (
memberList.map((name, i) => (
<span
key={i}
className="px-2.5 py-1 bg-gradient-to-r from-primary to-primary-dark text-white text-xs rounded-lg font-semibold shadow-sm"
>
{name.trim()}
</span>
))
)}
</div> </div>
)} )}
</div> </div>
@ -1094,14 +1091,20 @@ function TimelineScheduleCard({ schedule, categoryColor, categories, delay = 0,
{/* 멤버 */} {/* 멤버 */}
{memberList.length > 0 && ( {memberList.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-gray-100"> <div className="flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-gray-100">
{memberList.map((name, i) => ( {memberList.length >= 5 ? (
<span <span className="px-2.5 py-1 bg-gradient-to-r from-primary to-primary-dark text-white text-xs rounded-lg font-semibold shadow-sm">
key={i} 프로미스나인
className="px-2.5 py-1 bg-gradient-to-r from-primary to-primary-dark text-white text-xs rounded-lg font-semibold shadow-sm"
>
{name.trim()}
</span> </span>
))} ) : (
memberList.map((name, i) => (
<span
key={i}
className="px-2.5 py-1 bg-gradient-to-r from-primary to-primary-dark text-white text-xs rounded-lg font-semibold shadow-sm"
>
{name.trim()}
</span>
))
)}
</div> </div>
)} )}
</div> </div>
@ -1164,9 +1167,6 @@ function CalendarPicker({
const year = viewDate.getFullYear(); const year = viewDate.getFullYear();
const month = viewDate.getMonth(); const month = viewDate.getMonth();
// 2017 1
const canGoPrevMonth = !(year === 2017 && month === 0);
// //
const getCalendarDays = useCallback((y, m) => { const getCalendarDays = useCallback((y, m) => {
const firstDay = new Date(y, m, 1); const firstDay = new Date(y, m, 1);
@ -1209,11 +1209,10 @@ function CalendarPicker({
}, []); }, []);
const changeMonth = useCallback((delta) => { const changeMonth = useCallback((delta) => {
if (delta < 0 && !canGoPrevMonth) return;
const newDate = new Date(viewDate); const newDate = new Date(viewDate);
newDate.setMonth(newDate.getMonth() + delta); newDate.setMonth(newDate.getMonth() + delta);
setViewDate(newDate); setViewDate(newDate);
}, [viewDate, canGoPrevMonth]); }, [viewDate]);
const isToday = (date) => { const isToday = (date) => {
const today = new Date(); const today = new Date();
@ -1240,7 +1239,7 @@ function CalendarPicker({
} }
}; };
const MIN_YEAR = 2017; const MIN_YEAR = 2025;
const [yearRangeStart, setYearRangeStart] = useState(MIN_YEAR); const [yearRangeStart, setYearRangeStart] = useState(MIN_YEAR);
const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i); const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i);
const canGoPrevYearRange = yearRangeStart > MIN_YEAR; const canGoPrevYearRange = yearRangeStart > MIN_YEAR;
@ -1453,8 +1452,7 @@ function CalendarPicker({
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<button <button
onClick={() => changeMonth(-1)} onClick={() => changeMonth(-1)}
disabled={!canGoPrevMonth} className="p-1"
className={`p-1 ${!canGoPrevMonth ? 'opacity-30' : ''}`}
> >
<ChevronLeft size={18} /> <ChevronLeft size={18} />
</button> </button>

View file

@ -2,12 +2,88 @@ import { useParams, Link } from 'react-router-dom';
import { useQuery, keepPreviousData } from '@tanstack/react-query'; import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight } from 'lucide-react'; import { Calendar, Clock, ChevronLeft, Check, Link2, MapPin, Navigation, ExternalLink } from 'lucide-react';
import Linkify from 'react-linkify'; import { getSchedule, getXProfile } from '../../../api/public/schedules';
import { getSchedule } from '../../../api/public/schedules';
import { formatXDateTime } from '../../../utils/date';
import '../../../mobile.css'; import '../../../mobile.css';
// SDK
const KAKAO_MAP_KEY = import.meta.env.VITE_KAKAO_JS_KEY;
//
function KakaoMap({ lat, lng, name }) {
const mapRef = useRef(null);
const [mapLoaded, setMapLoaded] = useState(false);
const [mapError, setMapError] = useState(false);
useEffect(() => {
if (!KAKAO_MAP_KEY) {
setMapError(true);
return;
}
if (!window.kakao?.maps) {
const script = document.createElement('script');
script.src = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${KAKAO_MAP_KEY}&autoload=false`;
script.onload = () => {
window.kakao.maps.load(() => setMapLoaded(true));
};
script.onerror = () => setMapError(true);
document.head.appendChild(script);
} else {
setMapLoaded(true);
}
}, []);
useEffect(() => {
if (!mapLoaded || !mapRef.current || mapError) return;
try {
const position = new window.kakao.maps.LatLng(lat, lng);
const map = new window.kakao.maps.Map(mapRef.current, {
center: position,
level: 3,
});
const marker = new window.kakao.maps.Marker({
position,
map,
});
if (name) {
const infowindow = new window.kakao.maps.InfoWindow({
content: `<div style="padding:6px 10px;font-size:12px;font-weight:500;">${name}</div>`,
});
infowindow.open(map, marker);
}
} catch (e) {
setMapError(true);
}
}, [mapLoaded, lat, lng, name, mapError]);
if (mapError) {
return (
<a
href={`https://map.kakao.com/link/map/${encodeURIComponent(name)},${lat},${lng}`}
target="_blank"
rel="noopener noreferrer"
className="block w-full h-40 rounded-xl bg-gray-100 flex items-center justify-center active:bg-gray-200 transition-colors"
>
<div className="text-center">
<Navigation size={24} className="mx-auto text-gray-400 mb-1" />
<p className="text-xs text-gray-500">지도에서 보기</p>
</div>
</a>
);
}
return (
<div
ref={mapRef}
className="w-full h-40 rounded-xl overflow-hidden"
/>
);
}
// ( ) // ( )
function useFullscreenOrientation(isShorts) { function useFullscreenOrientation(isShorts) {
useEffect(() => { useEffect(() => {
@ -52,6 +128,10 @@ function useFullscreenOrientation(isShorts) {
const CATEGORY_ID = { const CATEGORY_ID = {
YOUTUBE: 2, YOUTUBE: 2,
X: 3, X: 3,
ALBUM: 4,
FANSIGN: 5,
CONCERT: 6,
TICKET: 7,
}; };
// HTML // HTML
@ -62,6 +142,18 @@ const decodeHtmlEntities = (text) => {
return textarea.value; return textarea.value;
}; };
// ID
const extractYoutubeVideoId = (url) => {
if (!url) return null;
const shortMatch = url.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/);
if (shortMatch) return shortMatch[1];
const watchMatch = url.match(/youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/);
if (watchMatch) return watchMatch[1];
const shortsMatch = url.match(/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/);
if (shortsMatch) return shortsMatch[1];
return null;
};
// //
const formatFullDate = (dateStr) => { const formatFullDate = (dateStr) => {
if (!dateStr) return ''; if (!dateStr) return '';
@ -76,10 +168,37 @@ const formatTime = (timeStr) => {
return timeStr.slice(0, 5); return timeStr.slice(0, 5);
}; };
// X URL username
const extractXUsername = (url) => {
if (!url) return null;
const match = url.match(/(?:twitter\.com|x\.com)\/([^/]+)/);
return match ? match[1] : null;
};
// X / ( 2:30 · 2026 1 15)
const formatXDateTime = (dateStr, timeStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
let result = `${year}${month}${day}`;
if (timeStr) {
const [hours, minutes] = timeStr.split(':').map(Number);
const period = hours < 12 ? '오전' : '오후';
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
result = `${period} ${hour12}:${String(minutes).padStart(2, '0')} · ${result}`;
}
return result;
};
// //
function YoutubeSection({ schedule }) { function YoutubeSection({ schedule }) {
const videoId = schedule.videoId; const videoId = extractYoutubeVideoId(schedule.source?.url);
const isShorts = schedule.videoType === 'shorts'; const isShorts = schedule.source?.url?.includes('/shorts/');
// ( ) // ( )
useFullscreenOrientation(isShorts); useFullscreenOrientation(isShorts);
@ -111,12 +230,12 @@ function YoutubeSection({ schedule }) {
</div> </div>
</motion.div> </motion.div>
{/* 영상 정보 */} {/* 영상 정보 카드 */}
<motion.div <motion.div
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }} transition={{ delay: 0.2 }}
className="bg-gradient-to-br from-gray-100 to-gray-200/80 rounded-xl p-4" className="bg-white rounded-xl p-4 shadow-sm"
> >
{/* 제목 */} {/* 제목 */}
<h1 className="font-bold text-gray-900 text-base leading-relaxed mb-3"> <h1 className="font-bold text-gray-900 text-base leading-relaxed mb-3">
@ -127,12 +246,18 @@ function YoutubeSection({ schedule }) {
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 mb-3"> <div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 mb-3">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Calendar size={12} /> <Calendar size={12} />
<span>{formatXDateTime(schedule.datetime)}</span> <span>{formatFullDate(schedule.date)}</span>
</div> </div>
{schedule.channelName && ( {schedule.time && (
<div className="flex items-center gap-1">
<Clock size={12} />
<span>{formatTime(schedule.time)}</span>
</div>
)}
{schedule.source?.name && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Link2 size={12} /> <Link2 size={12} />
<span>{schedule.channelName}</span> <span>{schedule.source?.name}</span>
</div> </div>
)} )}
</div> </div>
@ -158,19 +283,17 @@ function YoutubeSection({ schedule }) {
)} )}
{/* 유튜브에서 보기 버튼 */} {/* 유튜브에서 보기 버튼 */}
<div className="pt-4 border-t border-gray-300/50"> <a
<a href={schedule.source?.url}
href={schedule.videoUrl} target="_blank"
target="_blank" rel="noopener noreferrer"
rel="noopener noreferrer" className="flex items-center justify-center gap-2 w-full py-3 bg-red-500 active:bg-red-600 text-white rounded-xl font-medium transition-colors"
className="flex items-center justify-center gap-2 w-full py-3 bg-red-500 active:bg-red-600 text-white rounded-xl font-medium transition-colors" >
> <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor"> <path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/> </svg>
</svg> YouTube에서 보기
YouTube에서 보기 </a>
</a>
</div>
</motion.div> </motion.div>
</div> </div>
); );
@ -178,83 +301,20 @@ function YoutubeSection({ schedule }) {
// X() // X()
function XSection({ schedule }) { function XSection({ schedule }) {
const profile = schedule.profile; const username = extractXUsername(schedule.source?.url);
const username = profile?.username || 'realfromis_9';
const displayName = profile?.displayName || username; //
const { data: profile } = useQuery({
queryKey: ['x-profile', username],
queryFn: () => getXProfile(username),
enabled: !!username,
staleTime: 1000 * 60 * 60, // 1
});
const displayName = profile?.displayName || schedule.source?.name || username || 'Unknown';
const avatarUrl = profile?.avatarUrl; const avatarUrl = profile?.avatarUrl;
//
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const historyPushedRef = useRef(false);
const openLightbox = (index) => {
setLightboxIndex(index);
setLightboxOpen(true);
window.history.pushState({ lightbox: true }, '');
historyPushedRef.current = true;
};
const closeLightbox = () => {
setLightboxOpen(false);
if (historyPushedRef.current) {
historyPushedRef.current = false;
window.history.back();
}
};
const goToPrev = () => {
if (schedule.imageUrls?.length > 1) {
setLightboxIndex((lightboxIndex - 1 + schedule.imageUrls.length) % schedule.imageUrls.length);
}
};
const goToNext = () => {
if (schedule.imageUrls?.length > 1) {
setLightboxIndex((lightboxIndex + 1) % schedule.imageUrls.length);
}
};
// body
useEffect(() => {
if (lightboxOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [lightboxOpen]);
// ( )
useEffect(() => {
const handlePopState = () => {
if (lightboxOpen) {
historyPushedRef.current = false;
setLightboxOpen(false);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [lightboxOpen]);
// ( )
const linkDecorator = (href, text, key) => (
<a
key={key}
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500"
>
{text}
</a>
);
return ( return (
<>
<motion.div <motion.div
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
@ -287,7 +347,9 @@ function XSection({ schedule }) {
<path d="M22.25 12c0-1.43-.88-2.67-2.19-3.34.46-1.39.2-2.9-.81-3.91s-2.52-1.27-3.91-.81c-.66-1.31-1.91-2.19-3.34-2.19s-2.67.88-3.34 2.19c-1.39-.46-2.9-.2-3.91.81s-1.27 2.52-.81 3.91C2.63 9.33 1.75 10.57 1.75 12s.88 2.67 2.19 3.34c-.46 1.39-.2 2.9.81 3.91s2.52 1.27 3.91.81c.66 1.31 1.91 2.19 3.34 2.19s2.67-.88 3.34-2.19c1.39.46 2.9.2 3.91-.81s1.27-2.52.81-3.91c1.31-.67 2.19-1.91 2.19-3.34zm-11.07 4.57l-3.84-3.84 1.27-1.27 2.57 2.57 5.39-5.39 1.27 1.27-6.66 6.66z"/> <path d="M22.25 12c0-1.43-.88-2.67-2.19-3.34.46-1.39.2-2.9-.81-3.91s-2.52-1.27-3.91-.81c-.66-1.31-1.91-2.19-3.34-2.19s-2.67.88-3.34 2.19c-1.39-.46-2.9-.2-3.91.81s-1.27 2.52-.81 3.91C2.63 9.33 1.75 10.57 1.75 12s.88 2.67 2.19 3.34c-.46 1.39-.2 2.9.81 3.91s2.52 1.27 3.91.81c.66 1.31 1.91 2.19 3.34 2.19s2.67-.88 3.34-2.19c1.39.46 2.9.2 3.91-.81s1.27-2.52.81-3.91c1.31-.67 2.19-1.91 2.19-3.34zm-11.07 4.57l-3.84-3.84 1.27-1.27 2.57 2.57 5.39-5.39 1.27 1.27-6.66 6.66z"/>
</svg> </svg>
</div> </div>
<span className="text-xs text-gray-500">@{username}</span> {username && (
<span className="text-xs text-gray-500">@{username}</span>
)}
</div> </div>
</div> </div>
</div> </div>
@ -295,53 +357,32 @@ function XSection({ schedule }) {
{/* 본문 */} {/* 본문 */}
<div className="p-4"> <div className="p-4">
<p className="text-gray-900 text-[15px] leading-relaxed whitespace-pre-wrap"> <p className="text-gray-900 text-[15px] leading-relaxed whitespace-pre-wrap">
<Linkify componentDecorator={linkDecorator}> {decodeHtmlEntities(schedule.description || schedule.title)}
{decodeHtmlEntities(schedule.content || schedule.title)}
</Linkify>
</p> </p>
</div> </div>
{/* 이미지 */} {/* 이미지 */}
{schedule.imageUrls?.length > 0 && ( {schedule.image_url && (
<div className="px-4 pb-3"> <div className="px-4 pb-3">
{schedule.imageUrls.length === 1 ? ( <img
<img src={schedule.image_url}
src={schedule.imageUrls[0]} alt=""
alt="" className="w-full rounded-xl border border-gray-100"
className="w-full rounded-xl border border-gray-100 cursor-pointer active:opacity-80 transition-opacity" />
onClick={() => openLightbox(0)}
/>
) : (
<div className={`grid gap-1 rounded-xl overflow-hidden border border-gray-100 ${
schedule.imageUrls.length === 2 ? 'grid-cols-2' : 'grid-cols-2'
}`}>
{schedule.imageUrls.slice(0, 4).map((url, i) => (
<img
key={i}
src={url}
alt=""
className={`w-full object-cover cursor-pointer active:opacity-80 transition-opacity ${
schedule.imageUrls.length === 3 && i === 0 ? 'row-span-2 h-full' : 'aspect-square'
}`}
onClick={() => openLightbox(i)}
/>
))}
</div>
)}
</div> </div>
)} )}
{/* 날짜/시간 */} {/* 날짜/시간 */}
<div className="px-4 py-3 border-t border-gray-100"> <div className="px-4 py-3 border-t border-gray-100">
<span className="text-gray-500 text-sm"> <span className="text-gray-500 text-sm">
{formatXDateTime(schedule.datetime)} {formatXDateTime(schedule.date, schedule.time)}
</span> </span>
</div> </div>
{/* X에서 보기 버튼 */} {/* X에서 보기 버튼 */}
<div className="px-4 py-3 border-t border-gray-100 bg-gray-50/50"> <div className="px-4 py-3 border-t border-gray-100 bg-gray-50/50">
<a <a
href={schedule.postUrl} href={schedule.source?.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full py-2.5 bg-gray-900 active:bg-black text-white rounded-full font-medium transition-colors" className="flex items-center justify-center gap-2 w-full py-2.5 bg-gray-900 active:bg-black text-white rounded-full font-medium transition-colors"
@ -353,70 +394,332 @@ function XSection({ schedule }) {
</a> </a>
</div> </div>
</motion.div> </motion.div>
);
}
{/* 모바일 라이트박스 */} //
<AnimatePresence> function ConcertSection({ schedule }) {
{lightboxOpen && schedule.imageUrls?.length > 0 && ( // ID ( state - URL )
<motion.div const [selectedDateId, setSelectedDateId] = useState(schedule.id);
initial={{ opacity: 0 }} //
animate={{ opacity: 1 }} const [isDialogOpen, setIsDialogOpen] = useState(false);
exit={{ opacity: 0 }} // ref ( )
className="fixed inset-0 bg-black z-50 flex items-center justify-center" const listRef = useRef(null);
onClick={closeLightbox} const selectedItemRef = useRef(null);
>
{/* 닫기 버튼 */} // state ( )
const [displayData, setDisplayData] = useState({
posterUrl: schedule.images?.[0] || null,
title: schedule.title,
date: schedule.date,
time: schedule.time,
locationName: schedule.location_name,
locationAddress: schedule.location_address,
locationLat: schedule.location_lat,
locationLng: schedule.location_lng,
description: schedule.description,
sourceUrl: schedule.source?.url,
});
//
const { data: selectedSchedule } = useQuery({
queryKey: ['schedule', selectedDateId],
queryFn: () => getSchedule(selectedDateId),
placeholderData: keepPreviousData,
enabled: selectedDateId !== schedule.id,
});
//
useEffect(() => {
const newData = selectedDateId === schedule.id ? schedule : selectedSchedule;
if (!newData) return;
setDisplayData(prev => {
const updates = {};
const newPosterUrl = newData.images?.[0] || null;
if (prev.posterUrl !== newPosterUrl) updates.posterUrl = newPosterUrl;
if (prev.title !== newData.title) updates.title = newData.title;
if (prev.date !== newData.date) updates.date = newData.date;
if (prev.time !== newData.time) updates.time = newData.time;
if (prev.locationName !== newData.location_name) updates.locationName = newData.location_name;
if (prev.locationAddress !== newData.location_address) updates.locationAddress = newData.location_address;
if (prev.locationLat !== newData.location_lat) updates.locationLat = newData.location_lat;
if (prev.locationLng !== newData.location_lng) updates.locationLng = newData.location_lng;
if (prev.description !== newData.description) updates.description = newData.description;
if (prev.sourceUrl !== newData.source?.url) updates.sourceUrl = newData.source?.url;
//
if (Object.keys(updates).length > 0) {
return { ...prev, ...updates };
}
return prev;
});
}, [selectedDateId, schedule, selectedSchedule]);
//
useEffect(() => {
if (isDialogOpen && selectedItemRef.current) {
setTimeout(() => {
selectedItemRef.current?.scrollIntoView({ block: 'center', behavior: 'instant' });
}, 50);
}
}, [isDialogOpen]);
const relatedDates = schedule.related_dates || [];
const hasMultipleDates = relatedDates.length > 1;
const hasLocation = displayData.locationLat && displayData.locationLng;
//
const selectedIndex = relatedDates.findIndex(d => d.id === selectedDateId);
//
const handleSelectDate = (id) => {
setSelectedDateId(id);
setIsDialogOpen(false);
};
//
const formatSingleDate = (dateStr, timeStr) => {
const date = new Date(dateStr);
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
const month = date.getMonth() + 1;
const day = date.getDate();
const weekday = dayNames[date.getDay()];
let result = `${month}${day}일 (${weekday})`;
if (timeStr) {
result += ` ${timeStr.slice(0, 5)}`;
}
return result;
};
return (
<>
<div className="-mx-4 -mt-4">
{/* 히어로 헤더 */}
<div className="relative overflow-hidden">
{/* 배경 블러 이미지 */}
{displayData.posterUrl ? (
<div className="absolute inset-0 scale-110 overflow-hidden">
<img
src={displayData.posterUrl}
alt=""
className="w-full h-full object-cover blur-[24px]"
/>
</div>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-primary via-primary/90 to-primary/70" />
)}
{/* 오버레이 그라디언트 */}
<div className="absolute inset-0 bg-gradient-to-b from-black/40 via-black/50 to-black/70" />
{/* 콘텐츠 */}
<div className="relative px-5 pt-6 pb-8">
<div className="flex flex-col items-center text-center">
{/* 포스터 */}
{displayData.posterUrl && (
<div className="mb-4 rounded-xl overflow-hidden shadow-2xl ring-1 ring-white/20">
<img
src={displayData.posterUrl}
alt={displayData.title}
className="w-32 h-44 object-cover"
/>
</div>
)}
{/* 제목 */}
<h1 className="text-white font-bold text-lg leading-snug drop-shadow-lg max-w-xs">
{decodeHtmlEntities(displayData.title)}
</h1>
</div>
</div>
</div>
{/* 카드 섹션 */}
<div className="px-4 pt-4 space-y-4">
{/* 공연 일정 카드 */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-white rounded-xl p-4 shadow-sm"
>
<div className="flex items-center gap-2 text-xs text-gray-500 mb-3">
<Calendar size={14} />
<span>공연 일정</span>
</div>
{/* 현재 회차 표시 */}
<div className="px-4 py-3 bg-primary/10 rounded-lg">
<p className="text-primary font-medium text-sm">
{hasMultipleDates && <span className="mr-1">{selectedIndex + 1}회차 ·</span>}
{formatSingleDate(displayData.date, displayData.time)}
</p>
</div>
{/* 다른 회차 선택 버튼 */}
{hasMultipleDates && (
<button <button
className="absolute top-4 right-4 p-2 text-white/70 z-10" onClick={() => setIsDialogOpen(true)}
onClick={closeLightbox} className="w-full mt-2 py-2.5 text-sm text-gray-500 font-medium active:bg-gray-50 rounded-lg transition-colors"
> >
<X size={28} /> 다른 회차 선택
</button> </button>
)}
</motion.div>
{/* 이미지 */} {/* 장소 카드 */}
<motion.img {displayData.locationName && (
key={lightboxIndex} <motion.div
src={schedule.imageUrls[lightboxIndex]} initial={{ opacity: 0, y: 10 }}
alt="" animate={{ opacity: 1, y: 0 }}
className="max-w-full max-h-full object-contain" transition={{ delay: 0.15 }}
initial={{ opacity: 0, scale: 0.95 }} className="bg-white rounded-xl p-4 shadow-sm"
animate={{ opacity: 1, scale: 1 }} >
onClick={(e) => e.stopPropagation()} <div className="flex items-center gap-2 text-xs text-gray-500 mb-2">
/> <MapPin size={14} />
<span>장소</span>
{/* 이전/다음 버튼 */} </div>
{schedule.imageUrls.length > 1 && ( <p className="text-gray-900 font-medium">{displayData.locationName}</p>
<> {displayData.locationAddress && (
<button <p className="text-gray-500 text-sm mt-0.5">{displayData.locationAddress}</p>
className="absolute left-2 top-1/2 -translate-y-1/2 p-2 text-white/70"
onClick={(e) => { e.stopPropagation(); goToPrev(); }}
>
<ChevronLeft size={32} />
</button>
<button
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-white/70"
onClick={(e) => { e.stopPropagation(); goToNext(); }}
>
<ChevronRight size={32} />
</button>
</>
)} )}
{/* 인디케이터 */} {/* 지도 - 좌표가 있으면 카카오맵, 없으면 구글맵 */}
{schedule.imageUrls.length > 1 && ( {hasLocation ? (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex gap-2"> <div className="mt-3 rounded-xl overflow-hidden">
{schedule.imageUrls.map((_, i) => ( <KakaoMap
<button lat={parseFloat(displayData.locationLat)}
key={i} lng={parseFloat(displayData.locationLng)}
className={`w-2 h-2 rounded-full transition-colors ${ name={displayData.locationName}
i === lightboxIndex ? 'bg-white' : 'bg-white/40' />
}`} </div>
onClick={(e) => { e.stopPropagation(); setLightboxIndex(i); }} ) : (
/> <div className="mt-3 rounded-xl overflow-hidden">
))} <iframe
src={`https://maps.google.com/maps?q=${encodeURIComponent(displayData.locationAddress || displayData.locationName)}&output=embed&hl=ko`}
className="w-full h-40 border-0"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
title="Google Maps"
/>
</div> </div>
)} )}
</motion.div> </motion.div>
)} )}
{/* 설명 */}
{displayData.description && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-white rounded-xl p-4 shadow-sm"
>
<p className="text-gray-600 text-sm leading-relaxed">
{decodeHtmlEntities(displayData.description)}
</p>
</motion.div>
)}
{/* 버튼 영역 */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
className="space-y-2"
>
{displayData.locationName && (
<a
href={hasLocation
? `https://map.kakao.com/link/to/${encodeURIComponent(displayData.locationName)},${displayData.locationLat},${displayData.locationLng}`
: `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(displayData.locationAddress || displayData.locationName)}`
}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center justify-center gap-2 w-full py-3.5 text-white rounded-xl font-medium transition-colors ${
hasLocation
? 'bg-blue-500 active:bg-blue-600'
: 'bg-[#4285F4] active:bg-[#3367D6]'
}`}
>
<Navigation size={18} />
길찾기
</a>
)}
{displayData.sourceUrl && (
<a
href={displayData.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full py-3.5 bg-gray-100 active:bg-gray-200 text-gray-900 rounded-xl font-medium transition-colors"
>
<ExternalLink size={18} />
상세 정보
</a>
)}
</motion.div>
</div>
</div>
{/* 회차 선택 다이얼로그 */}
<AnimatePresence>
{isDialogOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* 백드롭 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="absolute inset-0 bg-black/50"
onClick={() => setIsDialogOpen(false)}
/>
{/* 다이얼로그 */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="relative bg-white rounded-2xl w-full max-w-sm overflow-hidden shadow-xl"
>
{/* 헤더 */}
<div className="px-5 py-4 border-b border-gray-100">
<h3 className="text-base font-bold text-gray-900">회차 선택</h3>
</div>
{/* 회차 목록 */}
<div ref={listRef} className="max-h-72 overflow-y-auto">
{relatedDates.map((item, index) => {
const isSelected = item.id === selectedDateId;
return (
<button
key={item.id}
ref={isSelected ? selectedItemRef : null}
onClick={() => handleSelectDate(item.id)}
className={`w-full flex items-center justify-between px-5 py-3.5 text-sm transition-colors ${
isSelected
? 'bg-primary/10'
: 'active:bg-gray-50'
}`}
>
<span className={isSelected ? 'text-primary font-medium' : 'text-gray-700'}>
{index + 1}회차 · {formatSingleDate(item.date, item.time)}
</span>
{isSelected && <Check size={18} className="text-primary" />}
</button>
);
})}
</div>
{/* 닫기 버튼 */}
<div className="px-5 py-4 border-t border-gray-100">
<button
onClick={() => setIsDialogOpen(false)}
className="w-full py-3 bg-gray-100 active:bg-gray-200 text-gray-700 rounded-xl font-medium transition-colors"
>
닫기
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence> </AnimatePresence>
</> </>
); );
@ -567,13 +870,14 @@ function MobileScheduleDetail() {
} }
// //
const categoryId = schedule.category?.id;
const renderCategorySection = () => { const renderCategorySection = () => {
switch (categoryId) { switch (schedule.category_id) {
case CATEGORY_ID.YOUTUBE: case CATEGORY_ID.YOUTUBE:
return <YoutubeSection schedule={schedule} />; return <YoutubeSection schedule={schedule} />;
case CATEGORY_ID.X: case CATEGORY_ID.X:
return <XSection schedule={schedule} />; return <XSection schedule={schedule} />;
case CATEGORY_ID.CONCERT:
return <ConcertSection schedule={schedule} />;
default: default:
return <DefaultSection schedule={schedule} />; return <DefaultSection schedule={schedule} />;
} }
@ -593,9 +897,9 @@ function MobileScheduleDetail() {
<div className="flex-1 text-center"> <div className="flex-1 text-center">
<span <span
className="text-sm font-medium" className="text-sm font-medium"
style={{ color: schedule.category?.color }} style={{ color: schedule.category_color }}
> >
{schedule.category?.name} {schedule.category_name}
</span> </span>
</div> </div>
<div className="w-10" /> <div className="w-10" />

View file

@ -5,7 +5,7 @@ import {
Home, ChevronRight, Calendar, Plus, Edit2, Trash2, Home, ChevronRight, Calendar, Plus, Edit2, Trash2,
ChevronLeft, Search, ChevronDown, Bot, Tag, ArrowLeft, ExternalLink, Clock, Link2, Book ChevronLeft, Search, ChevronDown, Bot, Tag, ArrowLeft, ExternalLink, Clock, Link2, Book
} from 'lucide-react'; } from 'lucide-react';
import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
@ -27,24 +27,6 @@ const decodeHtmlEntities = (text) => {
return textarea.value; return textarea.value;
}; };
// ID
const CATEGORY_IDS = {
YOUTUBE: 2,
X: 3,
};
//
const getEditPath = (scheduleId, categoryId) => {
switch (categoryId) {
case CATEGORY_IDS.YOUTUBE:
return `/admin/schedule/${scheduleId}/edit/youtube`;
case CATEGORY_IDS.X:
return `/admin/schedule/${scheduleId}/edit/x`;
default:
return `/admin/schedule/${scheduleId}/edit`;
}
};
// - React.memo // - React.memo
const ScheduleItem = memo(function ScheduleItem({ const ScheduleItem = memo(function ScheduleItem({
schedule, schedule,
@ -137,7 +119,7 @@ const ScheduleItem = memo(function ScheduleItem({
</a> </a>
)} )}
<button <button
onClick={() => navigate(getEditPath(schedule.id, schedule.category_id))} onClick={() => navigate(`/admin/schedule/${schedule.id}/edit`)}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500" className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
> >
<Edit2 size={18} /> <Edit2 size={18} />
@ -157,7 +139,6 @@ const ScheduleItem = memo(function ScheduleItem({
function AdminSchedule() { function AdminSchedule() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient();
// Zustand // Zustand
const { const {
@ -173,6 +154,7 @@ function AdminSchedule() {
const { user, isAuthenticated } = useAdminAuth(); const { user, isAuthenticated } = useAdminAuth();
// ( ) // ( )
const [loading, setLoading] = useState(false);
const { toast, setToast } = useToast(); const { toast, setToast } = useToast();
const scrollContainerRef = useRef(null); const scrollContainerRef = useRef(null);
const searchContainerRef = useRef(null); // ( ) const searchContainerRef = useRef(null); // ( )
@ -291,8 +273,8 @@ function AdminSchedule() {
const days = ['일', '월', '화', '수', '목', '금', '토']; const days = ['일', '월', '화', '수', '목', '금', '토'];
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']; const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
// (2017 , 12 ) // (2025 , 12 )
const MIN_YEAR = 2017; const MIN_YEAR = 2025;
const startYear = Math.max(MIN_YEAR, Math.floor(year / 12) * 12 - 1); const startYear = Math.max(MIN_YEAR, Math.floor(year / 12) * 12 - 1);
const yearRange = Array.from({ length: 12 }, (_, i) => startYear + i); const yearRange = Array.from({ length: 12 }, (_, i) => startYear + i);
const canGoPrevYearRange = startYear > MIN_YEAR; const canGoPrevYearRange = startYear > MIN_YEAR;
@ -306,12 +288,8 @@ function AdminSchedule() {
const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate(); const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate();
// (React Query ) // (API )
const { data: schedules = [], isLoading: loading } = useQuery({ const [schedules, setSchedules] = useState([]);
queryKey: ['adminSchedules', year, month + 1],
queryFn: () => schedulesApi.getSchedules(year, month + 1),
enabled: isAuthenticated,
});
// //
const categories = useMemo(() => { const categories = useMemo(() => {
@ -408,10 +386,14 @@ function AdminSchedule() {
if (savedToast) { if (savedToast) {
setToast(JSON.parse(savedToast)); setToast(JSON.parse(savedToast));
sessionStorage.removeItem('scheduleToast'); sessionStorage.removeItem('scheduleToast');
// /
queryClient.invalidateQueries({ queryKey: ['adminSchedules'] });
} }
}, [isAuthenticated, queryClient]); }, [isAuthenticated]);
//
useEffect(() => {
fetchSchedules();
}, [year, month]);
// //
useEffect(() => { useEffect(() => {
@ -432,6 +414,21 @@ function AdminSchedule() {
setScrollPosition(e.target.scrollTop); setScrollPosition(e.target.scrollTop);
}; };
//
const fetchSchedules = async () => {
setLoading(true);
try {
const data = await schedulesApi.getSchedules(year, month + 1);
setSchedules(data);
} catch (error) {
console.error('일정 로드 오류:', error);
} finally {
setLoading(false);
}
};
// //
useEffect(() => { useEffect(() => {
const handleClickOutside = (event) => { const handleClickOutside = (event) => {
@ -456,12 +453,8 @@ function AdminSchedule() {
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showYearMonthPicker, showCategoryTooltip]); }, [showYearMonthPicker, showCategoryTooltip]);
// 2017 1
const canGoPrevMonth = !(year === 2017 && month === 0);
// //
const prevMonth = () => { const prevMonth = () => {
if (!canGoPrevMonth) return;
setSlideDirection(-1); setSlideDirection(-1);
const newDate = new Date(year, month - 1, 1); const newDate = new Date(year, month - 1, 1);
setCurrentDate(newDate); setCurrentDate(newDate);
@ -473,6 +466,7 @@ function AdminSchedule() {
const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`; const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`;
setSelectedDate(firstDay); setSelectedDate(firstDay);
} }
setSchedules([]); //
}; };
const nextMonth = () => { const nextMonth = () => {
@ -487,9 +481,10 @@ function AdminSchedule() {
const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`; const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`;
setSelectedDate(firstDay); setSelectedDate(firstDay);
} }
setSchedules([]); //
}; };
// (12 , 2017 ) // (12 , 2025 )
const prevYearRange = () => canGoPrevYearRange && setCurrentDate(new Date(Math.max(MIN_YEAR, year - 12), month, 1)); const prevYearRange = () => canGoPrevYearRange && setCurrentDate(new Date(Math.max(MIN_YEAR, year - 12), month, 1));
const nextYearRange = () => setCurrentDate(new Date(year + 12, month, 1)); const nextYearRange = () => setCurrentDate(new Date(year + 12, month, 1));
@ -539,8 +534,7 @@ function AdminSchedule() {
try { try {
await schedulesApi.deleteSchedule(scheduleToDelete.id); await schedulesApi.deleteSchedule(scheduleToDelete.id);
setToast({ type: 'success', message: '일정이 삭제되었습니다.' }); setToast({ type: 'success', message: '일정이 삭제되었습니다.' });
// fetchSchedules();
queryClient.invalidateQueries({ queryKey: ['adminSchedules', year, month + 1] });
} catch (error) { } catch (error) {
console.error('삭제 오류:', error); console.error('삭제 오류:', error);
setToast({ type: 'error', message: error.message || '삭제 중 오류가 발생했습니다.' }); setToast({ type: 'error', message: error.message || '삭제 중 오류가 발생했습니다.' });
@ -702,8 +696,8 @@ function AdminSchedule() {
<div className={`flex items-center justify-between mb-8 ${isSearchMode ? 'opacity-50' : ''}`}> <div className={`flex items-center justify-between mb-8 ${isSearchMode ? 'opacity-50' : ''}`}>
<button <button
onClick={prevMonth} onClick={prevMonth}
disabled={isSearchMode || !canGoPrevMonth} disabled={isSearchMode}
className={`p-2 rounded-full transition-colors ${isSearchMode || !canGoPrevMonth ? 'opacity-30' : 'hover:bg-gray-100'}`} className={`p-2 rounded-full transition-colors ${isSearchMode ? 'cursor-not-allowed' : 'hover:bg-gray-100'}`}
> >
<ChevronLeft size={24} /> <ChevronLeft size={24} />
</button> </button>
@ -1283,16 +1277,19 @@ function AdminSchedule() {
</span> </span>
)} )}
</div> </div>
{(schedule.members?.length > 0 || schedule.member_names) && ( {schedule.member_names && (
<div className="flex flex-wrap gap-1.5 mt-2"> <div className="flex flex-wrap gap-1.5 mt-2">
{(() => { {schedule.member_names.split(',').length >= 5 ? (
const memberList = schedule.members?.map(m => m.name) || schedule.member_names?.split(',') || []; <span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
return memberList.map((name, i) => ( 프로미스나인
</span>
) : (
schedule.member_names.split(',').map((name, i) => (
<span key={i} className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full"> <span key={i} className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
{name.trim()} {name.trim()}
</span> </span>
)); ))
})()} )}
</div> </div>
)} )}
</div> </div>
@ -1310,7 +1307,7 @@ function AdminSchedule() {
</a> </a>
)} )}
<button <button
onClick={() => navigate(getEditPath(schedule.id, schedule.category_id))} onClick={() => navigate(`/admin/schedule/${schedule.id}/edit`)}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500" className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
> >
<Edit2 size={18} /> <Edit2 size={18} />

View file

@ -1,6 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
Home, ChevronRight, Bot, Play, Square, Home, ChevronRight, Bot, Play, Square,
@ -45,42 +44,46 @@ const MeilisearchIcon = ({ size = 20 }) => (
function AdminScheduleBots() { function AdminScheduleBots() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient();
const { user, isAuthenticated } = useAdminAuth(); const { user, isAuthenticated } = useAdminAuth();
const { toast, setToast } = useToast(); const { toast, setToast } = useToast();
const [bots, setBots] = useState([]);
const [loading, setLoading] = useState(true);
const [isInitialLoad, setIsInitialLoad] = useState(true); // () const [isInitialLoad, setIsInitialLoad] = useState(true); // ()
const [syncing, setSyncing] = useState(null); // ID const [syncing, setSyncing] = useState(null); // ID
const [quotaWarning, setQuotaWarning] = useState(null); // const [quotaWarning, setQuotaWarning] = useState(null); //
useEffect(() => {
if (isAuthenticated) {
fetchBots();
fetchQuotaWarning();
}
}, [isAuthenticated]);
// //
const { data: bots = [], isLoading: loading, isError, refetch: fetchBots } = useQuery({ const fetchBots = async () => {
queryKey: ['admin', 'bots'], setLoading(true);
queryFn: botsApi.getBots, try {
enabled: isAuthenticated, const data = await botsApi.getBots();
staleTime: 30000, setBots(data);
}); } catch (error) {
console.error('봇 목록 조회 오류:', error);
setToast({ type: 'error', message: '봇 목록을 불러올 수 없습니다.' });
} finally {
setLoading(false);
}
};
// //
const { data: quotaData } = useQuery({ const fetchQuotaWarning = async () => {
queryKey: ['admin', 'bots', 'quota'], try {
queryFn: botsApi.getQuotaWarning, const data = await botsApi.getQuotaWarning();
enabled: isAuthenticated, if (data.active) {
staleTime: 60000, setQuotaWarning(data);
}); }
} catch (error) {
// console.error('할당량 경고 조회 오류:', error);
useEffect(() => {
if (isError) {
setToast({ type: 'error', message: '봇 목록을 불러올 수 없습니다.' });
} }
}, [isError, setToast]); };
//
useEffect(() => {
if (quotaData?.active) {
setQuotaWarning(quotaData);
}
}, [quotaData]);
// //
const handleDismissQuotaWarning = async () => { const handleDismissQuotaWarning = async () => {
@ -103,14 +106,12 @@ function AdminScheduleBots() {
await botsApi.stopBot(botId); await botsApi.stopBot(botId);
} }
// ( ) // ( )
queryClient.setQueryData(['admin', 'bots'], (prev) => setBots(prev => prev.map(bot =>
prev?.map(bot => bot.id === botId
bot.id === botId ? { ...bot, status: action === 'start' ? 'running' : 'stopped' }
? { ...bot, status: action === 'start' ? 'running' : 'stopped' } : bot
: bot ));
)
);
setToast({ setToast({
type: 'success', type: 'success',
message: action === 'start' ? `${botName} 봇이 시작되었습니다.` : `${botName} 봇이 정지되었습니다.` message: action === 'start' ? `${botName} 봇이 시작되었습니다.` : `${botName} 봇이 정지되었습니다.`

View file

@ -1,7 +1,6 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useState, useEffect, useMemo, useRef } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Home, ChevronRight, Book, Plus, Trash2, Search, ChevronDown } from 'lucide-react'; import { Home, ChevronRight, Book, Plus, Trash2, Search, ChevronDown } from 'lucide-react';
import Toast from '../../../components/Toast'; import Toast from '../../../components/Toast';
import AdminLayout from '../../../components/admin/AdminLayout'; import AdminLayout from '../../../components/admin/AdminLayout';
@ -172,8 +171,8 @@ function WordItem({ id, word, pos, index, onUpdate, onDelete }) {
function AdminScheduleDict() { function AdminScheduleDict() {
const { user, isAuthenticated } = useAdminAuth(); const { user, isAuthenticated } = useAdminAuth();
const { toast, setToast } = useToast(); const { toast, setToast } = useToast();
const queryClient = useQueryClient();
const [entries, setEntries] = useState([]); // [{word, pos, isComment, id}] const [entries, setEntries] = useState([]); // [{word, pos, isComment, id}]
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [filterPos, setFilterPos] = useState('all'); const [filterPos, setFilterPos] = useState('all');
@ -240,59 +239,55 @@ function AdminScheduleDict() {
return stats; return stats;
}, [entries]); }, [entries]);
useEffect(() => {
if (isAuthenticated) {
fetchDict();
}
}, [isAuthenticated]);
// ID // ID
const generateId = useCallback(() => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, []); const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// //
const parseDict = useCallback((content) => { const parseDict = (content) => {
const lines = content.split('\n'); const lines = content.split('\n');
return lines.map(line => { return lines.map(line => {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) { if (!trimmed || trimmed.startsWith('#')) {
return { isComment: true, raw: line, id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}` }; return { isComment: true, raw: line, id: generateId() };
} }
const parts = trimmed.split('\t'); const parts = trimmed.split('\t');
return { return {
word: parts[0] || '', word: parts[0] || '',
pos: parts[1] || 'NNP', pos: parts[1] || 'NNP',
isComment: false, isComment: false,
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, id: generateId(),
}; };
}).filter(e => e.isComment || e.word); // }).filter(e => e.isComment || e.word); //
}, []); };
// //
const serializeDict = useCallback((entries) => { const serializeDict = (entries) => {
return entries.map(e => { return entries.map(e => {
if (e.isComment) return e.raw; if (e.isComment) return e.raw;
return `${e.word}\t${e.pos}`; return `${e.word}\t${e.pos}`;
}).join('\n'); }).join('\n');
}, []); };
// (useQuery) //
const { data: dictContent, isLoading: loading, isError } = useQuery({ const fetchDict = async () => {
queryKey: ['admin', 'dict'], setLoading(true);
queryFn: async () => { try {
const data = await suggestionsApi.getDict(); const data = await suggestionsApi.getDict();
return data.content || ''; const parsed = parseDict(data.content || '');
},
enabled: isAuthenticated,
});
//
useEffect(() => {
if (dictContent !== undefined) {
const parsed = parseDict(dictContent);
setEntries(parsed); setEntries(parsed);
} } catch (error) {
}, [dictContent, parseDict]); console.error('사전 조회 오류:', error);
//
useEffect(() => {
if (isError) {
setToast({ type: 'error', message: '사전을 불러올 수 없습니다.' }); setToast({ type: 'error', message: '사전을 불러올 수 없습니다.' });
} finally {
setLoading(false);
} }
}, [isError, setToast]); };
// (entries ) // (entries )
const saveDict = async (newEntries) => { const saveDict = async (newEntries) => {

View file

@ -1,6 +1,5 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useNavigate, Link, useParams } from "react-router-dom"; import { useNavigate, Link, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { formatDate } from "../../../utils/date"; import { formatDate } from "../../../utils/date";
import { import {
@ -43,6 +42,7 @@ function AdminScheduleForm() {
const { toast, setToast } = useToast(); const { toast, setToast } = useToast();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [members, setMembers] = useState([]);
// (/ ) // (/ )
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@ -77,22 +77,8 @@ function AdminScheduleForm() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTargetIndex, setDeleteTargetIndex] = useState(null); const [deleteTargetIndex, setDeleteTargetIndex] = useState(null);
// // (API )
const { data: membersData = [] } = useQuery({ const [categories, setCategories] = useState([]);
queryKey: ["members"],
queryFn: getMembers,
enabled: isAuthenticated,
staleTime: 5 * 60 * 1000,
});
const members = membersData.filter((m) => !m.is_former);
//
const { data: categories = [] } = useQuery({
queryKey: ["admin", "categories"],
queryFn: categoriesApi.getCategories,
enabled: isAuthenticated,
staleTime: 5 * 60 * 1000,
});
// //
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -146,16 +132,28 @@ function AdminScheduleForm() {
return days[date.getDay()]; return days[date.getDay()];
}; };
// //
useEffect(() => { const fetchCategories = async () => {
if (categories.length > 0 && !formData.category && !isEditMode) { try {
setFormData((prev) => ({ ...prev, category: categories[0].id })); const data = await categoriesApi.getCategories();
setCategories(data);
//
if (data.length > 0 && !formData.category) {
setFormData((prev) => ({ ...prev, category: data[0].id }));
}
} catch (error) {
console.error("카테고리 로드 오류:", error);
} }
}, [categories, isEditMode]); };
//
useEffect(() => { useEffect(() => {
if (isAuthenticated && isEditMode && id) { if (!isAuthenticated) return;
fetchMembers();
fetchCategories();
//
if (isEditMode && id) {
fetchSchedule(); fetchSchedule();
} }
}, [isAuthenticated, isEditMode, id]); }, [isAuthenticated, isEditMode, id]);
@ -225,6 +223,15 @@ function AdminScheduleForm() {
} }
}; };
const fetchMembers = async () => {
try {
const data = await getMembers();
setMembers(data.filter((m) => !m.is_former));
} catch (error) {
console.error("멤버 로드 오류:", error);
}
};
// //
const toggleMember = (memberId) => { const toggleMember = (memberId) => {
const newMembers = formData.members.includes(memberId) const newMembers = formData.members.includes(memberId)

View file

@ -1,539 +0,0 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams, Link } from "react-router-dom";
import { motion } from "framer-motion";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
Youtube,
Loader2,
Save,
ExternalLink,
Home,
ChevronRight,
Users,
Check,
} from "lucide-react";
import AdminLayout from "../../../../../components/admin/AdminLayout";
import Toast from "../../../../../components/Toast";
import useAdminAuth from "../../../../../hooks/useAdminAuth";
import useToast from "../../../../../hooks/useToast";
// variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: "easeOut" },
},
};
/**
* YouTube 일정 수정
* - 기존 일정 데이터 로드
* - 멤버 선택 수정
*/
function YouTubeEditForm() {
const navigate = useNavigate();
const { id } = useParams();
const queryClient = useQueryClient();
const { user, isAuthenticated } = useAdminAuth();
const { toast, setToast } = useToast();
const [saving, setSaving] = useState(false);
const [selectedMembers, setSelectedMembers] = useState([]);
const [videoType, setVideoType] = useState("video");
const [isInitialized, setIsInitialized] = useState(false);
//
const { data: schedule, isLoading: scheduleLoading } = useQuery({
queryKey: ["schedule", id],
queryFn: async () => {
const token = localStorage.getItem("adminToken");
const res = await fetch(`/api/schedules/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error("일정을 찾을 수 없습니다.");
return res.json();
},
enabled: isAuthenticated && !!id,
});
//
const { data: membersData = [], isLoading: membersLoading } = useQuery({
queryKey: ["members"],
queryFn: async () => {
const res = await fetch("/api/members");
if (!res.ok) throw new Error("멤버 목록을 불러올 수 없습니다.");
return res.json();
},
enabled: isAuthenticated,
});
//
const members = membersData.filter((m) => !m.is_former);
//
useEffect(() => {
if (schedule && !isInitialized) {
// YouTube
if (schedule.category?.id !== 2) {
setToast({ type: "error", message: "YouTube 일정이 아닙니다." });
navigate("/admin/schedule");
return;
}
setSelectedMembers(schedule.members?.map((m) => m.id) || []);
setVideoType(schedule.videoType || "video");
setIsInitialized(true);
}
}, [schedule, isInitialized, navigate, setToast]);
const loading = scheduleLoading || membersLoading;
//
const toggleMember = (memberId) => {
setSelectedMembers((prev) =>
prev.includes(memberId)
? prev.filter((id) => id !== memberId)
: [...prev, memberId]
);
};
// /
const toggleAllMembers = () => {
if (selectedMembers.length === members.length) {
setSelectedMembers([]);
} else {
setSelectedMembers(members.map((m) => m.id));
}
};
//
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
try {
const token = localStorage.getItem("adminToken");
const response = await fetch(`/api/admin/youtube/schedule/${id}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
memberIds: selectedMembers,
videoType,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "수정에 실패했습니다.");
}
//
queryClient.invalidateQueries({ queryKey: ["schedule", id] });
sessionStorage.setItem(
"scheduleToast",
JSON.stringify({
type: "success",
message: "YouTube 일정이 수정되었습니다.",
})
);
navigate("/admin/schedule");
} catch (err) {
setToast({
type: "error",
message: err.message,
});
} finally {
setSaving(false);
}
};
if (loading) {
return (
<AdminLayout user={user}>
<div className="flex items-center justify-center min-h-[400px]">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
</div>
</AdminLayout>
);
}
if (!schedule) {
return null;
}
const videoUrl = videoType === "shorts"
? `https://www.youtube.com/shorts/${schedule.videoId}`
: `https://www.youtube.com/watch?v=${schedule.videoId}`;
// (datetime )
const formatDatetime = (datetime) => {
if (!datetime) return "";
// datetime: "2025-01-20 14:00" "2025-01-20"
const [dateStr, timeStr] = datetime.split(" ");
const date = new Date(dateStr);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const dayNames = ["일", "월", "화", "수", "목", "금", "토"];
const dayName = dayNames[date.getDay()];
const time = timeStr ? timeStr.slice(0, 5) : "";
return `${year}${month}${day}일 (${dayName}) ${time}`;
};
return (
<AdminLayout user={user}>
<Toast toast={toast} onClose={() => setToast(null)} />
<motion.div
className="max-w-4xl mx-auto px-6 py-8"
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* 브레드크럼 */}
<motion.div
variants={itemVariants}
className="flex items-center gap-2 text-sm text-gray-400 mb-8"
>
<Link
to="/admin/dashboard"
className="hover:text-primary transition-colors"
>
<Home size={16} />
</Link>
<ChevronRight size={14} />
<Link
to="/admin/schedule"
className="hover:text-primary transition-colors"
>
일정 관리
</Link>
<ChevronRight size={14} />
<span className="text-gray-700">YouTube 일정 수정</span>
</motion.div>
<form onSubmit={handleSubmit} className="space-y-6">
{videoType === "shorts" ? (
/* Shorts 레이아웃: 영상(왼쪽) + 정보/멤버(오른쪽) */
<motion.div
variants={itemVariants}
className="bg-white rounded-2xl shadow-sm p-8"
>
<div className="flex gap-8">
{/* 왼쪽: 영상 */}
<div className="flex-shrink-0 w-96">
<div className="bg-black rounded-xl overflow-hidden">
<div className="relative aspect-[9/16]">
<iframe
src={`https://www.youtube.com/embed/${schedule.videoId}`}
title={schedule.title}
className="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
</div>
{/* 오른쪽: 정보 + 멤버 선택 */}
<div className="flex-1 min-w-0">
{/* 영상 정보 */}
<div className="flex items-center gap-2 mb-4">
<Youtube size={24} className="text-red-500" />
<h2 className="text-lg font-bold text-gray-900">영상 정보</h2>
</div>
<h3 className="text-base font-bold text-gray-900 mb-3 line-clamp-2">
{schedule.title}
</h3>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 mb-3">
<span>
<span className="text-gray-400">채널:</span>{" "}
{schedule.channelName}
</span>
<span>
<span className="text-gray-400">업로드:</span>{" "}
{formatDatetime(schedule.datetime)}
</span>
</div>
<div className="flex items-center gap-4 mb-4">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-400">유형:</span>
<button
type="button"
onClick={() => setVideoType("video")}
className="px-3 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-500 hover:bg-gray-200 transition-colors"
>
Video
</button>
<button
type="button"
onClick={() => setVideoType("shorts")}
className="px-3 py-1 rounded-full text-xs font-medium bg-pink-500 text-white transition-colors"
>
Shorts
</button>
</div>
<a
href={videoUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-red-500 hover:text-red-600 transition-colors"
>
<ExternalLink size={14} />
YouTube
</a>
</div>
{/* 멤버 선택 */}
<div className="border-t border-gray-100 pt-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Users size={18} className="text-primary" />
<h2 className="text-base font-bold text-gray-900">출연 멤버</h2>
</div>
<button
type="button"
onClick={toggleAllMembers}
className="text-sm text-primary hover:underline"
>
{selectedMembers.length === members.length
? "전체 해제"
: "전체 선택"}
</button>
</div>
<div className="grid grid-cols-3 gap-3">
{members.map((member) => {
const isSelected = selectedMembers.includes(member.id);
return (
<button
key={member.id}
type="button"
onClick={() => toggleMember(member.id)}
className={`relative rounded-xl overflow-hidden transition-all ${
isSelected
? "ring-2 ring-primary ring-offset-2"
: "hover:opacity-80"
}`}
>
<div className="aspect-[3/4] bg-gray-100">
{member.image_url ? (
<img
src={member.image_url}
alt={member.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-200">
<Users size={20} className="text-gray-400" />
</div>
)}
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-2">
<p className="text-white text-xs font-medium text-center">
{member.name}
</p>
</div>
{isSelected && (
<div className="absolute top-1.5 right-1.5 w-5 h-5 bg-primary rounded-full flex items-center justify-center">
<Check size={12} className="text-white" />
</div>
)}
</button>
);
})}
</div>
</div>
</div>
</div>
</motion.div>
) : (
/* Video 레이아웃: 기존 세로 배치 */
<>
<motion.div
variants={itemVariants}
className="bg-white rounded-2xl shadow-sm p-8"
>
<div className="flex items-center gap-2 mb-6">
<Youtube size={24} className="text-red-500" />
<h2 className="text-lg font-bold text-gray-900">영상 정보</h2>
</div>
<div className="w-full bg-black rounded-xl overflow-hidden mb-6">
<div className="relative aspect-video">
<iframe
src={`https://www.youtube.com/embed/${schedule.videoId}`}
title={schedule.title}
className="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
<div className="space-y-3">
<h3 className="text-lg font-bold text-gray-900">
{schedule.title}
</h3>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-gray-600">
<span>
<span className="text-gray-400">채널:</span>{" "}
{schedule.channelName}
</span>
<span>
<span className="text-gray-400">업로드:</span>{" "}
{formatDatetime(schedule.datetime)}
</span>
<div className="flex items-center gap-2">
<span className="text-gray-400">유형:</span>
<button
type="button"
onClick={() => setVideoType("video")}
className="px-3 py-1 rounded-full text-xs font-medium bg-blue-500 text-white transition-colors"
>
Video
</button>
<button
type="button"
onClick={() => setVideoType("shorts")}
className="px-3 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-500 hover:bg-gray-200 transition-colors"
>
Shorts
</button>
</div>
</div>
<a
href={videoUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm text-red-500 hover:text-red-600 transition-colors"
>
<ExternalLink size={16} />
YouTube에서 보기
</a>
</div>
</motion.div>
<motion.div
variants={itemVariants}
className="bg-white rounded-2xl shadow-sm p-8"
>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Users size={20} className="text-primary" />
<h2 className="text-lg font-bold text-gray-900">출연 멤버</h2>
</div>
<button
type="button"
onClick={toggleAllMembers}
className="text-sm text-primary hover:underline"
>
{selectedMembers.length === members.length
? "전체 해제"
: "전체 선택"}
</button>
</div>
<div className="grid grid-cols-5 gap-4">
{members.map((member) => {
const isSelected = selectedMembers.includes(member.id);
return (
<button
key={member.id}
type="button"
onClick={() => toggleMember(member.id)}
className={`relative rounded-xl overflow-hidden transition-all ${
isSelected
? "ring-2 ring-primary ring-offset-2"
: "hover:opacity-80"
}`}
>
<div className="aspect-[3/4] bg-gray-100">
{member.image_url ? (
<img
src={member.image_url}
alt={member.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-200">
<Users size={24} className="text-gray-400" />
</div>
)}
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-3">
<p className="text-white text-sm font-medium">
{member.name}
</p>
</div>
{isSelected && (
<div className="absolute top-2 right-2 w-6 h-6 bg-primary rounded-full flex items-center justify-center">
<Check size={14} className="text-white" />
</div>
)}
</button>
);
})}
</div>
</motion.div>
</>
)}
{/* 버튼 */}
<motion.div
variants={itemVariants}
className="flex items-center justify-end gap-4"
>
<button
type="button"
onClick={() => navigate("/admin/schedule")}
className="px-6 py-3 text-gray-700 hover:bg-gray-100 rounded-xl transition-colors font-medium"
>
취소
</button>
<button
type="submit"
disabled={saving}
className="flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? (
<>
<Loader2 size={18} className="animate-spin" />
저장 ...
</>
) : (
<>
<Save size={18} />
저장하기
</>
)}
</button>
</motion.div>
</form>
</motion.div>
</AdminLayout>
);
}
export default YouTubeEditForm;

View file

@ -45,30 +45,10 @@ function AlbumDetail() {
})); }));
}, [lightbox.images.length]); }, [lightbox.images.length]);
// -
const openLightbox = useCallback((images, index, options = {}) => {
setLightbox({ open: true, images, index, teasers: options.teasers });
window.history.pushState({ lightbox: true }, '');
}, []);
const closeLightbox = useCallback(() => { const closeLightbox = useCallback(() => {
setLightbox(prev => ({ ...prev, open: false })); setLightbox(prev => ({ ...prev, open: false }));
}, []); }, []);
//
useEffect(() => {
const handlePopState = () => {
if (showDescriptionModal) {
setShowDescriptionModal(false);
} else if (lightbox.open) {
setLightbox(prev => ({ ...prev, open: false }));
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [showDescriptionModal, lightbox.open]);
// body // body
useEffect(() => { useEffect(() => {
if (lightbox.open) { if (lightbox.open) {
@ -118,7 +98,7 @@ function AlbumDetail() {
goToNext(); goToNext();
break; break;
case 'Escape': case 'Escape':
window.history.back(); closeLightbox();
break; break;
default: default:
break; break;
@ -222,10 +202,11 @@ function AlbumDetail() {
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4 }} transition={{ duration: 0.4 }}
className="w-80 h-80 flex-shrink-0 rounded-2xl overflow-hidden shadow-lg cursor-pointer group" className="w-80 h-80 flex-shrink-0 rounded-2xl overflow-hidden shadow-lg cursor-pointer group"
onClick={() => openLightbox( onClick={() => setLightbox({
[album.cover_original_url || album.cover_medium_url], open: true,
0 images: [album.cover_original_url || album.cover_medium_url],
)} index: 0
})}
> >
<img <img
src={album.cover_medium_url || album.cover_original_url} src={album.cover_medium_url || album.cover_original_url}
@ -272,7 +253,6 @@ function AlbumDetail() {
<button <button
onClick={() => { onClick={() => {
setShowDescriptionModal(true); setShowDescriptionModal(true);
window.history.pushState({ description: true }, '');
setShowMenu(false); setShowMenu(false);
}} }}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors" className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
@ -313,13 +293,14 @@ function AlbumDetail() {
{album.teasers.map((teaser, index) => ( {album.teasers.map((teaser, index) => (
<div <div
key={index} key={index}
onClick={() => openLightbox( onClick={() => setLightbox({
album.teasers.map(t => open: true,
images: album.teasers.map(t =>
t.media_type === 'video' ? (t.video_url || t.original_url) : t.original_url t.media_type === 'video' ? (t.video_url || t.original_url) : t.original_url
), ),
index, index,
{ teasers: album.teasers } teasers: album.teasers // media_type
)} })}
className="w-24 h-24 bg-gray-200 rounded-lg overflow-hidden cursor-pointer transition-all duration-300 ease-out hover:scale-105 hover:shadow-xl hover:z-10 relative" className="w-24 h-24 bg-gray-200 rounded-lg overflow-hidden cursor-pointer transition-all duration-300 ease-out hover:scale-105 hover:shadow-xl hover:z-10 relative"
> >
<> <>
@ -352,7 +333,7 @@ function AlbumDetail() {
transition={{ delay: 0.2, duration: 0.4 }} transition={{ delay: 0.2, duration: 0.4 }}
> >
<h2 className="text-xl font-bold mb-4">수록곡</h2> <h2 className="text-xl font-bold mb-4">수록곡</h2>
<div className="bg-white rounded-2xl shadow-md overflow-hidden"> <div className="bg-white rounded-2xl shadow-lg overflow-hidden">
{album.tracks?.map((track, index) => ( {album.tracks?.map((track, index) => (
<div <div
key={track.id} key={track.id}
@ -418,7 +399,7 @@ function AlbumDetail() {
{previewPhotos.map((photo, idx) => ( {previewPhotos.map((photo, idx) => (
<div <div
key={photo.id} key={photo.id}
onClick={() => openLightbox([photo.original_url], 0)} onClick={() => setLightbox({ open: true, images: [photo.original_url], index: 0 })}
className="aspect-square bg-gray-200 rounded-xl overflow-hidden cursor-pointer transition-all duration-300 ease-out hover:scale-[1.03] hover:shadow-xl hover:z-10" className="aspect-square bg-gray-200 rounded-xl overflow-hidden cursor-pointer transition-all duration-300 ease-out hover:scale-[1.03] hover:shadow-xl hover:z-10"
> >
<img <img
@ -466,7 +447,7 @@ function AlbumDetail() {
{/* 닫기 버튼 */} {/* 닫기 버튼 */}
<button <button
className="text-white/70 hover:text-white transition-colors" className="text-white/70 hover:text-white transition-colors"
onClick={() => window.history.back()} onClick={closeLightbox}
> >
<X size={32} /> <X size={32} />
</button> </button>
@ -556,7 +537,7 @@ function AlbumDetail() {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
onClick={() => window.history.back()} onClick={() => setShowDescriptionModal(false)}
> >
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
@ -569,7 +550,7 @@ function AlbumDetail() {
<div className="flex items-center justify-between p-5 border-b border-gray-100"> <div className="flex items-center justify-between p-5 border-b border-gray-100">
<h3 className="text-lg font-bold">앨범 소개</h3> <h3 className="text-lg font-bold">앨범 소개</h3>
<button <button
onClick={() => window.history.back()} onClick={() => setShowDescriptionModal(false)}
className="p-1.5 hover:bg-gray-100 rounded-full transition-colors" className="p-1.5 hover:bg-gray-100 rounded-full transition-colors"
> >
<X size={20} className="text-gray-500" /> <X size={20} className="text-gray-500" />

View file

@ -70,30 +70,17 @@ function AlbumGallery() {
return allPhotos; return allPhotos;
}, [album]); }, [album]);
// - //
const openLightbox = useCallback((index) => { const openLightbox = (index) => {
setImageLoaded(false); setImageLoaded(false);
setLightbox({ open: true, index }); setLightbox({ open: true, index });
window.history.pushState({ lightbox: true }, ''); };
}, []);
// //
const closeLightbox = useCallback(() => { const closeLightbox = useCallback(() => {
setLightbox(prev => ({ ...prev, open: false })); setLightbox(prev => ({ ...prev, open: false }));
}, []); }, []);
//
useEffect(() => {
const handlePopState = () => {
if (lightbox.open) {
setLightbox(prev => ({ ...prev, open: false }));
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [lightbox.open]);
// body // body
useEffect(() => { useEffect(() => {
if (lightbox.open) { if (lightbox.open) {
@ -159,7 +146,7 @@ function AlbumGallery() {
switch (e.key) { switch (e.key) {
case 'ArrowLeft': goToPrev(); break; case 'ArrowLeft': goToPrev(); break;
case 'ArrowRight': goToNext(); break; case 'ArrowRight': goToNext(); break;
case 'Escape': window.history.back(); break; case 'Escape': closeLightbox(); break;
default: break; default: break;
} }
}; };
@ -282,7 +269,7 @@ function AlbumGallery() {
</button> </button>
<button <button
className="text-white/70 hover:text-white transition-colors" className="text-white/70 hover:text-white transition-colors"
onClick={() => window.history.back()} onClick={closeLightbox}
> >
<X size={32} /> <X size={32} />
</button> </button>

View file

@ -254,10 +254,9 @@ function Home() {
// //
const memberList = schedule.member_names const memberList = schedule.member_names
? schedule.member_names.split(",") ? schedule.member_names.split(",")
: schedule.members?.map(m => m.name) || []; : [];
const displayMembers = memberList; const displayMembers =
memberList.length >= 5 ? ["프로미스나인"] : memberList;
const categoryColor = schedule.category_color || '#6366f1';
return ( return (
<motion.div <motion.div
@ -270,48 +269,71 @@ function Home() {
transition: { duration: 0.4, ease: "easeOut" }, transition: { duration: 0.4, ease: "easeOut" },
}, },
}} }}
className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer" className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden"
> >
{/* 날짜 영역 - 카테고리 색상 */} {/* 날짜 영역 - primary 색상 고정 */}
<div <div className="w-20 flex flex-col items-center justify-center text-white py-5 bg-primary">
className="w-24 flex flex-col items-center justify-center text-white py-6" {/* 현재 년도가 아니면 년.월 표시 */}
style={{ backgroundColor: categoryColor }}
>
{!isCurrentYear && ( {!isCurrentYear && (
<span className="text-xs font-medium opacity-70"> <span className="text-xs font-medium opacity-70">
{scheduleYear}.{scheduleMonth + 1} {scheduleYear}.{scheduleMonth + 1}
</span> </span>
)} )}
{/* 현재 달이 아니면 월 표시 (현재 년도일 때) */}
{isCurrentYear && !isCurrentMonth && ( {isCurrentYear && !isCurrentMonth && (
<span className="text-xs font-medium opacity-70"> <span className="text-xs font-medium opacity-70">
{scheduleMonth + 1} {scheduleMonth + 1}
</span> </span>
)} )}
<span className="text-3xl font-bold">{day}</span> <span className="text-3xl font-bold">{day}</span>
<span className="text-sm font-medium opacity-80">{weekday}</span> <span className="text-sm font-medium opacity-80">
{weekday}
</span>
</div> </div>
{/* 내용 영역 */} {/* 내용 영역 */}
<div className="flex-1 p-6 flex flex-col justify-center"> <div className="flex-1 p-5 flex flex-col justify-center">
<h3 className="font-bold text-lg mb-2">{schedule.title}</h3> <h3 className="font-bold text-lg text-gray-900 mb-2">
<div className="flex flex-wrap gap-3 text-base text-gray-500"> {schedule.title}
</h3>
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-500">
{schedule.time && ( {schedule.time && (
<span className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Clock size={16} className="opacity-60" /> <Clock
{schedule.time.slice(0, 5)} size={14}
</span> className="text-primary opacity-60"
/>
<span>{schedule.time.slice(0, 5)}</span>
</div>
)}
{schedule.category_name && (
<div className="flex items-center gap-1">
<Tag
size={14}
className="text-primary opacity-60"
/>
<span>{schedule.category_name}</span>
</div>
)}
{schedule.source?.name && (
<div className="flex items-center gap-1">
<Link2
size={14}
className="text-primary opacity-60"
/>
<span>{schedule.source?.name}</span>
</div>
)} )}
<span className="flex items-center gap-1">
<Tag size={16} className="opacity-60" />
{schedule.category_name}
</span>
</div> </div>
{/* 멤버 태그 */}
{displayMembers.length > 0 && ( {displayMembers.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2"> <div className="flex flex-wrap gap-1.5 mt-3">
{displayMembers.map((name, i) => ( {displayMembers.map((name, i) => (
<span <span
key={i} key={i}
className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full" className="px-2.5 py-1 bg-primary/10 text-primary text-xs font-medium rounded-full"
> >
{name.trim()} {name.trim()}
</span> </span>

View file

@ -64,9 +64,10 @@ function Members() {
{/* 정보 */} {/* 정보 */}
<div className="p-6 flex-1 flex flex-col"> <div className="p-6 flex-1 flex flex-col">
<h3 className="text-xl font-bold mb-3">{member.name}</h3> <h3 className="text-xl font-bold mb-1">{member.name}</h3>
<p className="text-primary text-sm font-medium mb-3 min-h-[20px]">{member.position || '\u00A0'}</p>
<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-4">
<Calendar 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>
@ -122,7 +123,8 @@ function Members() {
{/* 정보 */} {/* 정보 */}
<div className="p-6 flex-1 flex flex-col"> <div className="p-6 flex-1 flex flex-col">
<h3 className="text-xl font-bold mb-3 text-gray-500">{member.name}</h3> <h3 className="text-xl font-bold mb-1 text-gray-500">{member.name}</h3>
<p className="text-gray-400 text-sm font-medium mb-3 min-h-[20px]">{member.position || '\u00A0'}</p>
<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} /> <Calendar size={14} />

View file

@ -274,7 +274,7 @@ function Schedule() {
category_name: s.category?.name, category_name: s.category?.name,
category_color: s.category?.color, category_color: s.category?.color,
// members ( ) // members ( )
member_names: Array.isArray(s.members) ? s.members.map(m => m.name).join(',') : s.member_names, member_names: Array.isArray(s.members) ? s.members.join(',') : s.member_names,
}; };
}) })
); );
@ -395,11 +395,7 @@ function Schedule() {
return scheduleDateMap.has(dateStr); return scheduleDateMap.has(dateStr);
}; };
// 2017 1
const canGoPrevMonth = !(year === 2017 && month === 0);
const prevMonth = () => { const prevMonth = () => {
if (!canGoPrevMonth) return;
setSlideDirection(-1); setSlideDirection(-1);
const newDate = new Date(year, month - 1, 1); const newDate = new Date(year, month - 1, 1);
setCurrentDate(newDate); setCurrentDate(newDate);
@ -571,8 +567,8 @@ function Schedule() {
return year === now.getFullYear() && m === now.getMonth(); return year === now.getFullYear() && m === now.getMonth();
}; };
// // (2025 )
const MIN_YEAR = 2017; const MIN_YEAR = 2025;
const [yearRangeStart, setYearRangeStart] = useState(MIN_YEAR); const [yearRangeStart, setYearRangeStart] = useState(MIN_YEAR);
const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i); const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i);
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']; const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
@ -621,7 +617,7 @@ function Schedule() {
return ( return (
<div className="h-[calc(100vh-64px)] overflow-hidden flex flex-col"> <div className="h-[calc(100vh-64px)] overflow-hidden flex flex-col">
<div className="flex-1 flex flex-col overflow-hidden max-w-7xl mx-auto px-6 pt-16 pb-8 w-full"> <div className="flex-1 flex flex-col overflow-hidden max-w-7xl mx-auto px-6 py-8 w-full">
{/* 헤더 */} {/* 헤더 */}
<div className="flex-shrink-0 text-center mb-8"> <div className="flex-shrink-0 text-center mb-8">
<motion.h1 <motion.h1
@ -656,8 +652,7 @@ function Schedule() {
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<button <button
onClick={prevMonth} onClick={prevMonth}
disabled={!canGoPrevMonth} className="p-2 hover:bg-gray-100 rounded-full transition-colors"
className={`p-2 rounded-full transition-colors ${canGoPrevMonth ? 'hover:bg-gray-100' : 'opacity-30'}`}
> >
<ChevronLeft size={24} /> <ChevronLeft size={24} />
</button> </button>

View file

@ -8,6 +8,7 @@ import { getSchedule } from '../../../api/public/schedules';
import { import {
YoutubeSection, YoutubeSection,
XSection, XSection,
ConcertSection,
DefaultSection, DefaultSection,
CATEGORY_ID, CATEGORY_ID,
decodeHtmlEntities, decodeHtmlEntities,
@ -117,25 +118,27 @@ function ScheduleDetail() {
} }
// //
const categoryId = schedule.category?.id;
const renderCategorySection = () => { const renderCategorySection = () => {
switch (categoryId) { switch (schedule.category_id) {
case CATEGORY_ID.YOUTUBE: case CATEGORY_ID.YOUTUBE:
return <YoutubeSection schedule={schedule} />; return <YoutubeSection schedule={schedule} />;
case CATEGORY_ID.X: case CATEGORY_ID.X:
return <XSection schedule={schedule} />; return <XSection schedule={schedule} />;
case CATEGORY_ID.CONCERT:
return <ConcertSection schedule={schedule} />;
default: default:
return <DefaultSection schedule={schedule} />; return <DefaultSection schedule={schedule} />;
} }
}; };
const isYoutube = categoryId === CATEGORY_ID.YOUTUBE; const isYoutube = schedule.category_id === CATEGORY_ID.YOUTUBE;
const isX = categoryId === CATEGORY_ID.X; const isX = schedule.category_id === CATEGORY_ID.X;
const hasCustomLayout = isYoutube || isX; const isConcert = schedule.category_id === CATEGORY_ID.CONCERT;
const hasCustomLayout = isYoutube || isX || isConcert;
return ( return (
<div className="min-h-[calc(100vh-64px)] bg-gray-50"> <div className="min-h-[calc(100vh-64px)] bg-gray-50">
<div className={`${isYoutube ? 'max-w-5xl' : 'max-w-3xl'} mx-auto px-6 py-8`}> <div className={`${isYoutube ? 'max-w-5xl' : isConcert ? 'max-w-4xl' : 'max-w-3xl'} mx-auto px-6 py-8`}>
{/* 브레드크럼 네비게이션 */} {/* 브레드크럼 네비게이션 */}
<motion.div <motion.div
initial={{ opacity: 0, y: -10 }} initial={{ opacity: 0, y: -10 }}
@ -148,9 +151,9 @@ function ScheduleDetail() {
<ChevronRight size={14} /> <ChevronRight size={14} />
<span <span
className="hover:text-primary transition-colors" className="hover:text-primary transition-colors"
style={{ color: schedule.category?.color }} style={{ color: schedule.category_color }}
> >
{schedule.category?.name} {schedule.category_name}
</span> </span>
<ChevronRight size={14} /> <ChevronRight size={14} />
<span className="text-gray-700 font-medium truncate max-w-md"> <span className="text-gray-700 font-medium truncate max-w-md">

View file

@ -0,0 +1,389 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
import { ExternalLink, ChevronDown, Check, MapPin, Navigation } from 'lucide-react';
import { getSchedule } from '../../../../api/public/schedules';
import { decodeHtmlEntities } from './utils';
import KakaoMap from './KakaoMap';
//
function ConcertSection({ schedule }) {
// ID ( state - URL )
const [selectedDateId, setSelectedDateId] = useState(schedule.id);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef(null);
const dropdownListRef = useRef(null);
// state ( )
const [displayData, setDisplayData] = useState({
posterUrl: schedule.images?.[0] || null,
title: schedule.title,
date: schedule.date,
time: schedule.time,
locationName: schedule.location_name,
locationAddress: schedule.location_address,
locationLat: schedule.location_lat,
locationLng: schedule.location_lng,
description: schedule.description,
sourceUrl: schedule.source?.url,
});
//
const { data: selectedSchedule } = useQuery({
queryKey: ['schedule', selectedDateId],
queryFn: () => getSchedule(selectedDateId),
placeholderData: keepPreviousData,
enabled: selectedDateId !== schedule.id,
});
//
useEffect(() => {
const newData = selectedDateId === schedule.id ? schedule : selectedSchedule;
if (!newData) return;
setDisplayData(prev => {
const updates = {};
const newPosterUrl = newData.images?.[0] || null;
if (prev.posterUrl !== newPosterUrl) updates.posterUrl = newPosterUrl;
if (prev.title !== newData.title) updates.title = newData.title;
if (prev.date !== newData.date) updates.date = newData.date;
if (prev.time !== newData.time) updates.time = newData.time;
if (prev.locationName !== newData.location_name) updates.locationName = newData.location_name;
if (prev.locationAddress !== newData.location_address) updates.locationAddress = newData.location_address;
if (prev.locationLat !== newData.location_lat) updates.locationLat = newData.location_lat;
if (prev.locationLng !== newData.location_lng) updates.locationLng = newData.location_lng;
if (prev.description !== newData.description) updates.description = newData.description;
if (prev.sourceUrl !== newData.source?.url) updates.sourceUrl = newData.source?.url;
if (Object.keys(updates).length > 0) {
return { ...prev, ...updates };
}
return prev;
});
}, [selectedDateId, schedule, selectedSchedule]);
//
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
//
useEffect(() => {
if (isDropdownOpen && dropdownListRef.current) {
const selectedElement = dropdownListRef.current.querySelector('[data-selected="true"]');
if (selectedElement) {
// ( )
setTimeout(() => {
selectedElement.scrollIntoView({ block: 'center', behavior: 'smooth' });
}, 50);
}
}
}, [isDropdownOpen]);
const relatedDates = schedule.related_dates || [];
const hasMultipleDates = relatedDates.length > 1;
const hasLocation = displayData.locationLat && displayData.locationLng;
const hasPoster = !!displayData.posterUrl;
const hasDescription = !!displayData.description;
//
const selectedIndex = relatedDates.findIndex(d => d.id === selectedDateId);
const selectedDisplayIndex = selectedIndex >= 0 ? selectedIndex + 1 : 1;
//
const handleSelectDate = (id) => {
setSelectedDateId(id);
setIsDropdownOpen(false);
};
// ( )
const formatShortDate = (dateStr, timeStr) => {
const date = new Date(dateStr);
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
const month = date.getMonth() + 1;
const day = date.getDate();
const weekday = dayNames[date.getDay()];
let result = `${month}/${day} (${weekday})`;
if (timeStr) {
result += ` ${timeStr.slice(0, 5)}`;
}
return result;
};
return (
<div className="space-y-6">
{/* ========== 히어로 섹션 ========== */}
<motion.section
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="relative rounded-3xl overflow-visible"
>
{/* 배경 레이어 - 포스터 확대 + 블러 */}
<div className="absolute inset-0 rounded-3xl overflow-hidden">
{hasPoster ? (
<>
<img
src={displayData.posterUrl}
alt=""
className="w-full h-full object-cover scale-125 blur-xl"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/50 to-black/30" />
</>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-primary via-primary/90 to-primary/70" />
)}
</div>
{/* 메인 콘텐츠 */}
<div className="relative z-10 p-10">
<div className="flex items-start gap-10">
{/* 포스터 */}
{hasPoster && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="flex-shrink-0"
>
<img
src={displayData.posterUrl}
alt={displayData.title}
className="w-52 h-72 object-cover rounded-2xl shadow-2xl"
/>
</motion.div>
)}
{/* 제목 및 회차 선택 */}
<div className="flex-1 min-w-0 pt-4">
{/* 제목 */}
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="text-3xl font-bold text-white leading-tight mb-6"
>
{decodeHtmlEntities(displayData.title)}
</motion.h1>
{/* 회차 선택 드롭다운 */}
{hasMultipleDates && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
ref={dropdownRef}
className="relative inline-block"
>
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="group flex items-center gap-4 px-5 py-3.5 bg-white rounded-2xl shadow-lg hover:shadow-xl transition-all border border-gray-100"
>
{/* 회차 뱃지 */}
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<span className="text-xs font-bold text-white">{selectedDisplayIndex}</span>
</div>
<span className="text-xs font-medium text-gray-400">회차</span>
</div>
{/* 구분선 */}
<div className="w-px h-6 bg-gray-200" />
{/* 날짜 정보 */}
<div className="text-left">
<div className="text-sm font-semibold text-gray-800">
{formatShortDate(displayData.date, displayData.time)}
</div>
</div>
<ChevronDown
size={18}
className={`text-gray-400 transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''}`}
/>
</button>
<AnimatePresence>
{isDropdownOpen && (
<motion.div
initial={{ opacity: 0, y: -10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="absolute top-full left-0 mt-3 bg-white rounded-2xl shadow-2xl z-[100] w-80 overflow-hidden border border-gray-100"
>
{/* 드롭다운 헤더 */}
<div className="px-4 py-3 bg-gray-50 border-b border-gray-100">
<span className="text-sm font-semibold text-gray-600">공연 일정 선택</span>
</div>
<div ref={dropdownListRef} className="py-2 max-h-72 overflow-y-auto">
{relatedDates.map((item, index) => {
const isSelected = item.id === selectedDateId;
return (
<button
key={item.id}
data-selected={isSelected}
onClick={() => handleSelectDate(item.id)}
className={`w-full flex items-center gap-4 px-4 py-3.5 transition-all ${
isSelected
? 'bg-primary/5'
: 'hover:bg-gray-50'
}`}
>
{/* 회차 번호 */}
<div className={`w-9 h-9 rounded-xl flex items-center justify-center text-sm font-bold transition-colors ${
isSelected
? 'bg-primary text-white'
: 'bg-gray-100 text-gray-500'
}`}>
{index + 1}
</div>
{/* 날짜 정보 */}
<div className="flex-1 text-left">
<div className={`font-medium ${
isSelected ? 'text-primary' : 'text-gray-800'
}`}>
{formatShortDate(item.date, item.time)}
</div>
</div>
{/* 체크 표시 */}
{isSelected && (
<div className="w-6 h-6 bg-primary rounded-full flex items-center justify-center">
<Check size={14} className="text-white" strokeWidth={3} />
</div>
)}
</button>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</div>
</div>
</div>
</motion.section>
{/* ========== 장소 정보 카드 ========== */}
{displayData.locationName && (
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="bg-white rounded-2xl shadow-sm ring-1 ring-gray-100 overflow-hidden"
>
{/* 헤더 */}
<div className="p-6 border-b border-gray-100">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* 장소 아이콘 */}
<div className="w-12 h-12 bg-gray-100 rounded-xl flex items-center justify-center">
<MapPin size={24} className="text-gray-600" />
</div>
{/* 텍스트 */}
<div>
<h2 className="text-lg font-bold text-gray-900">{displayData.locationName}</h2>
{displayData.locationAddress && (
<p className="text-sm text-gray-500 mt-0.5">{displayData.locationAddress}</p>
)}
</div>
</div>
{/* 길찾기 버튼 - 카카오맵(국내) / 구글맵(해외) */}
<a
href={hasLocation
? `https://map.kakao.com/link/to/${encodeURIComponent(displayData.locationName)},${displayData.locationLat},${displayData.locationLng}`
: `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(displayData.locationAddress || displayData.locationName)}`
}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2.5 text-white text-sm font-semibold rounded-lg transition-colors"
style={{
backgroundColor: hasLocation ? '#0079f4' : '#4285F4'
}}
>
<Navigation size={16} />
길찾기
</a>
</div>
</div>
{/* 지도 - 높이 2배 */}
<div className="h-[32rem]">
{hasLocation ? (
<KakaoMap
lat={parseFloat(displayData.locationLat)}
lng={parseFloat(displayData.locationLng)}
name={displayData.locationName}
/>
) : (
<iframe
src={`https://maps.google.com/maps?q=${encodeURIComponent(displayData.locationAddress || displayData.locationName)}&output=embed&hl=ko`}
className="h-full w-full border-0"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
title="Google Maps"
/>
)}
</div>
</motion.section>
)}
{/* ========== 공연 정보 카드 ========== */}
{hasDescription && (
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="bg-white rounded-2xl shadow-sm ring-1 ring-gray-100 p-6"
>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-purple-50 flex items-center justify-center">
<svg className="w-5 h-5 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h2 className="text-lg font-bold text-gray-900">공연 정보</h2>
</div>
<p className="text-gray-600 leading-relaxed whitespace-pre-wrap">
{decodeHtmlEntities(displayData.description)}
</p>
</motion.section>
)}
{/* ========== 외부 링크 버튼 ========== */}
{displayData.sourceUrl && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="flex justify-center"
>
<a
href={displayData.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-6 py-3 bg-gray-900 text-white rounded-xl font-semibold hover:bg-gray-800 transition-colors"
>
<ExternalLink size={18} />
티켓 예매 상세 정보
</a>
</motion.div>
)}
</div>
);
}
export default ConcertSection;

View file

@ -0,0 +1,88 @@
import { useEffect, useRef, useState } from 'react';
import { Navigation } from 'lucide-react';
// SDK
const KAKAO_MAP_KEY = import.meta.env.VITE_KAKAO_JS_KEY;
//
function KakaoMap({ lat, lng, name }) {
const mapRef = useRef(null);
const [mapLoaded, setMapLoaded] = useState(false);
const [mapError, setMapError] = useState(false);
useEffect(() => {
// API
if (!KAKAO_MAP_KEY) {
setMapError(true);
return;
}
// SDK
if (!window.kakao?.maps) {
const script = document.createElement('script');
script.src = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${KAKAO_MAP_KEY}&autoload=false`;
script.onload = () => {
window.kakao.maps.load(() => setMapLoaded(true));
};
script.onerror = () => setMapError(true);
document.head.appendChild(script);
} else {
setMapLoaded(true);
}
}, []);
useEffect(() => {
if (!mapLoaded || !mapRef.current || mapError) return;
try {
const position = new window.kakao.maps.LatLng(lat, lng);
const map = new window.kakao.maps.Map(mapRef.current, {
center: position,
level: 3,
});
//
const marker = new window.kakao.maps.Marker({
position,
map,
});
//
if (name) {
const infowindow = new window.kakao.maps.InfoWindow({
content: `<div style="padding:8px 12px;font-size:13px;font-weight:500;">${name}</div>`,
});
infowindow.open(map, marker);
}
} catch (e) {
setMapError(true);
}
}, [mapLoaded, lat, lng, name, mapError]);
//
if (mapError) {
return (
<a
href={`https://map.kakao.com/link/to/${encodeURIComponent(name)},${lat},${lng}`}
target="_blank"
rel="noopener noreferrer"
className="group flex h-full w-full items-center justify-center bg-white/80 text-center backdrop-blur transition-all hover:bg-white"
>
<div>
<Navigation size={32} className="mx-auto text-gray-400 group-hover:text-primary transition-colors mb-3" />
<p className="text-gray-700 font-semibold">{name}</p>
<p className="text-sm text-gray-400 mt-1">길찾기 열기</p>
</div>
</a>
);
}
return (
<div
ref={mapRef}
className="h-full w-full"
/>
);
}
export default KakaoMap;

View file

@ -1,63 +1,30 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import Linkify from 'react-linkify'; import { getXProfile } from '../../../../api/public/schedules';
import { decodeHtmlEntities } from './utils'; import { decodeHtmlEntities, formatXDateTime } from './utils';
import Lightbox from '../../../../components/common/Lightbox';
import { formatXDateTime } from '../../../../utils/date'; // X URL username
const extractXUsername = (url) => {
if (!url) return null;
const match = url.match(/(?:twitter\.com|x\.com)\/([^/]+)/);
return match ? match[1] : null;
};
// X() // X()
function XSection({ schedule }) { function XSection({ schedule }) {
const profile = schedule.profile; const username = extractXUsername(schedule.source?.url);
const username = profile?.username || 'realfromis_9';
const displayName = profile?.displayName || username; //
const { data: profile } = useQuery({
queryKey: ['x-profile', username],
queryFn: () => getXProfile(username),
enabled: !!username,
staleTime: 1000 * 60 * 60, // 1
});
const displayName = profile?.displayName || schedule.source?.name || username || 'Unknown';
const avatarUrl = profile?.avatarUrl; const avatarUrl = profile?.avatarUrl;
//
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const historyPushedRef = useRef(false);
const openLightbox = useCallback((index) => {
setLightboxIndex(index);
setLightboxOpen(true);
window.history.pushState({ lightbox: true }, '');
historyPushedRef.current = true;
}, []);
const closeLightbox = useCallback(() => {
setLightboxOpen(false);
if (historyPushedRef.current) {
historyPushedRef.current = false;
window.history.back();
}
}, []);
// ( )
useEffect(() => {
const handlePopState = () => {
if (lightboxOpen) {
historyPushedRef.current = false;
setLightboxOpen(false);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [lightboxOpen]);
// ( )
const linkDecorator = (href, text, key) => (
<a
key={key}
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
{text}
</a>
);
return ( return (
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
{/* X 스타일 카드 */} {/* X 스타일 카드 */}
@ -93,7 +60,9 @@ function XSection({ schedule }) {
<path d="M22.25 12c0-1.43-.88-2.67-2.19-3.34.46-1.39.2-2.9-.81-3.91s-2.52-1.27-3.91-.81c-.66-1.31-1.91-2.19-3.34-2.19s-2.67.88-3.34 2.19c-1.39-.46-2.9-.2-3.91.81s-1.27 2.52-.81 3.91C2.63 9.33 1.75 10.57 1.75 12s.88 2.67 2.19 3.34c-.46 1.39-.2 2.9.81 3.91s2.52 1.27 3.91.81c.66 1.31 1.91 2.19 3.34 2.19s2.67-.88 3.34-2.19c1.39.46 2.9.2 3.91-.81s1.27-2.52.81-3.91c1.31-.67 2.19-1.91 2.19-3.34zm-11.07 4.57l-3.84-3.84 1.27-1.27 2.57 2.57 5.39-5.39 1.27 1.27-6.66 6.66z"/> <path d="M22.25 12c0-1.43-.88-2.67-2.19-3.34.46-1.39.2-2.9-.81-3.91s-2.52-1.27-3.91-.81c-.66-1.31-1.91-2.19-3.34-2.19s-2.67.88-3.34 2.19c-1.39-.46-2.9-.2-3.91.81s-1.27 2.52-.81 3.91C2.63 9.33 1.75 10.57 1.75 12s.88 2.67 2.19 3.34c-.46 1.39-.2 2.9.81 3.91s2.52 1.27 3.91.81c.66 1.31 1.91 2.19 3.34 2.19s2.67-.88 3.34-2.19c1.39.46 2.9.2 3.91-.81s1.27-2.52.81-3.91c1.31-.67 2.19-1.91 2.19-3.34zm-11.07 4.57l-3.84-3.84 1.27-1.27 2.57 2.57 5.39-5.39 1.27 1.27-6.66 6.66z"/>
</svg> </svg>
</div> </div>
<span className="text-sm text-gray-500">@{username}</span> {username && (
<span className="text-sm text-gray-500">@{username}</span>
)}
</div> </div>
</div> </div>
</div> </div>
@ -101,55 +70,32 @@ function XSection({ schedule }) {
{/* 본문 */} {/* 본문 */}
<div className="p-5"> <div className="p-5">
<p className="text-gray-900 text-[17px] leading-relaxed whitespace-pre-wrap"> <p className="text-gray-900 text-[17px] leading-relaxed whitespace-pre-wrap">
<Linkify componentDecorator={linkDecorator}> {decodeHtmlEntities(schedule.description || schedule.title)}
{decodeHtmlEntities(schedule.content || schedule.title)}
</Linkify>
</p> </p>
</div> </div>
{/* 이미지 */} {/* 이미지 */}
{schedule.imageUrls?.length > 0 && ( {schedule.image_url && (
<div className="px-5 pb-3"> <div className="px-5 pb-3">
{schedule.imageUrls.length === 1 ? ( <img
<img src={schedule.image_url}
src={schedule.imageUrls[0]} alt=""
alt="" className="w-full rounded-2xl border border-gray-100"
className="w-full rounded-2xl border border-gray-100 cursor-pointer hover:opacity-90 transition-opacity" />
onClick={() => openLightbox(0)}
/>
) : (
<div className={`grid gap-1 rounded-2xl overflow-hidden border border-gray-100 ${
schedule.imageUrls.length === 2 ? 'grid-cols-2' :
schedule.imageUrls.length === 3 ? 'grid-cols-2' :
'grid-cols-2'
}`}>
{schedule.imageUrls.slice(0, 4).map((url, i) => (
<img
key={i}
src={url}
alt=""
className={`w-full object-cover cursor-pointer hover:opacity-90 transition-opacity ${
schedule.imageUrls.length === 3 && i === 0 ? 'row-span-2 h-full' : 'aspect-square'
}`}
onClick={() => openLightbox(i)}
/>
))}
</div>
)}
</div> </div>
)} )}
{/* 날짜/시간 */} {/* 날짜/시간 */}
<div className="px-5 py-4 border-t border-gray-100"> <div className="px-5 py-4 border-t border-gray-100">
<span className="text-gray-500 text-[15px]"> <span className="text-gray-500 text-[15px]">
{formatXDateTime(schedule.datetime)} {formatXDateTime(schedule.date, schedule.time)}
</span> </span>
</div> </div>
{/* X에서 보기 버튼 */} {/* X에서 보기 버튼 */}
<div className="px-5 py-4 border-t border-gray-100 bg-gray-50/50"> <div className="px-5 py-4 border-t border-gray-100 bg-gray-50/50">
<a <a
href={schedule.postUrl} href={schedule.source?.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-900 hover:bg-black text-white rounded-full font-medium transition-colors" className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-900 hover:bg-black text-white rounded-full font-medium transition-colors"
@ -161,15 +107,6 @@ function XSection({ schedule }) {
</a> </a>
</div> </div>
</motion.div> </motion.div>
{/* 라이트박스 */}
<Lightbox
images={schedule.imageUrls || []}
currentIndex={lightboxIndex}
isOpen={lightboxOpen}
onClose={closeLightbox}
onIndexChange={setLightboxIndex}
/>
</div> </div>
); );
} }

View file

@ -1,7 +1,6 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Calendar, Clock, Link2 } from 'lucide-react'; import { Calendar, Clock, Link2 } from 'lucide-react';
import { decodeHtmlEntities } from './utils'; import { decodeHtmlEntities, formatFullDate, formatTime } from './utils';
import { formatXDateTime } from '../../../../utils/date';
// () // ()
function VideoInfo({ schedule, isShorts }) { function VideoInfo({ schedule, isShorts }) {
@ -9,7 +8,7 @@ function VideoInfo({ schedule, isShorts }) {
const isFullGroup = members.length === 5; const isFullGroup = members.length === 5;
return ( return (
<div className={`bg-gradient-to-br from-gray-100 to-gray-200/80 rounded-2xl p-6 ${isShorts ? 'flex-1' : ''}`}> <div className={`bg-gradient-to-br from-gray-50 to-gray-100/50 rounded-2xl p-6 ${isShorts ? 'flex-1' : ''}`}>
{/* 제목 */} {/* 제목 */}
<h1 className={`font-bold text-gray-900 mb-4 leading-relaxed ${isShorts ? 'text-lg' : 'text-xl'}`}> <h1 className={`font-bold text-gray-900 mb-4 leading-relaxed ${isShorts ? 'text-lg' : 'text-xl'}`}>
{decodeHtmlEntities(schedule.title)} {decodeHtmlEntities(schedule.title)}
@ -17,19 +16,30 @@ function VideoInfo({ schedule, isShorts }) {
{/* 메타 정보 */} {/* 메타 정보 */}
<div className={`flex flex-wrap items-center gap-4 text-sm ${isShorts ? 'gap-3' : ''}`}> <div className={`flex flex-wrap items-center gap-4 text-sm ${isShorts ? 'gap-3' : ''}`}>
{/* 날짜/시간 */} {/* 날짜 */}
<div className="flex items-center gap-1.5 text-gray-500"> <div className="flex items-center gap-1.5 text-gray-500">
<Calendar size={14} /> <Calendar size={14} />
<span>{formatXDateTime(schedule.datetime)}</span> <span>{formatFullDate(schedule.date)}</span>
</div> </div>
{/* 시간 */}
{schedule.time && (
<>
<div className="w-px h-4 bg-gray-300" />
<div className="flex items-center gap-1.5 text-gray-500">
<Clock size={14} />
<span>{formatTime(schedule.time)}</span>
</div>
</>
)}
{/* 채널명 */} {/* 채널명 */}
{schedule.channelName && ( {schedule.source?.name && (
<> <>
<div className="w-px h-4 bg-gray-300" /> <div className="w-px h-4 bg-gray-300" />
<div className="flex items-center gap-2 text-gray-500"> <div className="flex items-center gap-2 text-gray-500">
<Link2 size={14} className="opacity-60" /> <Link2 size={14} className="opacity-60" />
<span className="font-medium">{schedule.channelName}</span> <span className="font-medium">{schedule.source.name}</span>
</div> </div>
</> </>
)} )}
@ -56,9 +66,9 @@ function VideoInfo({ schedule, isShorts }) {
)} )}
{/* 유튜브에서 보기 버튼 */} {/* 유튜브에서 보기 버튼 */}
<div className="mt-6 pt-5 border-t border-gray-300/60"> <div className="mt-6 pt-5 border-t border-gray-200">
<a <a
href={schedule.videoUrl} href={schedule.source?.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-red-500 hover:bg-red-600 text-white rounded-xl font-medium transition-colors shadow-lg shadow-red-500/20" className="inline-flex items-center gap-2 px-5 py-2.5 bg-red-500 hover:bg-red-600 text-white rounded-xl font-medium transition-colors shadow-lg shadow-red-500/20"
@ -73,10 +83,22 @@ function VideoInfo({ schedule, isShorts }) {
); );
} }
// ID
const extractYoutubeVideoId = (url) => {
if (!url) return null;
const shortMatch = url.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/);
if (shortMatch) return shortMatch[1];
const watchMatch = url.match(/youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/);
if (watchMatch) return watchMatch[1];
const shortsMatch = url.match(/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/);
if (shortsMatch) return shortsMatch[1];
return null;
};
// //
function YoutubeSection({ schedule }) { function YoutubeSection({ schedule }) {
const videoId = schedule.videoId; const videoId = extractYoutubeVideoId(schedule.source?.url);
const isShorts = schedule.videoType === 'shorts'; const isShorts = schedule.source?.url?.includes('/shorts/');
if (!videoId) return null; if (!videoId) return null;
@ -91,7 +113,7 @@ function YoutubeSection({ schedule }) {
transition={{ delay: 0.1 }} transition={{ delay: 0.1 }}
className="w-[420px] flex-shrink-0" className="w-[420px] flex-shrink-0"
> >
<div className="relative aspect-[9/16] bg-gray-900 rounded-2xl overflow-hidden shadow-lg shadow-black/10"> <div className="relative aspect-[9/16] bg-gray-900 rounded-2xl overflow-hidden shadow-2xl shadow-black/20">
<iframe <iframe
src={`https://www.youtube.com/embed/${videoId}?rel=0`} src={`https://www.youtube.com/embed/${videoId}?rel=0`}
title={schedule.title} title={schedule.title}
@ -125,7 +147,7 @@ function YoutubeSection({ schedule }) {
transition={{ delay: 0.1 }} transition={{ delay: 0.1 }}
className="w-full" className="w-full"
> >
<div className="relative aspect-video bg-gray-900 rounded-2xl overflow-hidden shadow-lg shadow-black/10"> <div className="relative aspect-video bg-gray-900 rounded-2xl overflow-hidden shadow-2xl shadow-black/20">
<iframe <iframe
src={`https://www.youtube.com/embed/${videoId}?rel=0`} src={`https://www.youtube.com/embed/${videoId}?rel=0`}
title={schedule.title} title={schedule.title}

View file

@ -1,4 +1,6 @@
export { default as YoutubeSection } from "./YoutubeSection"; export { default as YoutubeSection } from "./YoutubeSection";
export { default as XSection } from "./XSection"; export { default as XSection } from "./XSection";
export { default as ConcertSection } from "./ConcertSection";
export { default as DefaultSection } from "./DefaultSection"; export { default as DefaultSection } from "./DefaultSection";
export { default as KakaoMap } from "./KakaoMap";
export * from "./utils"; export * from "./utils";

View file

@ -49,4 +49,8 @@ export const formatXDateTime = (dateStr, timeStr) => {
export const CATEGORY_ID = { export const CATEGORY_ID = {
YOUTUBE: 2, YOUTUBE: 2,
X: 3, X: 3,
ALBUM: 4,
FANSIGN: 5,
CONCERT: 6,
TICKET: 7,
}; };

View file

@ -77,30 +77,5 @@ export const isToday = (date) => {
return isSameDay(date, dayjs()); return isSameDay(date, dayjs());
}; };
/**
* X(트위터) 스타일 날짜/시간 포맷팅
* 입력: "2026-01-18 19:00" 또는 "2026-01-18"
* 출력: "오후 7:00 · 2026년 1월 18일" 또는 "2026년 1월 18일"
* @param {string} datetime - 날짜/시간 문자열
* @returns {string} 포맷된 문자열
*/
export const formatXDateTime = (datetime) => {
if (!datetime) return '';
const d = dayjs(datetime).tz(KST);
const datePart = d.format('YYYY년 M월 D일');
// 시간이 포함된 경우
if (datetime.includes(' ') || datetime.includes('T')) {
const hour = d.hour();
const minute = d.minute();
const period = hour >= 12 ? '오후' : '오전';
const hour12 = hour > 12 ? hour - 12 : hour === 0 ? 12 : hour;
return `${period} ${hour12}:${String(minute).padStart(2, '0')} · ${datePart}`;
}
return datePart;
};
// dayjs 인스턴스도 export (고급 사용용) // dayjs 인스턴스도 export (고급 사용용)
export { dayjs }; export { dayjs };