Compare commits
No commits in common. "f483f2cf533e1cb8a515a50a435049bba18c80da" and "2d469739b7c66c3a874deb86665127f92f58f964" have entirely different histories.
f483f2cf53
...
2d469739b7
63 changed files with 1937 additions and 3327 deletions
|
|
@ -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);
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: '로그인 처리 중 오류가 발생했습니다.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: '사전 저장 중 오류가 발생했습니다.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: '통계 조회 실패' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
|
||||||
};
|
|
||||||
|
|
@ -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' } },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -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: '만료 시간' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -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'],
|
|
||||||
};
|
|
||||||
|
|
@ -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';
|
|
||||||
|
|
@ -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' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -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' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -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: '앨범이 삭제되었습니다.' };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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 [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] 리로드 완료');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미지
|
// 이미지
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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');
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
81
docs/api.md
81
docs/api.md
|
|
@ -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 (인증 필요)
|
||||||
|
|
|
||||||
|
|
@ -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 # 프론트엔드 컨테이너
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 | 완료 |
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
85
frontend/package-lock.json
generated
85
frontend/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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('-', '.')}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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} 봇이 정지되었습니다.`
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
88
frontend/src/pages/pc/public/schedule-sections/KakaoMap.jsx
Normal file
88
frontend/src/pages/pc/public/schedule-sections/KakaoMap.jsx
Normal 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;
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue