Compare commits
30 commits
2d469739b7
...
f483f2cf53
| Author | SHA1 | Date | |
|---|---|---|---|
| f483f2cf53 | |||
| 84113a8c48 | |||
| e3278c81de | |||
| 2d7d82baf3 | |||
| 7b227a6c56 | |||
| f719fd9259 | |||
| 44e3878f2d | |||
| 0a91d04992 | |||
| c3e504d1e3 | |||
| 46469fd324 | |||
| 7593004bd6 | |||
| 2f30c67b93 | |||
| 430bf38c91 | |||
| b61bfe93b4 | |||
| 2b24bfe0a7 | |||
| 9eb49929c7 | |||
| bdd5b90870 | |||
| beabcc094f | |||
| a1090e56c0 | |||
| e1a07f4849 | |||
| 67e9992cf1 | |||
| d8055c00e5 | |||
| f797736f8e | |||
| 0c278597c0 | |||
| d1250124a7 | |||
| 1d17c83568 | |||
| 8bcd3e881d | |||
| f780e91f14 | |||
| 8e15cd6d2c | |||
| 4a4a163abe |
63 changed files with 3333 additions and 1943 deletions
121
backend/scripts/update-x-content.js
Normal file
121
backend/scripts/update-x-content.js
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* 기존 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,6 +7,7 @@ import fastifySwagger from '@fastify/swagger';
|
|||
import scalarApiReference from '@scalar/fastify-api-reference';
|
||||
import multipart from '@fastify/multipart';
|
||||
import config from './config/index.js';
|
||||
import * as schemas from './schemas/index.js';
|
||||
|
||||
// 플러그인
|
||||
import dbPlugin from './plugins/db.js';
|
||||
|
|
@ -50,6 +51,14 @@ export async function buildApp(opts = {}) {
|
|||
await fastify.register(xBotPlugin);
|
||||
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) 설정
|
||||
await fastify.register(fastifySwagger, {
|
||||
openapi: {
|
||||
|
|
@ -66,6 +75,9 @@ export async function buildApp(opts = {}) {
|
|||
{ name: 'members', description: '멤버 API' },
|
||||
{ name: 'albums', 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' },
|
||||
],
|
||||
components: {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,22 @@
|
|||
// 카테고리 ID 상수
|
||||
export const CATEGORY_IDS = {
|
||||
YOUTUBE: 2,
|
||||
X: 3,
|
||||
BIRTHDAY: 8,
|
||||
};
|
||||
|
||||
export default {
|
||||
server: {
|
||||
port: parseInt(process.env.PORT) || 80,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
image: {
|
||||
medium: { width: 800, quality: 85 },
|
||||
thumb: { width: 400, quality: 80 },
|
||||
},
|
||||
x: {
|
||||
defaultUsername: 'realfromis_9',
|
||||
},
|
||||
db: {
|
||||
host: process.env.DB_HOST || 'mariadb',
|
||||
port: parseInt(process.env.DB_PORT) || 3306,
|
||||
|
|
@ -33,5 +47,6 @@ export default {
|
|||
meilisearch: {
|
||||
host: process.env.MEILI_HOST || 'http://fromis9-meilisearch:7700',
|
||||
apiKey: process.env.MEILI_MASTER_KEY,
|
||||
minScore: 0.5,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -46,6 +46,27 @@ async function schedulerPlugin(fastify, opts) {
|
|||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 시작
|
||||
*/
|
||||
|
|
@ -71,19 +92,8 @@ async function schedulerPlugin(fastify, opts) {
|
|||
fastify.log.info(`[${botId}] 동기화 시작`);
|
||||
try {
|
||||
const result = await syncFn(bot);
|
||||
const status = await getStatus(botId);
|
||||
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}개 추가`);
|
||||
const addedCount = await handleSyncResult(botId, result, { setRunningStatus: true });
|
||||
fastify.log.info(`[${botId}] 동기화 완료: ${addedCount}개 추가`);
|
||||
} catch (err) {
|
||||
await updateStatus(botId, {
|
||||
status: 'error',
|
||||
|
|
@ -101,17 +111,8 @@ async function schedulerPlugin(fastify, opts) {
|
|||
// 즉시 1회 실행
|
||||
try {
|
||||
const result = await syncFn(bot);
|
||||
const status = await getStatus(botId);
|
||||
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}개 추가`);
|
||||
const addedCount = await handleSyncResult(botId, result);
|
||||
fastify.log.info(`[${botId}] 초기 동기화 완료: ${addedCount}개 추가`);
|
||||
} catch (err) {
|
||||
fastify.log.error(`[${botId}] 초기 동기화 오류: ${err.message}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,30 @@
|
|||
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'],
|
||||
};
|
||||
|
||||
/**
|
||||
* 봇 관리 라우트
|
||||
|
|
@ -16,7 +42,14 @@ export default async function botsRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['admin/bots'],
|
||||
summary: '봇 목록 조회',
|
||||
description: '등록된 모든 봇(YouTube, X)의 상태를 조회합니다.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
response: {
|
||||
200: {
|
||||
type: 'array',
|
||||
items: botResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
|
|
@ -57,7 +90,19 @@ export default async function botsRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['admin/bots'],
|
||||
summary: '봇 시작',
|
||||
description: '지정된 봇의 스케줄러를 시작합니다.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: botIdParam,
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
},
|
||||
400: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
|
|
@ -79,7 +124,19 @@ export default async function botsRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['admin/bots'],
|
||||
summary: '봇 정지',
|
||||
description: '지정된 봇의 스케줄러를 정지합니다.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: botIdParam,
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
},
|
||||
400: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
|
|
@ -101,7 +158,22 @@ export default async function botsRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['admin/bots'],
|
||||
summary: '봇 전체 동기화',
|
||||
description: '봇이 관리하는 모든 콘텐츠를 다시 동기화합니다.',
|
||||
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],
|
||||
}, async (request, reply) => {
|
||||
|
|
@ -151,7 +223,18 @@ export default async function botsRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['admin/bots'],
|
||||
summary: '할당량 경고 조회',
|
||||
description: 'YouTube API 할당량 경고 상태를 조회합니다.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
active: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
timestamp: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
|
|
@ -170,7 +253,16 @@ export default async function botsRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['admin/bots'],
|
||||
summary: '할당량 경고 해제',
|
||||
description: 'YouTube API 할당량 경고를 해제합니다.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import { fetchSingleTweet, extractTitle } from '../../services/x/scraper.js';
|
||||
import { addOrUpdateSchedule } from '../../services/meilisearch/index.js';
|
||||
import { formatDate, formatTime } from '../../utils/date.js';
|
||||
import config from '../../config/index.js';
|
||||
import config, { CATEGORY_IDS } from '../../config/index.js';
|
||||
import {
|
||||
errorResponse,
|
||||
xPostInfoQuery,
|
||||
xScheduleCreate,
|
||||
} from '../../schemas/index.js';
|
||||
|
||||
const X_CATEGORY_ID = 3;
|
||||
const X_CATEGORY_ID = CATEGORY_IDS.X;
|
||||
const NITTER_URL = config.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080';
|
||||
const DEFAULT_USERNAME = 'realfromis_9';
|
||||
const DEFAULT_USERNAME = config.x.defaultUsername;
|
||||
|
||||
/**
|
||||
* X(Twitter) 관련 관리자 라우트
|
||||
|
|
@ -21,14 +26,33 @@ export default async function xRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['admin/x'],
|
||||
summary: 'X 게시글 정보 조회',
|
||||
description: 'X(Twitter) 게시글 ID로 정보를 조회합니다.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
postId: { type: 'string', description: '게시글 ID' },
|
||||
username: { type: 'string', description: '사용자명 (기본: realfromis_9)' },
|
||||
querystring: xPostInfoQuery,
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['postId'],
|
||||
400: errorResponse,
|
||||
500: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
|
|
@ -68,18 +92,19 @@ export default async function xRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['admin/x'],
|
||||
summary: 'X 일정 저장',
|
||||
description: 'X(Twitter) 게시글을 일정으로 등록합니다.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
postId: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
content: { type: 'string' },
|
||||
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||
date: { type: 'string' },
|
||||
time: { type: 'string' },
|
||||
body: xScheduleCreate,
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
scheduleId: { type: 'integer' },
|
||||
},
|
||||
},
|
||||
required: ['postId', 'title', 'date'],
|
||||
409: errorResponse,
|
||||
500: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
import { fetchVideoInfo } from '../../services/youtube/api.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 = 2;
|
||||
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE;
|
||||
|
||||
/**
|
||||
* YouTube 관련 관리자 라우트
|
||||
|
|
@ -16,13 +24,27 @@ export default async function youtubeRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['admin/youtube'],
|
||||
summary: 'YouTube 영상 정보 조회',
|
||||
description: 'YouTube URL에서 영상 정보를 추출합니다.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'YouTube URL' },
|
||||
querystring: youtubeVideoInfo,
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
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' },
|
||||
},
|
||||
},
|
||||
required: ['url'],
|
||||
400: errorResponse,
|
||||
404: errorResponse,
|
||||
500: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
|
|
@ -66,19 +88,19 @@ export default async function youtubeRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['admin/youtube'],
|
||||
summary: 'YouTube 일정 저장',
|
||||
description: 'YouTube 영상을 일정으로 등록합니다.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
videoId: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
channelId: { type: 'string' },
|
||||
channelName: { type: 'string' },
|
||||
date: { type: 'string' },
|
||||
time: { type: 'string' },
|
||||
videoType: { type: 'string' },
|
||||
body: youtubeScheduleCreate,
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
scheduleId: { type: 'integer' },
|
||||
},
|
||||
},
|
||||
required: ['videoId', 'title', 'date'],
|
||||
409: errorResponse,
|
||||
500: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
|
|
@ -132,6 +154,109 @@ export default async function youtubeRoutes(fastify) {
|
|||
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,9 +1,13 @@
|
|||
import {
|
||||
uploadAlbumCover,
|
||||
deleteAlbumCover,
|
||||
} from '../../services/image.js';
|
||||
getAlbumDetails,
|
||||
getAlbumsWithTracks,
|
||||
createAlbum,
|
||||
updateAlbum,
|
||||
deleteAlbum,
|
||||
} from '../../services/album.js';
|
||||
import photosRoutes from './photos.js';
|
||||
import teasersRoutes from './teasers.js';
|
||||
import { errorResponse, successResponse, idParam } from '../../schemas/index.js';
|
||||
|
||||
/**
|
||||
* 앨범 라우트
|
||||
|
|
@ -16,60 +20,6 @@ export default async function albumsRoutes(fastify) {
|
|||
fastify.register(photosRoutes);
|
||||
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 (공개) ====================
|
||||
|
||||
/**
|
||||
|
|
@ -79,25 +29,13 @@ export default async function albumsRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['albums'],
|
||||
summary: '전체 앨범 목록 조회',
|
||||
description: '모든 앨범과 트랙 목록을 조회합니다.',
|
||||
response: {
|
||||
200: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
||||
},
|
||||
},
|
||||
}, async () => {
|
||||
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;
|
||||
return await getAlbumsWithTracks(db);
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -107,6 +45,18 @@ export default async function albumsRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['albums'],
|
||||
summary: '앨범명과 트랙명으로 트랙 조회',
|
||||
description: '앨범명(또는 폴더명)과 트랙명으로 트랙 상세 정보를 조회합니다.',
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
albumName: { type: 'string', description: '앨범명 또는 폴더명' },
|
||||
trackTitle: { type: 'string', description: '트랙 제목' },
|
||||
},
|
||||
required: ['albumName', 'trackTitle'],
|
||||
},
|
||||
response: {
|
||||
404: errorResponse,
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const albumName = decodeURIComponent(request.params.albumName);
|
||||
|
|
@ -161,6 +111,17 @@ export default async function albumsRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['albums'],
|
||||
summary: '앨범명으로 앨범 조회',
|
||||
description: '앨범명(또는 폴더명)으로 앨범 상세 정보를 조회합니다.',
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: '앨범명 또는 폴더명' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
response: {
|
||||
200: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const name = decodeURIComponent(request.params.name);
|
||||
|
|
@ -174,7 +135,7 @@ export default async function albumsRoutes(fastify) {
|
|||
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
return getAlbumDetails(albums[0]);
|
||||
return getAlbumDetails(db, albums[0]);
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -184,6 +145,11 @@ export default async function albumsRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['albums'],
|
||||
summary: 'ID로 앨범 조회',
|
||||
description: '앨범 ID로 상세 정보(트랙, 티저, 컨셉포토 포함)를 조회합니다.',
|
||||
params: idParam,
|
||||
response: {
|
||||
200: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const [albums] = await db.query('SELECT * FROM albums WHERE id = ?', [
|
||||
|
|
@ -194,7 +160,7 @@ export default async function albumsRoutes(fastify) {
|
|||
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
return getAlbumDetails(albums[0]);
|
||||
return getAlbumDetails(db, albums[0]);
|
||||
});
|
||||
|
||||
// ==================== POST/PUT/DELETE (인증 필요) ====================
|
||||
|
|
@ -206,7 +172,19 @@ export default async function albumsRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['albums'],
|
||||
summary: '앨범 생성',
|
||||
description: 'multipart/form-data로 앨범을 생성합니다. data 필드에 JSON, cover 필드에 이미지 파일.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
consumes: ['multipart/form-data'],
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string' },
|
||||
albumId: { type: 'integer' },
|
||||
},
|
||||
},
|
||||
400: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
|
|
@ -226,59 +204,13 @@ export default async function albumsRoutes(fastify) {
|
|||
return reply.code(400).send({ error: '데이터가 필요합니다.' });
|
||||
}
|
||||
|
||||
const { title, album_type, album_type_short, release_date, folder_name, description, tracks } = data;
|
||||
const { title, album_type, release_date, folder_name } = data;
|
||||
|
||||
if (!title || !album_type || !release_date || !folder_name) {
|
||||
return reply.code(400).send({ error: '필수 필드를 모두 입력해주세요.' });
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
return await createAlbum(db, data, coverBuffer);
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -288,7 +220,15 @@ export default async function albumsRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['albums'],
|
||||
summary: '앨범 수정',
|
||||
description: 'multipart/form-data로 앨범을 수정합니다.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
consumes: ['multipart/form-data'],
|
||||
params: idParam,
|
||||
response: {
|
||||
200: successResponse,
|
||||
400: errorResponse,
|
||||
404: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
|
|
@ -309,62 +249,11 @@ export default async function albumsRoutes(fastify) {
|
|||
return reply.code(400).send({ error: '데이터가 필요합니다.' });
|
||||
}
|
||||
|
||||
const { title, album_type, album_type_short, release_date, folder_name, description, tracks } = data;
|
||||
|
||||
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();
|
||||
const result = await updateAlbum(db, id, data, coverBuffer);
|
||||
if (!result) {
|
||||
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -374,37 +263,21 @@ export default async function albumsRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['albums'],
|
||||
summary: '앨범 삭제',
|
||||
description: '앨범과 관련 데이터(트랙, 커버 이미지)를 삭제합니다.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: idParam,
|
||||
response: {
|
||||
200: successResponse,
|
||||
404: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
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 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();
|
||||
const result = await deleteAlbum(db, id);
|
||||
if (!result) {
|
||||
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
deleteAlbumPhoto,
|
||||
uploadAlbumVideo,
|
||||
} from '../../services/image.js';
|
||||
import { withTransaction } from '../../utils/transaction.js';
|
||||
|
||||
/**
|
||||
* 앨범 사진 라우트
|
||||
|
|
@ -166,12 +167,11 @@ export default async function photosRoutes(fastify) {
|
|||
photoId = result.insertId;
|
||||
|
||||
if (meta.members && meta.members.length > 0) {
|
||||
for (const memberId of meta.members) {
|
||||
await connection.query(
|
||||
'INSERT INTO album_photo_members (photo_id, member_id) VALUES (?, ?)',
|
||||
[photoId, memberId]
|
||||
);
|
||||
}
|
||||
const values = meta.members.map(memberId => [photoId, memberId]);
|
||||
await connection.query(
|
||||
'INSERT INTO album_photo_members (photo_id, member_id) VALUES ?',
|
||||
[values]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,7 +196,7 @@ export default async function photosRoutes(fastify) {
|
|||
reply.raw.end();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
console.error('사진 업로드 오류:', error);
|
||||
fastify.log.error(`사진 업로드 오류: ${error.message}`);
|
||||
reply.raw.write(`data: ${JSON.stringify({ error: '사진 업로드 중 오류가 발생했습니다.' })}\n\n`);
|
||||
reply.raw.end();
|
||||
} finally {
|
||||
|
|
@ -216,37 +216,29 @@ export default async function photosRoutes(fastify) {
|
|||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { albumId, photoId } = request.params;
|
||||
const connection = await db.getConnection();
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
// 사진 존재 여부 먼저 확인
|
||||
const [photos] = await db.query(
|
||||
`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 [photos] = await connection.query(
|
||||
`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) {
|
||||
return reply.code(404).send({ error: '사진을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
if (photos.length === 0) {
|
||||
return reply.code(404).send({ error: '사진을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const photo = photos[0];
|
||||
const filename = photo.original_url.split('/').pop();
|
||||
const photo = photos[0];
|
||||
const filename = photo.original_url.split('/').pop();
|
||||
|
||||
return withTransaction(db, async (connection) => {
|
||||
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_photos WHERE id = ?', [photoId]);
|
||||
|
||||
await connection.commit();
|
||||
return { message: '사진이 삭제되었습니다.' };
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
deleteAlbumPhoto,
|
||||
deleteAlbumVideo,
|
||||
} from '../../services/image.js';
|
||||
import { withTransaction } from '../../utils/transaction.js';
|
||||
|
||||
/**
|
||||
* 앨범 티저 라우트
|
||||
|
|
@ -49,26 +50,24 @@ export default async function teasersRoutes(fastify) {
|
|||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { albumId, teaserId } = request.params;
|
||||
const connection = await db.getConnection();
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
// 티저 존재 여부 먼저 확인
|
||||
const [teasers] = await db.query(
|
||||
`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 [teasers] = await connection.query(
|
||||
`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) {
|
||||
return reply.code(404).send({ error: '티저를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
if (teasers.length === 0) {
|
||||
return reply.code(404).send({ error: '티저를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const teaser = teasers[0];
|
||||
const filename = teaser.original_url.split('/').pop();
|
||||
const teaser = teasers[0];
|
||||
const filename = teaser.original_url.split('/').pop();
|
||||
|
||||
return withTransaction(db, async (connection) => {
|
||||
await deleteAlbumPhoto(teaser.folder_name, 'teaser', filename);
|
||||
|
||||
if (teaser.video_url) {
|
||||
|
|
@ -78,13 +77,7 @@ export default async function teasersRoutes(fastify) {
|
|||
|
||||
await connection.query('DELETE FROM album_teasers WHERE id = ?', [teaserId]);
|
||||
|
||||
await connection.commit();
|
||||
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 || {};
|
||||
|
||||
if (!username || !password) {
|
||||
return reply.status(400).send({ error: '아이디와 비밀번호를 입력해주세요.' });
|
||||
return reply.code(400).send({ error: '아이디와 비밀번호를 입력해주세요.' });
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -52,14 +52,14 @@ export default async function authRoutes(fastify, opts) {
|
|||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
return reply.status(401).send({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' });
|
||||
return reply.code(401).send({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' });
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
return reply.status(401).send({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' });
|
||||
return reply.code(401).send({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' });
|
||||
}
|
||||
|
||||
// JWT 토큰 생성
|
||||
|
|
@ -75,7 +75,7 @@ export default async function authRoutes(fastify, opts) {
|
|||
};
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.status(500).send({ error: '로그인 처리 중 오류가 발생했습니다.' });
|
||||
return reply.code(500).send({ error: '로그인 처리 중 오류가 발생했습니다.' });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export default async function membersRoutes(fastify, opts) {
|
|||
return result;
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.status(500).send({ error: '멤버 목록 조회 실패' });
|
||||
return reply.code(500).send({ error: '멤버 목록 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ export default async function membersRoutes(fastify, opts) {
|
|||
`, [decodeURIComponent(name)]);
|
||||
|
||||
if (members.length === 0) {
|
||||
return reply.status(404).send({ error: '멤버를 찾을 수 없습니다' });
|
||||
return reply.code(404).send({ error: '멤버를 찾을 수 없습니다' });
|
||||
}
|
||||
|
||||
const member = members[0];
|
||||
|
|
@ -106,7 +106,7 @@ export default async function membersRoutes(fastify, opts) {
|
|||
};
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.status(500).send({ error: '멤버 조회 실패' });
|
||||
return reply.code(500).send({ error: '멤버 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ export default async function membersRoutes(fastify, opts) {
|
|||
);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return reply.status(404).send({ error: '멤버를 찾을 수 없습니다' });
|
||||
return reply.code(404).send({ error: '멤버를 찾을 수 없습니다' });
|
||||
}
|
||||
|
||||
const memberId = existing[0].id;
|
||||
|
|
@ -218,7 +218,7 @@ export default async function membersRoutes(fastify, opts) {
|
|||
return { message: '멤버 정보가 수정되었습니다', id: memberId };
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.status(500).send({ error: '멤버 수정 실패: ' + err.message });
|
||||
return reply.code(500).send({ error: '멤버 수정 실패: ' + err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@
|
|||
*/
|
||||
import suggestionsRoutes from './suggestions.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) {
|
||||
const { db, meilisearch, redis } = fastify;
|
||||
|
|
@ -19,6 +27,10 @@ export default async function schedulesRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['schedules'],
|
||||
summary: '카테고리 목록 조회',
|
||||
description: '일정 카테고리 목록을 조회합니다.',
|
||||
response: {
|
||||
200: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const [categories] = await db.query(
|
||||
|
|
@ -36,31 +48,31 @@ export default async function schedulesRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['schedules'],
|
||||
summary: '일정 조회 (검색 또는 월별)',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
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: '결과 개수' },
|
||||
},
|
||||
description: 'search 파라미터로 검색, year/month로 월별 조회, startDate로 다가오는 일정 조회',
|
||||
querystring: scheduleSearchQuery,
|
||||
response: {
|
||||
200: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { search, year, month, offset = 0, limit = 100 } = request.query;
|
||||
const { search, year, month, startDate, offset = 0, limit = 100 } = request.query;
|
||||
|
||||
// 검색 모드
|
||||
if (search && search.trim()) {
|
||||
return await handleSearch(fastify, search.trim(), parseInt(offset), parseInt(limit));
|
||||
}
|
||||
|
||||
// 월별 조회 모드
|
||||
if (!year || !month) {
|
||||
return reply.code(400).send({ error: 'search 또는 year/month는 필수입니다.' });
|
||||
// 다가오는 일정 조회 (startDate부터)
|
||||
if (startDate) {
|
||||
return await getUpcomingSchedules(db, startDate, parseInt(limit));
|
||||
}
|
||||
|
||||
return await handleMonthlySchedules(db, parseInt(year), parseInt(month));
|
||||
// 월별 조회 모드
|
||||
if (!year || !month) {
|
||||
return reply.code(400).send({ error: 'search, startDate, 또는 year/month는 필수입니다.' });
|
||||
}
|
||||
|
||||
return await getMonthlySchedules(db, parseInt(year), parseInt(month));
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -71,7 +83,17 @@ export default async function schedulesRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['schedules'],
|
||||
summary: 'Meilisearch 전체 동기화',
|
||||
description: 'DB의 모든 일정을 Meilisearch에 동기화합니다.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
synced: { type: 'integer', description: '동기화된 일정 수' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
|
|
@ -81,12 +103,17 @@ export default async function schedulesRoutes(fastify) {
|
|||
|
||||
/**
|
||||
* GET /api/schedules/:id
|
||||
* 일정 상세 조회
|
||||
* 일정 상세 조회 (카테고리별 다른 형식 반환)
|
||||
*/
|
||||
fastify.get('/:id', {
|
||||
schema: {
|
||||
tags: ['schedules'],
|
||||
summary: '일정 상세 조회',
|
||||
description: '일정 ID로 상세 정보를 조회합니다. 카테고리에 따라 추가 정보(YouTube/X)가 포함됩니다.',
|
||||
params: idParam,
|
||||
response: {
|
||||
200: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
|
|
@ -99,7 +126,9 @@ export default async function schedulesRoutes(fastify) {
|
|||
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
|
||||
sx.post_id as x_post_id,
|
||||
sx.content as x_content,
|
||||
sx.image_urls as x_image_urls
|
||||
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
|
||||
|
|
@ -112,34 +141,62 @@ export default async function schedulesRoutes(fastify) {
|
|||
}
|
||||
|
||||
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 = {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
date: s.date,
|
||||
time: s.time,
|
||||
datetime,
|
||||
category: {
|
||||
id: s.category_id,
|
||||
name: s.category_name,
|
||||
color: s.category_color,
|
||||
},
|
||||
created_at: s.created_at,
|
||||
updated_at: s.updated_at,
|
||||
members,
|
||||
createdAt: s.created_at,
|
||||
updatedAt: s.updated_at,
|
||||
};
|
||||
|
||||
// source 정보 추가 (YouTube: 2, X: 3)
|
||||
if (s.category_id === 2 && s.youtube_video_id) {
|
||||
const videoUrl = s.youtube_video_type === 'shorts'
|
||||
// 카테고리별 추가 필드
|
||||
if (s.category_id === CATEGORY_IDS.YOUTUBE && s.youtube_video_id) {
|
||||
// YouTube
|
||||
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/watch?v=${s.youtube_video_id}`;
|
||||
result.source = {
|
||||
name: s.youtube_channel || 'YouTube',
|
||||
url: videoUrl,
|
||||
};
|
||||
} else if (s.category_id === 3 && s.x_post_id) {
|
||||
result.source = {
|
||||
name: '',
|
||||
url: `https://x.com/realfromis_9/status/${s.x_post_id}`,
|
||||
};
|
||||
} else if (s.category_id === CATEGORY_IDS.X && s.x_post_id) {
|
||||
// X (Twitter)
|
||||
const username = config.x.defaultUsername;
|
||||
result.postId = s.x_post_id;
|
||||
result.content = s.x_content || null;
|
||||
result.imageUrls = s.x_image_urls ? JSON.parse(s.x_image_urls) : [];
|
||||
result.postUrl = `https://x.com/${username}/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;
|
||||
|
|
@ -153,7 +210,18 @@ export default async function schedulesRoutes(fastify) {
|
|||
schema: {
|
||||
tags: ['schedules'],
|
||||
summary: '일정 삭제',
|
||||
description: '일정과 관련 데이터(YouTube/X 정보, 멤버, 이미지)를 삭제합니다.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: idParam,
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
404: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
|
|
@ -223,155 +291,6 @@ async function saveSearchQueryAsync(fastify, query) {
|
|||
const service = new SuggestionService(fastify.db, fastify.redis);
|
||||
await service.saveSearchQuery(query);
|
||||
} catch (err) {
|
||||
console.error('[Search] 검색어 저장 실패:', err.message);
|
||||
fastify.log.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.initialize().catch(err => {
|
||||
console.error('[Suggestions] 서비스 초기화 실패:', err.message);
|
||||
fastify.log.error(`[Suggestions] 서비스 초기화 실패: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -109,11 +109,11 @@ export default async function suggestionsRoutes(fastify) {
|
|||
const { query } = request.body;
|
||||
|
||||
if (!query || query.trim().length === 0) {
|
||||
return { success: false };
|
||||
return reply.code(400).send({ error: '검색어가 필요합니다.' });
|
||||
}
|
||||
|
||||
await suggestionService.saveSearchQuery(query);
|
||||
return { success: true };
|
||||
return { message: '검색어가 저장되었습니다.' };
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -168,7 +168,6 @@ export default async function suggestionsRoutes(fastify) {
|
|||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
},
|
||||
|
|
@ -185,13 +184,10 @@ export default async function suggestionsRoutes(fastify) {
|
|||
// 형태소 분석기 리로드
|
||||
await reloadMorpheme();
|
||||
|
||||
return { success: true, message: '사전이 저장되었습니다.' };
|
||||
return { message: '사전이 저장되었습니다.' };
|
||||
} catch (error) {
|
||||
console.error('[Suggestions] 사전 저장 오류:', error.message);
|
||||
return reply.code(500).send({
|
||||
success: false,
|
||||
message: '사전 저장 중 오류가 발생했습니다.',
|
||||
});
|
||||
fastify.log.error(`[Suggestions] 사전 저장 오류: ${error.message}`);
|
||||
return reply.code(500).send({ error: '사전 저장 중 오류가 발생했습니다.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export default async function statsRoutes(fastify, opts) {
|
|||
};
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.status(500).send({ error: '통계 조회 실패' });
|
||||
return reply.code(500).send({ error: '통계 조회 실패' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
59
backend/src/schemas/admin.js
Normal file
59
backend/src/schemas/admin.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* 관리자 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'],
|
||||
};
|
||||
75
backend/src/schemas/album.js
Normal file
75
backend/src/schemas/album.js
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* 앨범 스키마
|
||||
*/
|
||||
|
||||
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' } },
|
||||
},
|
||||
};
|
||||
20
backend/src/schemas/auth.js
Normal file
20
backend/src/schemas/auth.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* 인증 스키마
|
||||
*/
|
||||
|
||||
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: '만료 시간' },
|
||||
},
|
||||
};
|
||||
34
backend/src/schemas/common.js
Normal file
34
backend/src/schemas/common.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* 공통 스키마
|
||||
*/
|
||||
|
||||
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'],
|
||||
};
|
||||
11
backend/src/schemas/index.js
Normal file
11
backend/src/schemas/index.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* 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';
|
||||
15
backend/src/schemas/member.js
Normal file
15
backend/src/schemas/member.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* 멤버 스키마
|
||||
*/
|
||||
|
||||
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' },
|
||||
},
|
||||
};
|
||||
57
backend/src/schemas/schedule.js
Normal file
57
backend/src/schemas/schedule.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* 일정 스키마
|
||||
*/
|
||||
|
||||
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' },
|
||||
},
|
||||
};
|
||||
255
backend/src/services/album.js
Normal file
255
backend/src/services/album.js
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
/**
|
||||
* 앨범 서비스
|
||||
* 앨범 관련 비즈니스 로직
|
||||
*/
|
||||
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,6 +1,9 @@
|
|||
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import sharp from 'sharp';
|
||||
import config from '../config/index.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
|
||||
const logger = createLogger('S3');
|
||||
|
||||
// S3 클라이언트 생성
|
||||
const s3Client = new S3Client({
|
||||
|
|
@ -16,6 +19,9 @@ const s3Client = new S3Client({
|
|||
const BUCKET = config.s3.bucket;
|
||||
const PUBLIC_URL = config.s3.publicUrl;
|
||||
|
||||
// 이미지 처리 설정
|
||||
const { medium, thumb } = config.image;
|
||||
|
||||
/**
|
||||
* 이미지를 3가지 해상도로 변환
|
||||
*/
|
||||
|
|
@ -23,12 +29,12 @@ async function processImage(buffer) {
|
|||
const [originalBuffer, mediumBuffer, thumbBuffer] = await Promise.all([
|
||||
sharp(buffer).webp({ lossless: true }).toBuffer(),
|
||||
sharp(buffer)
|
||||
.resize(800, null, { withoutEnlargement: true })
|
||||
.webp({ quality: 85 })
|
||||
.resize(medium.width, null, { withoutEnlargement: true })
|
||||
.webp({ quality: medium.quality })
|
||||
.toBuffer(),
|
||||
sharp(buffer)
|
||||
.resize(400, null, { withoutEnlargement: true })
|
||||
.webp({ quality: 80 })
|
||||
.resize(thumb.width, null, { withoutEnlargement: true })
|
||||
.webp({ quality: thumb.quality })
|
||||
.toBuffer(),
|
||||
]);
|
||||
|
||||
|
|
@ -58,7 +64,7 @@ async function deleteFromS3(key) {
|
|||
Key: key,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(`S3 삭제 오류 (${key}):`, err.message);
|
||||
logger.error(`삭제 오류 (${key}): ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,13 @@
|
|||
* - 일정 동기화
|
||||
*/
|
||||
import Inko from 'inko';
|
||||
import config, { CATEGORY_IDS } from '../../config/index.js';
|
||||
import { createLogger } from '../../utils/logger.js';
|
||||
|
||||
const inko = new Inko();
|
||||
const logger = createLogger('Meilisearch');
|
||||
const INDEX_NAME = 'schedules';
|
||||
const MIN_SCORE = config.meilisearch.minScore;
|
||||
|
||||
/**
|
||||
* 영문 자판으로 입력된 검색어인지 확인
|
||||
|
|
@ -86,9 +90,9 @@ export async function searchSchedules(meilisearch, db, query, options = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
// 유사도 0.5 미만 필터링
|
||||
// 유사도 필터링
|
||||
let filteredHits = Array.from(allHits.values())
|
||||
.filter(hit => hit._rankingScore >= 0.5);
|
||||
.filter(hit => hit._rankingScore >= MIN_SCORE);
|
||||
|
||||
// 유사도 순 정렬
|
||||
filteredHits.sort((a, b) => (b._rankingScore || 0) - (a._rankingScore || 0));
|
||||
|
|
@ -109,7 +113,7 @@ export async function searchSchedules(meilisearch, db, query, options = {}) {
|
|||
hasMore: offset + paginatedHits.length < total,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[Meilisearch] 검색 오류:', err.message);
|
||||
logger.error(`검색 오류: ${err.message}`);
|
||||
return { hits: [], total: 0, offset: 0, limit, hasMore: false };
|
||||
}
|
||||
}
|
||||
|
|
@ -139,11 +143,9 @@ function formatScheduleResponse(hit) {
|
|||
|
||||
// source 객체 구성 (X는 name 비움)
|
||||
let source = null;
|
||||
if (hit.category_id === 2 && hit.source_name) {
|
||||
// YouTube
|
||||
if (hit.category_id === CATEGORY_IDS.YOUTUBE && hit.source_name) {
|
||||
source = { name: hit.source_name, url: null };
|
||||
} else if (hit.category_id === 3) {
|
||||
// X (name 비움)
|
||||
} else if (hit.category_id === CATEGORY_IDS.X) {
|
||||
source = { name: '', url: null };
|
||||
}
|
||||
|
||||
|
|
@ -183,9 +185,9 @@ export async function addOrUpdateSchedule(meilisearch, schedule) {
|
|||
};
|
||||
|
||||
await index.addDocuments([document]);
|
||||
console.log(`[Meilisearch] 일정 추가/업데이트: ${schedule.id}`);
|
||||
logger.info(`일정 추가/업데이트: ${schedule.id}`);
|
||||
} catch (err) {
|
||||
console.error('[Meilisearch] 문서 추가 오류:', err.message);
|
||||
logger.error(`문서 추가 오류: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,9 +198,9 @@ export async function deleteSchedule(meilisearch, scheduleId) {
|
|||
try {
|
||||
const index = meilisearch.index(INDEX_NAME);
|
||||
await index.deleteDocument(scheduleId);
|
||||
console.log(`[Meilisearch] 일정 삭제: ${scheduleId}`);
|
||||
logger.info(`일정 삭제: ${scheduleId}`);
|
||||
} catch (err) {
|
||||
console.error('[Meilisearch] 문서 삭제 오류:', err.message);
|
||||
logger.error(`문서 삭제 오류: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -249,11 +251,11 @@ export async function syncAllSchedules(meilisearch, db) {
|
|||
|
||||
// 일괄 추가
|
||||
await index.addDocuments(documents);
|
||||
console.log(`[Meilisearch] ${documents.length}개 일정 동기화 완료`);
|
||||
logger.info(`${documents.length}개 일정 동기화 완료`);
|
||||
|
||||
return documents.length;
|
||||
} catch (err) {
|
||||
console.error('[Meilisearch] 동기화 오류:', err.message);
|
||||
logger.error(`동기화 오류: ${err.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
251
backend/src/services/schedule.js
Normal file
251
backend/src/services/schedule.js
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
/**
|
||||
* 스케줄 서비스
|
||||
* 스케줄 관련 비즈니스 로직
|
||||
*/
|
||||
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,8 +8,10 @@
|
|||
import Inko from 'inko';
|
||||
import { extractNouns, initMorpheme, isReady } from './morpheme.js';
|
||||
import { getChosung, isChosungOnly, isChosungMatch } from './chosung.js';
|
||||
import { createLogger } from '../../utils/logger.js';
|
||||
|
||||
const inko = new Inko();
|
||||
const logger = createLogger('Suggestion');
|
||||
|
||||
// 설정
|
||||
const CONFIG = {
|
||||
|
|
@ -42,9 +44,9 @@ export class SuggestionService {
|
|||
async initialize() {
|
||||
try {
|
||||
await initMorpheme();
|
||||
console.log('[Suggestion] 서비스 초기화 완료');
|
||||
logger.info('서비스 초기화 완료');
|
||||
} catch (error) {
|
||||
console.error('[Suggestion] 서비스 초기화 실패:', error.message);
|
||||
logger.error(`서비스 초기화 실패: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +81,7 @@ export class SuggestionService {
|
|||
if (this.isEnglishOnly(normalizedQuery)) {
|
||||
const korean = this.convertEnglishToKorean(normalizedQuery);
|
||||
if (korean) {
|
||||
console.log(`[Suggestion] 한글 변환: "${normalizedQuery}" → "${korean}"`);
|
||||
logger.debug(`한글 변환: "${normalizedQuery}" → "${korean}"`);
|
||||
normalizedQuery = korean;
|
||||
}
|
||||
}
|
||||
|
|
@ -131,9 +133,9 @@ export class SuggestionService {
|
|||
}
|
||||
}
|
||||
|
||||
console.log(`[Suggestion] 저장: "${normalizedQuery}" → 명사: [${nouns.join(', ')}]`);
|
||||
logger.debug(`저장: "${normalizedQuery}" → 명사: [${nouns.join(', ')}]`);
|
||||
} catch (error) {
|
||||
console.error('[Suggestion] 저장 오류:', error.message);
|
||||
logger.error(`저장 오류: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -171,7 +173,7 @@ export class SuggestionService {
|
|||
return await this.getPrefixSuggestions(searchQuery.trim(), koreanQuery?.trim(), limit);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Suggestion] 조회 오류:', error.message);
|
||||
logger.error(`조회 오류: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -200,7 +202,7 @@ export class SuggestionService {
|
|||
|
||||
return rows.map(r => `${prefix} ${r.word2}`);
|
||||
} catch (error) {
|
||||
console.error('[Suggestion] Bi-gram 조회 오류:', error.message);
|
||||
logger.error(`Bi-gram 조회 오류: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -236,7 +238,7 @@ export class SuggestionService {
|
|||
|
||||
return rows.map(r => r.query);
|
||||
} catch (error) {
|
||||
console.error('[Suggestion] Prefix 조회 오류:', error.message);
|
||||
logger.error(`Prefix 조회 오류: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -258,7 +260,7 @@ export class SuggestionService {
|
|||
|
||||
return rows.map(r => r.word);
|
||||
} catch (error) {
|
||||
console.error('[Suggestion] 초성 검색 오류:', error.message);
|
||||
logger.error(`초성 검색 오류: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -293,7 +295,7 @@ export class SuggestionService {
|
|||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[Suggestion] 인기 검색어 조회 오류:', error.message);
|
||||
logger.error(`인기 검색어 조회 오류: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { createLogger } from '../../utils/logger.js';
|
||||
|
||||
const logger = createLogger('Morpheme');
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
|
@ -47,7 +50,7 @@ export async function initMorpheme() {
|
|||
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
console.log('[Morpheme] kiwi-nlp 초기화 시작...');
|
||||
logger.info('kiwi-nlp 초기화 시작...');
|
||||
|
||||
// kiwi-nlp 동적 import (ESM)
|
||||
const { KiwiBuilder } = await import('kiwi-nlp');
|
||||
|
|
@ -69,7 +72,7 @@ export async function initMorpheme() {
|
|||
try {
|
||||
modelFiles[filename] = new Uint8Array(readFileSync(filepath));
|
||||
} catch (err) {
|
||||
console.warn(`[Morpheme] 모델 파일 로드 실패: ${filename}`);
|
||||
logger.warn(`모델 파일 로드 실패: ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -78,18 +81,18 @@ export async function initMorpheme() {
|
|||
try {
|
||||
modelFiles[USER_DICT] = new Uint8Array(readFileSync(userDictPath));
|
||||
userDicts = [USER_DICT];
|
||||
console.log('[Morpheme] 사용자 사전 로드 완료');
|
||||
logger.info('사용자 사전 로드 완료');
|
||||
} catch (err) {
|
||||
console.warn('[Morpheme] 사용자 사전 없음, 기본 사전만 사용');
|
||||
logger.warn('사용자 사전 없음, 기본 사전만 사용');
|
||||
}
|
||||
|
||||
// Kiwi 인스턴스 생성
|
||||
kiwi = await builder.build({ modelFiles, userDicts });
|
||||
|
||||
isInitialized = true;
|
||||
console.log('[Morpheme] kiwi-nlp 초기화 완료');
|
||||
logger.info('kiwi-nlp 초기화 완료');
|
||||
} catch (error) {
|
||||
console.error('[Morpheme] 초기화 실패:', error.message);
|
||||
logger.error(`초기화 실패: ${error.message}`);
|
||||
// 초기화 실패해도 서비스는 계속 동작 (fallback 사용)
|
||||
}
|
||||
})();
|
||||
|
|
@ -114,7 +117,7 @@ export async function extractNouns(text) {
|
|||
|
||||
// kiwi가 초기화되지 않았으면 fallback
|
||||
if (!kiwi) {
|
||||
console.warn('[Morpheme] kiwi 미초기화, fallback 사용');
|
||||
logger.warn('kiwi 미초기화, fallback 사용');
|
||||
return fallbackExtract(text);
|
||||
}
|
||||
|
||||
|
|
@ -141,7 +144,7 @@ export async function extractNouns(text) {
|
|||
|
||||
return nouns.length > 0 ? nouns : fallbackExtract(text);
|
||||
} catch (error) {
|
||||
console.error('[Morpheme] 형태소 분석 오류:', error.message);
|
||||
logger.error(`형태소 분석 오류: ${error.message}`);
|
||||
return fallbackExtract(text);
|
||||
}
|
||||
}
|
||||
|
|
@ -167,12 +170,12 @@ export function isReady() {
|
|||
* 형태소 분석기 리로드 (사전 변경 시 호출)
|
||||
*/
|
||||
export async function reloadMorpheme() {
|
||||
console.log('[Morpheme] 리로드 시작...');
|
||||
logger.info('리로드 시작...');
|
||||
isInitialized = false;
|
||||
kiwi = null;
|
||||
initPromise = null;
|
||||
await initMorpheme();
|
||||
console.log('[Morpheme] 리로드 완료');
|
||||
logger.info('리로드 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -77,6 +77,25 @@ export function extractProfile(html) {
|
|||
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에서 트윗 목록 파싱
|
||||
*/
|
||||
|
|
@ -106,11 +125,7 @@ export function parseTweets(html, username) {
|
|||
const contentMatch = container.match(/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/);
|
||||
let text = '';
|
||||
if (contentMatch) {
|
||||
text = contentMatch[1]
|
||||
.replace(/<br\s*\/?>/g, '\n')
|
||||
.replace(/<a[^>]*>([^<]*)<\/a>/g, '$1')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.trim();
|
||||
text = extractTextFromHtml(contentMatch[1]);
|
||||
}
|
||||
|
||||
// 이미지
|
||||
|
|
@ -157,11 +172,7 @@ export async function fetchSingleTweet(nitterUrl, username, postId) {
|
|||
const contentMatch = container.match(/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/);
|
||||
let text = '';
|
||||
if (contentMatch) {
|
||||
text = contentMatch[1]
|
||||
.replace(/<br\s*\/?>/g, '\n')
|
||||
.replace(/<a[^>]*>([^<]*)<\/a>/g, '$1')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.trim();
|
||||
text = extractTextFromHtml(contentMatch[1]);
|
||||
}
|
||||
|
||||
// 이미지
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.js';
|
||||
import bots from '../../config/bots.js';
|
||||
import { CATEGORY_IDS } from '../../config/index.js';
|
||||
|
||||
const YOUTUBE_CATEGORY_ID = 2;
|
||||
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE;
|
||||
const PLAYLIST_CACHE_PREFIX = 'yt_uploads:';
|
||||
|
||||
async function youtubeBotPlugin(fastify, opts) {
|
||||
|
|
|
|||
50
backend/src/utils/error.js
Normal file
50
backend/src/utils/error.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* 에러 응답 유틸리티
|
||||
* 일관된 에러 응답 형식 제공
|
||||
*/
|
||||
|
||||
/**
|
||||
* 에러 응답 전송
|
||||
* @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);
|
||||
}
|
||||
43
backend/src/utils/logger.js
Normal file
43
backend/src/utils/logger.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* 로거 유틸리티
|
||||
* 서비스 레이어에서 사용할 수 있는 간단한 로깅 유틸리티
|
||||
*/
|
||||
|
||||
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');
|
||||
34
backend/src/utils/transaction.js
Normal file
34
backend/src/utils/transaction.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* 트랜잭션 헬퍼 유틸리티
|
||||
* 반복되는 트랜잭션 패턴 추상화
|
||||
*/
|
||||
|
||||
/**
|
||||
* 트랜잭션 래퍼 함수
|
||||
* @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
|
||||
로그인 (JWT 토큰 발급)
|
||||
|
||||
### GET /auth/me
|
||||
현재 사용자 정보 (인증 필요)
|
||||
### GET /auth/verify
|
||||
토큰 검증 및 사용자 정보 (인증 필요)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -38,10 +38,13 @@ Base URL: `/api`
|
|||
일정 조회
|
||||
|
||||
**Query Parameters:**
|
||||
- `year`, `month` - 월별 조회 (필수, search 없을 때)
|
||||
- `year`, `month` - 월별 조회
|
||||
- `startDate` - 시작 날짜 (YYYY-MM-DD), 다가오는 일정 조회
|
||||
- `search` - 검색어 (Meilisearch 사용)
|
||||
- `offset`, `limit` - 페이징
|
||||
|
||||
※ `search`, `startDate`, `year/month` 중 하나는 필수
|
||||
|
||||
**월별 조회 응답:**
|
||||
```json
|
||||
{
|
||||
|
|
@ -70,6 +73,23 @@ Base URL: `/api`
|
|||
- X (category_id=3): `{ name: "", url: "https://x.com/realfromis_9/status/..." }` (name 빈 문자열)
|
||||
- 기타 카테고리: 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
|
||||
{
|
||||
|
|
@ -129,6 +149,49 @@ 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..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관리자 - 봇 관리 (인증 필요)
|
||||
|
|
@ -227,6 +290,18 @@ YouTube 일정 저장
|
|||
}
|
||||
```
|
||||
|
||||
### PUT /admin/youtube/schedule/:id
|
||||
YouTube 일정 수정 (멤버, 영상 유형)
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"memberIds": [1, 2, 3],
|
||||
"videoType": "video"
|
||||
}
|
||||
```
|
||||
※ `videoType`: "video" 또는 "shorts"
|
||||
|
||||
---
|
||||
|
||||
## 관리자 - X (인증 필요)
|
||||
|
|
|
|||
|
|
@ -16,12 +16,22 @@ fromis_9/
|
|||
│ │ │ ├── meilisearch.js # 검색 엔진
|
||||
│ │ │ └── scheduler.js # 봇 스케줄러
|
||||
│ │ ├── routes/ # API 라우트
|
||||
│ │ │ ├── auth/
|
||||
│ │ │ ├── members/
|
||||
│ │ │ ├── admin/ # 관리자 API
|
||||
│ │ │ │ ├── bots.js # 봇 관리
|
||||
│ │ │ │ ├── youtube.js # YouTube 일정 관리
|
||||
│ │ │ │ └── x.js # X 일정 관리
|
||||
│ │ │ ├── albums/
|
||||
│ │ │ │ ├── index.js # 앨범 CRUD
|
||||
│ │ │ │ ├── photos.js # 앨범 사진 관리
|
||||
│ │ │ │ └── teasers.js # 앨범 티저 관리
|
||||
│ │ │ ├── auth.js # 인증 (로그인, 토큰 검증)
|
||||
│ │ │ ├── members/
|
||||
│ │ │ │ └── index.js # 멤버 조회/수정
|
||||
│ │ │ ├── schedules/
|
||||
│ │ │ │ ├── index.js # 일정 조회/검색
|
||||
│ │ │ │ └── suggestions.js
|
||||
│ │ │ │ ├── index.js # 일정 조회/검색/삭제
|
||||
│ │ │ │ └── suggestions.js # 추천 검색어
|
||||
│ │ │ ├── stats/
|
||||
│ │ │ │ └── index.js # 통계 조회
|
||||
│ │ │ └── index.js # 라우트 등록
|
||||
│ │ ├── services/ # 비즈니스 로직
|
||||
│ │ │ ├── youtube/ # YouTube 봇
|
||||
|
|
@ -40,12 +50,28 @@ fromis_9/
|
|||
│ │ ├── api/ # API 클라이언트
|
||||
│ │ │ ├── index.js # fetchApi 유틸
|
||||
│ │ │ ├── public/ # 공개 API
|
||||
│ │ │ │ ├── albums.js
|
||||
│ │ │ │ ├── members.js
|
||||
│ │ │ │ └── schedules.js
|
||||
│ │ │ └── admin/ # 어드민 API
|
||||
│ │ │ ├── albums.js
|
||||
│ │ │ ├── auth.js
|
||||
│ │ │ ├── bots.js
|
||||
│ │ │ ├── categories.js
|
||||
│ │ │ ├── members.js
|
||||
│ │ │ ├── schedules.js
|
||||
│ │ │ ├── stats.js
|
||||
│ │ │ └── suggestions.js
|
||||
│ │ ├── components/ # 공통 컴포넌트
|
||||
│ │ │ └── common/
|
||||
│ │ │ ├── Lightbox.jsx # 이미지 라이트박스 (PC)
|
||||
│ │ │ └── LightboxIndicator.jsx
|
||||
│ │ ├── pages/
|
||||
│ │ │ ├── pc/ # PC 페이지
|
||||
│ │ │ └── mobile/ # 모바일 페이지
|
||||
│ │ ├── stores/ # Zustand 스토어
|
||||
│ │ ├── utils/
|
||||
│ │ │ └── date.js # dayjs 기반 날짜 유틸리티
|
||||
│ │ └── App.jsx
|
||||
│ ├── vite.config.js
|
||||
│ ├── Dockerfile # 프론트엔드 컨테이너
|
||||
|
|
|
|||
|
|
@ -164,6 +164,82 @@ 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
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## 개요
|
||||
|
||||
`backend-backup/` (Express) → `backend/` (Fastify)로 마이그레이션 진행 중
|
||||
`backend-backup/` (Express) → `backend/` (Fastify)로 마이그레이션 완료
|
||||
|
||||
## 완료된 작업
|
||||
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
### API 라우트 (`src/routes/`)
|
||||
- [x] 인증 (`/api/auth`)
|
||||
- POST /login - 로그인
|
||||
- GET /me - 현재 사용자 정보
|
||||
- GET /verify - 토큰 검증
|
||||
- [x] 멤버 (`/api/members`)
|
||||
- GET / - 목록 조회
|
||||
- GET /:name - 상세 조회
|
||||
|
|
@ -40,19 +40,37 @@
|
|||
- GET / - 목록
|
||||
- POST / - 업로드
|
||||
- DELETE /:teaserId - 삭제
|
||||
- [x] 일정 (`/api/schedules`) - 조회만
|
||||
- [x] 일정 (`/api/schedules`)
|
||||
- GET / - 월별 조회 (생일 포함)
|
||||
- GET /?search= - Meilisearch 검색
|
||||
- GET /:id - 상세 조회
|
||||
- DELETE /:id - 삭제
|
||||
- POST /sync-search - Meilisearch 동기화
|
||||
- [x] 추천 검색어 (`/api/schedules/suggestions`)
|
||||
- GET / - 추천 검색어 조회
|
||||
- kiwi-nlp 형태소 분석
|
||||
- bi-gram 자동완성
|
||||
- 초성 검색
|
||||
- GET /popular - 인기 검색어 조회
|
||||
- POST /save - 검색어 저장
|
||||
- GET /dict - 사용자 사전 조회 (관리자)
|
||||
- PUT /dict - 사용자 사전 저장 (관리자)
|
||||
- [x] 통계 (`/api/stats`)
|
||||
- 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/`)
|
||||
- [x] YouTube 봇 (`services/youtube/`)
|
||||
- 영상 자동 수집
|
||||
|
|
@ -64,41 +82,33 @@
|
|||
- 일정 검색
|
||||
- 전체 동기화
|
||||
- [x] 추천 검색어 (`services/suggestions/`)
|
||||
- 형태소 분석
|
||||
- 형태소 분석 (kiwi-nlp)
|
||||
- bi-gram 빈도
|
||||
- 초성 검색
|
||||
- 사용자 사전 관리
|
||||
- [x] 이미지 업로드 (`services/image.js`)
|
||||
- 앨범 커버
|
||||
- 멤버 이미지
|
||||
- 앨범 사진/티저
|
||||
|
||||
## 남은 작업
|
||||
## 남은 작업 (미구현)
|
||||
|
||||
### 관리자 API (admin.js에서 마이그레이션 필요)
|
||||
- [ ] 일정 CRUD
|
||||
- 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 - 경고 해제
|
||||
### 일반 일정 CRUD
|
||||
- [ ] POST /api/schedules - 일정 생성 (일반)
|
||||
- [ ] PUT /api/schedules/:id - 일정 수정 (일반)
|
||||
|
||||
### 기타 기능
|
||||
- [ ] X 프로필 조회 (`/api/schedules/x-profile/:username`)
|
||||
- [ ] 어드민 사전 관리 (형태소 분석용 사전)
|
||||
※ 현재는 YouTube/X 전용 일정 생성 API만 구현됨
|
||||
|
||||
### 카테고리 관리
|
||||
- [ ] POST /api/schedule-categories - 생성
|
||||
- [ ] PUT /api/schedule-categories/:id - 수정
|
||||
- [ ] DELETE /api/schedule-categories/:id - 삭제
|
||||
- [ ] PUT /api/schedule-categories-order - 순서 변경
|
||||
|
||||
※ GET은 구현됨 (목록 조회)
|
||||
|
||||
### 기타
|
||||
- [ ] GET /api/kakao/places - 카카오 장소 검색 프록시
|
||||
|
||||
## 파일 비교표
|
||||
|
||||
|
|
@ -108,14 +118,17 @@
|
|||
| routes/admin.js (앨범 CRUD) | routes/albums/index.js | 완료 |
|
||||
| routes/admin.js (사진/티저) | routes/albums/photos.js, teasers.js | 완료 |
|
||||
| routes/admin.js (멤버 수정) | routes/members/index.js | 완료 |
|
||||
| routes/admin.js (일정 CRUD) | - | 미완료 |
|
||||
| routes/admin.js (카테고리) | - | 미완료 |
|
||||
| routes/admin.js (봇 관리) | - | 미완료 |
|
||||
| routes/admin.js (일정 삭제) | routes/schedules/index.js | 완료 |
|
||||
| routes/admin.js (일정 생성/수정) | - | 미완료 |
|
||||
| routes/admin.js (카테고리 CUD) | - | 미완료 |
|
||||
| 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/x.js | 신규 |
|
||||
| routes/albums.js | routes/albums/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 | 완료 |
|
||||
| services/youtube-bot.js | services/youtube/ | 완료 |
|
||||
| services/youtube-scheduler.js | plugins/scheduler.js | 완료 |
|
||||
|
|
|
|||
193
docs/refactoring.md
Normal file
193
docs/refactoring.md
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
# 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,6 +8,7 @@
|
|||
"name": "fromis9-frontend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
|
|
@ -22,6 +23,7 @@
|
|||
"react-infinite-scroll-component": "^6.1.1",
|
||||
"react-intersection-observer": "^10.0.0",
|
||||
"react-ios-time-picker": "^0.2.2",
|
||||
"react-linkify": "^1.0.0-alpha",
|
||||
"react-photo-album": "^3.4.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-window": "^2.2.3",
|
||||
|
|
@ -285,6 +287,15 @@
|
|||
"@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": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
|
|
@ -333,6 +344,25 @@
|
|||
"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": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
|
|
@ -1283,6 +1313,18 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
|
|
@ -1986,6 +2028,15 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
|
|
@ -2518,6 +2569,16 @@
|
|||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"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": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/react-photo-album/-/react-photo-album-3.4.0.tgz",
|
||||
|
|
@ -2930,6 +2991,15 @@
|
|||
"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": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
|
@ -2982,6 +3052,21 @@
|
|||
"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": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
|
|
@ -23,6 +24,7 @@
|
|||
"react-infinite-scroll-component": "^6.1.1",
|
||||
"react-intersection-observer": "^10.0.0",
|
||||
"react-ios-time-picker": "^0.2.2",
|
||||
"react-linkify": "^1.0.0-alpha",
|
||||
"react-photo-album": "^3.4.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-window": "^2.2.3",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import ScheduleFormPage from './pages/pc/admin/schedule/form';
|
|||
import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory';
|
||||
import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots';
|
||||
import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict';
|
||||
import YouTubeEditForm from './pages/pc/admin/schedule/edit/YouTubeEditForm';
|
||||
|
||||
// 레이아웃
|
||||
import PCLayout from './components/pc/Layout';
|
||||
|
|
@ -76,6 +77,7 @@ function App() {
|
|||
<Route path="/admin/schedule/new" element={<ScheduleFormPage />} />
|
||||
<Route path="/admin/schedule/new-legacy" 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/bots" element={<AdminScheduleBots />} />
|
||||
<Route path="/admin/schedule/dict" element={<AdminScheduleDict />} />
|
||||
|
|
|
|||
|
|
@ -176,8 +176,10 @@ function MobileHome() {
|
|||
const isCurrentYear = scheduleYear === currentYear;
|
||||
const isCurrentMonth = isCurrentYear && scheduleMonth === currentMonth;
|
||||
|
||||
// 멤버 처리 (5명 이상이면 프로미스나인)
|
||||
const memberList = schedule.member_names ? schedule.member_names.split(',').map(n => n.trim()).filter(Boolean) : [];
|
||||
// 멤버 처리
|
||||
const memberList = schedule.member_names
|
||||
? schedule.member_names.split(',').map(n => n.trim()).filter(Boolean)
|
||||
: schedule.members?.map(m => m.name) || [];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
|
@ -237,7 +239,7 @@ function MobileHome() {
|
|||
{/* 멤버 */}
|
||||
{memberList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{(memberList.length >= 5 ? ['프로미스나인'] : memberList).map((name, i) => (
|
||||
{memberList.map((name, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2 py-0.5 bg-primary/10 text-primary text-[10px] rounded-full font-medium"
|
||||
|
|
|
|||
|
|
@ -178,16 +178,9 @@ function MobileMembers() {
|
|||
{member.name}
|
||||
</h2>
|
||||
|
||||
{/* 포지션 */}
|
||||
{member.position && (
|
||||
<p className="mt-2 text-base text-white/90 font-medium">
|
||||
{member.position}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 생일 정보 */}
|
||||
{member.birth_date && (
|
||||
<div className="flex items-center gap-1.5 mt-3 text-white/80">
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-white/80">
|
||||
<Calendar size={16} className="text-white/70" />
|
||||
<span className="text-sm">
|
||||
{member.birth_date?.slice(0, 10).replaceAll('-', '.')}
|
||||
|
|
|
|||
|
|
@ -324,8 +324,13 @@ function MobileSchedule() {
|
|||
}
|
||||
}, [schedules, loading]);
|
||||
|
||||
// 2017년 1월 이전으로 이동 불가
|
||||
const canGoPrevMonth = !(selectedDate.getFullYear() === 2017 && selectedDate.getMonth() === 0);
|
||||
|
||||
// 월 변경
|
||||
const changeMonth = (delta) => {
|
||||
if (delta < 0 && !canGoPrevMonth) return;
|
||||
|
||||
const newDate = new Date(selectedDate);
|
||||
newDate.setMonth(newDate.getMonth() + delta);
|
||||
|
||||
|
|
@ -643,7 +648,11 @@ function MobileSchedule() {
|
|||
>
|
||||
<Calendar size={20} className="text-gray-600" />
|
||||
</button>
|
||||
<button onClick={() => changeMonth(-1)} className="p-2">
|
||||
<button
|
||||
onClick={() => changeMonth(-1)}
|
||||
disabled={!canGoPrevMonth}
|
||||
className={`p-2 ${!canGoPrevMonth ? 'opacity-30' : ''}`}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -1012,20 +1021,14 @@ function ScheduleCard({ schedule, categoryColor, categories, delay = 0, onClick
|
|||
{/* 멤버 */}
|
||||
{memberList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-gray-100">
|
||||
{memberList.length >= 5 ? (
|
||||
<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">
|
||||
프로미스나인
|
||||
{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>
|
||||
) : (
|
||||
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>
|
||||
|
|
@ -1091,20 +1094,14 @@ function TimelineScheduleCard({ schedule, categoryColor, categories, delay = 0,
|
|||
{/* 멤버 */}
|
||||
{memberList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-gray-100">
|
||||
{memberList.length >= 5 ? (
|
||||
<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">
|
||||
프로미스나인
|
||||
{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>
|
||||
) : (
|
||||
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>
|
||||
|
|
@ -1167,6 +1164,9 @@ function CalendarPicker({
|
|||
const year = viewDate.getFullYear();
|
||||
const month = viewDate.getMonth();
|
||||
|
||||
// 2017년 1월 이전으로 이동 불가
|
||||
const canGoPrevMonth = !(year === 2017 && month === 0);
|
||||
|
||||
// 달력 데이터 생성 함수
|
||||
const getCalendarDays = useCallback((y, m) => {
|
||||
const firstDay = new Date(y, m, 1);
|
||||
|
|
@ -1209,10 +1209,11 @@ function CalendarPicker({
|
|||
}, []);
|
||||
|
||||
const changeMonth = useCallback((delta) => {
|
||||
if (delta < 0 && !canGoPrevMonth) return;
|
||||
const newDate = new Date(viewDate);
|
||||
newDate.setMonth(newDate.getMonth() + delta);
|
||||
setViewDate(newDate);
|
||||
}, [viewDate]);
|
||||
}, [viewDate, canGoPrevMonth]);
|
||||
|
||||
const isToday = (date) => {
|
||||
const today = new Date();
|
||||
|
|
@ -1239,7 +1240,7 @@ function CalendarPicker({
|
|||
}
|
||||
};
|
||||
|
||||
const MIN_YEAR = 2025;
|
||||
const MIN_YEAR = 2017;
|
||||
const [yearRangeStart, setYearRangeStart] = useState(MIN_YEAR);
|
||||
const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i);
|
||||
const canGoPrevYearRange = yearRangeStart > MIN_YEAR;
|
||||
|
|
@ -1452,7 +1453,8 @@ function CalendarPicker({
|
|||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={() => changeMonth(-1)}
|
||||
className="p-1"
|
||||
disabled={!canGoPrevMonth}
|
||||
className={`p-1 ${!canGoPrevMonth ? 'opacity-30' : ''}`}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -2,88 +2,12 @@ import { useParams, Link } from 'react-router-dom';
|
|||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Calendar, Clock, ChevronLeft, Check, Link2, MapPin, Navigation, ExternalLink } from 'lucide-react';
|
||||
import { getSchedule, getXProfile } from '../../../api/public/schedules';
|
||||
import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight } from 'lucide-react';
|
||||
import Linkify from 'react-linkify';
|
||||
import { getSchedule } from '../../../api/public/schedules';
|
||||
import { formatXDateTime } from '../../../utils/date';
|
||||
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) {
|
||||
useEffect(() => {
|
||||
|
|
@ -128,10 +52,6 @@ function useFullscreenOrientation(isShorts) {
|
|||
const CATEGORY_ID = {
|
||||
YOUTUBE: 2,
|
||||
X: 3,
|
||||
ALBUM: 4,
|
||||
FANSIGN: 5,
|
||||
CONCERT: 6,
|
||||
TICKET: 7,
|
||||
};
|
||||
|
||||
// HTML 엔티티 디코딩 함수
|
||||
|
|
@ -142,18 +62,6 @@ const decodeHtmlEntities = (text) => {
|
|||
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) => {
|
||||
if (!dateStr) return '';
|
||||
|
|
@ -168,37 +76,10 @@ const formatTime = (timeStr) => {
|
|||
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 }) {
|
||||
const videoId = extractYoutubeVideoId(schedule.source?.url);
|
||||
const isShorts = schedule.source?.url?.includes('/shorts/');
|
||||
const videoId = schedule.videoId;
|
||||
const isShorts = schedule.videoType === 'shorts';
|
||||
|
||||
// 전체화면 시 가로 회전 (숏츠 제외)
|
||||
useFullscreenOrientation(isShorts);
|
||||
|
|
@ -230,12 +111,12 @@ function YoutubeSection({ schedule }) {
|
|||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 영상 정보 카드 */}
|
||||
{/* 영상 정보 */}
|
||||
<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"
|
||||
className="bg-gradient-to-br from-gray-100 to-gray-200/80 rounded-xl p-4"
|
||||
>
|
||||
{/* 제목 */}
|
||||
<h1 className="font-bold text-gray-900 text-base leading-relaxed mb-3">
|
||||
|
|
@ -246,18 +127,12 @@ function YoutubeSection({ schedule }) {
|
|||
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 mb-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar size={12} />
|
||||
<span>{formatFullDate(schedule.date)}</span>
|
||||
<span>{formatXDateTime(schedule.datetime)}</span>
|
||||
</div>
|
||||
{schedule.time && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
<span>{formatTime(schedule.time)}</span>
|
||||
</div>
|
||||
)}
|
||||
{schedule.source?.name && (
|
||||
{schedule.channelName && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Link2 size={12} />
|
||||
<span>{schedule.source?.name}</span>
|
||||
<span>{schedule.channelName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -283,17 +158,19 @@ function YoutubeSection({ schedule }) {
|
|||
)}
|
||||
|
||||
{/* 유튜브에서 보기 버튼 */}
|
||||
<a
|
||||
href={schedule.source?.url}
|
||||
target="_blank"
|
||||
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"
|
||||
>
|
||||
<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"/>
|
||||
</svg>
|
||||
YouTube에서 보기
|
||||
</a>
|
||||
<div className="pt-4 border-t border-gray-300/50">
|
||||
<a
|
||||
href={schedule.videoUrl}
|
||||
target="_blank"
|
||||
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"
|
||||
>
|
||||
<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"/>
|
||||
</svg>
|
||||
YouTube에서 보기
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -301,20 +178,83 @@ function YoutubeSection({ schedule }) {
|
|||
|
||||
// X(트위터) 섹션 컴포넌트
|
||||
function XSection({ schedule }) {
|
||||
const username = extractXUsername(schedule.source?.url);
|
||||
|
||||
// 프로필 정보 조회
|
||||
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 profile = schedule.profile;
|
||||
const username = profile?.username || 'realfromis_9';
|
||||
const displayName = profile?.displayName || username;
|
||||
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 (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
|
|
@ -347,9 +287,7 @@ 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"/>
|
||||
</svg>
|
||||
</div>
|
||||
{username && (
|
||||
<span className="text-xs text-gray-500">@{username}</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">@{username}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -357,32 +295,53 @@ function XSection({ schedule }) {
|
|||
{/* 본문 */}
|
||||
<div className="p-4">
|
||||
<p className="text-gray-900 text-[15px] leading-relaxed whitespace-pre-wrap">
|
||||
{decodeHtmlEntities(schedule.description || schedule.title)}
|
||||
<Linkify componentDecorator={linkDecorator}>
|
||||
{decodeHtmlEntities(schedule.content || schedule.title)}
|
||||
</Linkify>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 이미지 */}
|
||||
{schedule.image_url && (
|
||||
{schedule.imageUrls?.length > 0 && (
|
||||
<div className="px-4 pb-3">
|
||||
<img
|
||||
src={schedule.image_url}
|
||||
alt=""
|
||||
className="w-full rounded-xl border border-gray-100"
|
||||
/>
|
||||
{schedule.imageUrls.length === 1 ? (
|
||||
<img
|
||||
src={schedule.imageUrls[0]}
|
||||
alt=""
|
||||
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 className="px-4 py-3 border-t border-gray-100">
|
||||
<span className="text-gray-500 text-sm">
|
||||
{formatXDateTime(schedule.date, schedule.time)}
|
||||
{formatXDateTime(schedule.datetime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* X에서 보기 버튼 */}
|
||||
<div className="px-4 py-3 border-t border-gray-100 bg-gray-50/50">
|
||||
<a
|
||||
href={schedule.source?.url}
|
||||
href={schedule.postUrl}
|
||||
target="_blank"
|
||||
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"
|
||||
|
|
@ -394,331 +353,69 @@ function XSection({ schedule }) {
|
|||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// 콘서트 섹션 컴포넌트
|
||||
function ConcertSection({ schedule }) {
|
||||
// 현재 선택된 회차 ID (내부 state로 관리 - URL 변경 없음)
|
||||
const [selectedDateId, setSelectedDateId] = useState(schedule.id);
|
||||
// 다이얼로그 열림 상태
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
// 다이얼로그 목록 ref (자동 스크롤용)
|
||||
const listRef = useRef(null);
|
||||
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
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
className="w-full mt-2 py-2.5 text-sm text-gray-500 font-medium active:bg-gray-50 rounded-lg transition-colors"
|
||||
>
|
||||
다른 회차 선택
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 장소 카드 */}
|
||||
{displayData.locationName && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="bg-white rounded-xl p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 mb-2">
|
||||
<MapPin size={14} />
|
||||
<span>장소</span>
|
||||
</div>
|
||||
<p className="text-gray-900 font-medium">{displayData.locationName}</p>
|
||||
{displayData.locationAddress && (
|
||||
<p className="text-gray-500 text-sm mt-0.5">{displayData.locationAddress}</p>
|
||||
)}
|
||||
|
||||
{/* 지도 - 좌표가 있으면 카카오맵, 없으면 구글맵 */}
|
||||
{hasLocation ? (
|
||||
<div className="mt-3 rounded-xl overflow-hidden">
|
||||
<KakaoMap
|
||||
lat={parseFloat(displayData.locationLat)}
|
||||
lng={parseFloat(displayData.locationLng)}
|
||||
name={displayData.locationName}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</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
|
||||
{lightboxOpen && schedule.imageUrls?.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black z-50 flex items-center justify-center"
|
||||
onClick={closeLightbox}
|
||||
>
|
||||
{/* 닫기 버튼 */}
|
||||
<button
|
||||
className="absolute top-4 right-4 p-2 text-white/70 z-10"
|
||||
onClick={closeLightbox}
|
||||
>
|
||||
<X size={28} />
|
||||
</button>
|
||||
|
||||
{/* 이미지 */}
|
||||
<motion.img
|
||||
key={lightboxIndex}
|
||||
src={schedule.imageUrls[lightboxIndex]}
|
||||
alt=""
|
||||
className="max-w-full max-h-full object-contain"
|
||||
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">
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{/* 이전/다음 버튼 */}
|
||||
{schedule.imageUrls.length > 1 && (
|
||||
<>
|
||||
<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"
|
||||
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 && (
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex gap-2">
|
||||
{schedule.imageUrls.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`w-2 h-2 rounded-full transition-colors ${
|
||||
i === lightboxIndex ? 'bg-white' : 'bg-white/40'
|
||||
}`}
|
||||
onClick={(e) => { e.stopPropagation(); setLightboxIndex(i); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
|
|
@ -870,14 +567,13 @@ function MobileScheduleDetail() {
|
|||
}
|
||||
|
||||
// 카테고리별 섹션 렌더링
|
||||
const categoryId = schedule.category?.id;
|
||||
const renderCategorySection = () => {
|
||||
switch (schedule.category_id) {
|
||||
switch (categoryId) {
|
||||
case CATEGORY_ID.YOUTUBE:
|
||||
return <YoutubeSection schedule={schedule} />;
|
||||
case CATEGORY_ID.X:
|
||||
return <XSection schedule={schedule} />;
|
||||
case CATEGORY_ID.CONCERT:
|
||||
return <ConcertSection schedule={schedule} />;
|
||||
default:
|
||||
return <DefaultSection schedule={schedule} />;
|
||||
}
|
||||
|
|
@ -897,9 +593,9 @@ function MobileScheduleDetail() {
|
|||
<div className="flex-1 text-center">
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{ color: schedule.category_color }}
|
||||
style={{ color: schedule.category?.color }}
|
||||
>
|
||||
{schedule.category_name}
|
||||
{schedule.category?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-10" />
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
Home, ChevronRight, Calendar, Plus, Edit2, Trash2,
|
||||
ChevronLeft, Search, ChevronDown, Bot, Tag, ArrowLeft, ExternalLink, Clock, Link2, Book
|
||||
} from 'lucide-react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
|
||||
|
|
@ -27,6 +27,24 @@ const decodeHtmlEntities = (text) => {
|
|||
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로 불필요한 리렌더링 방지
|
||||
const ScheduleItem = memo(function ScheduleItem({
|
||||
schedule,
|
||||
|
|
@ -119,7 +137,7 @@ const ScheduleItem = memo(function ScheduleItem({
|
|||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate(`/admin/schedule/${schedule.id}/edit`)}
|
||||
onClick={() => navigate(getEditPath(schedule.id, schedule.category_id))}
|
||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
|
|
@ -139,6 +157,7 @@ const ScheduleItem = memo(function ScheduleItem({
|
|||
|
||||
function AdminSchedule() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Zustand 스토어에서 상태 가져오기
|
||||
const {
|
||||
|
|
@ -154,7 +173,6 @@ function AdminSchedule() {
|
|||
const { user, isAuthenticated } = useAdminAuth();
|
||||
|
||||
// 로컬 상태 (페이지 이동 시 유지할 필요 없는 것들)
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { toast, setToast } = useToast();
|
||||
const scrollContainerRef = useRef(null);
|
||||
const searchContainerRef = useRef(null); // 검색 컨테이너 (외부 클릭 감지용)
|
||||
|
|
@ -273,8 +291,8 @@ function AdminSchedule() {
|
|||
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
|
||||
|
||||
// 년도 범위 (2025년부터 시작, 12년 단위)
|
||||
const MIN_YEAR = 2025;
|
||||
// 년도 범위 (2017년부터 시작, 12년 단위)
|
||||
const MIN_YEAR = 2017;
|
||||
const startYear = Math.max(MIN_YEAR, Math.floor(year / 12) * 12 - 1);
|
||||
const yearRange = Array.from({ length: 12 }, (_, i) => startYear + i);
|
||||
const canGoPrevYearRange = startYear > MIN_YEAR;
|
||||
|
|
@ -288,8 +306,12 @@ function AdminSchedule() {
|
|||
|
||||
const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate();
|
||||
|
||||
// 일정 목록 (API에서 로드)
|
||||
const [schedules, setSchedules] = useState([]);
|
||||
// 일정 목록 (React Query로 캐싱)
|
||||
const { data: schedules = [], isLoading: loading } = useQuery({
|
||||
queryKey: ['adminSchedules', year, month + 1],
|
||||
queryFn: () => schedulesApi.getSchedules(year, month + 1),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// 카테고리는 일정 데이터에서 추출
|
||||
const categories = useMemo(() => {
|
||||
|
|
@ -386,14 +408,10 @@ function AdminSchedule() {
|
|||
if (savedToast) {
|
||||
setToast(JSON.parse(savedToast));
|
||||
sessionStorage.removeItem('scheduleToast');
|
||||
// 추가/수정 후 돌아왔을 때 캐시 무효화
|
||||
queryClient.invalidateQueries({ queryKey: ['adminSchedules'] });
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
|
||||
// 월이 변경될 때마다 일정 로드
|
||||
useEffect(() => {
|
||||
fetchSchedules();
|
||||
}, [year, month]);
|
||||
}, [isAuthenticated, queryClient]);
|
||||
|
||||
// 스크롤 위치 복원
|
||||
useEffect(() => {
|
||||
|
|
@ -414,21 +432,6 @@ function AdminSchedule() {
|
|||
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(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
|
|
@ -453,8 +456,12 @@ function AdminSchedule() {
|
|||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showYearMonthPicker, showCategoryTooltip]);
|
||||
|
||||
// 2017년 1월 이전으로 이동 불가
|
||||
const canGoPrevMonth = !(year === 2017 && month === 0);
|
||||
|
||||
// 월 이동
|
||||
const prevMonth = () => {
|
||||
if (!canGoPrevMonth) return;
|
||||
setSlideDirection(-1);
|
||||
const newDate = new Date(year, month - 1, 1);
|
||||
setCurrentDate(newDate);
|
||||
|
|
@ -466,7 +473,6 @@ function AdminSchedule() {
|
|||
const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`;
|
||||
setSelectedDate(firstDay);
|
||||
}
|
||||
setSchedules([]); // 이전 달 데이터 즉시 초기화
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
|
|
@ -481,10 +487,9 @@ function AdminSchedule() {
|
|||
const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`;
|
||||
setSelectedDate(firstDay);
|
||||
}
|
||||
setSchedules([]); // 이전 달 데이터 즉시 초기화
|
||||
};
|
||||
|
||||
// 년도 범위 이동 (12년 단위, 2025년 이전 불가)
|
||||
// 년도 범위 이동 (12년 단위, 2017년 이전 불가)
|
||||
const prevYearRange = () => canGoPrevYearRange && setCurrentDate(new Date(Math.max(MIN_YEAR, year - 12), month, 1));
|
||||
const nextYearRange = () => setCurrentDate(new Date(year + 12, month, 1));
|
||||
|
||||
|
|
@ -534,7 +539,8 @@ function AdminSchedule() {
|
|||
try {
|
||||
await schedulesApi.deleteSchedule(scheduleToDelete.id);
|
||||
setToast({ type: 'success', message: '일정이 삭제되었습니다.' });
|
||||
fetchSchedules();
|
||||
// 캐시 무효화하여 목록 새로고침
|
||||
queryClient.invalidateQueries({ queryKey: ['adminSchedules', year, month + 1] });
|
||||
} catch (error) {
|
||||
console.error('삭제 오류:', error);
|
||||
setToast({ type: 'error', message: error.message || '삭제 중 오류가 발생했습니다.' });
|
||||
|
|
@ -696,8 +702,8 @@ function AdminSchedule() {
|
|||
<div className={`flex items-center justify-between mb-8 ${isSearchMode ? 'opacity-50' : ''}`}>
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
disabled={isSearchMode}
|
||||
className={`p-2 rounded-full transition-colors ${isSearchMode ? 'cursor-not-allowed' : 'hover:bg-gray-100'}`}
|
||||
disabled={isSearchMode || !canGoPrevMonth}
|
||||
className={`p-2 rounded-full transition-colors ${isSearchMode || !canGoPrevMonth ? 'opacity-30' : 'hover:bg-gray-100'}`}
|
||||
>
|
||||
<ChevronLeft size={24} />
|
||||
</button>
|
||||
|
|
@ -1277,19 +1283,16 @@ function AdminSchedule() {
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
{schedule.member_names && (
|
||||
{(schedule.members?.length > 0 || schedule.member_names) && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{schedule.member_names.split(',').length >= 5 ? (
|
||||
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
||||
프로미스나인
|
||||
</span>
|
||||
) : (
|
||||
schedule.member_names.split(',').map((name, i) => (
|
||||
{(() => {
|
||||
const memberList = schedule.members?.map(m => m.name) || schedule.member_names?.split(',') || [];
|
||||
return memberList.map((name, i) => (
|
||||
<span key={i} className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
||||
{name.trim()}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1307,7 +1310,7 @@ function AdminSchedule() {
|
|||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate(`/admin/schedule/${schedule.id}/edit`)}
|
||||
onClick={() => navigate(getEditPath(schedule.id, schedule.category_id))}
|
||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Home, ChevronRight, Bot, Play, Square,
|
||||
|
|
@ -44,46 +45,42 @@ const MeilisearchIcon = ({ size = 20 }) => (
|
|||
|
||||
function AdminScheduleBots() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
const { toast, setToast } = useToast();
|
||||
const [bots, setBots] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true); // 첫 로드 여부 (애니메이션용)
|
||||
const [syncing, setSyncing] = useState(null); // 동기화 중인 봇 ID
|
||||
const [quotaWarning, setQuotaWarning] = useState(null); // 할당량 경고 상태
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchBots();
|
||||
fetchQuotaWarning();
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// 봇 목록 조회
|
||||
const fetchBots = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await botsApi.getBots();
|
||||
setBots(data);
|
||||
} catch (error) {
|
||||
console.error('봇 목록 조회 오류:', error);
|
||||
setToast({ type: 'error', message: '봇 목록을 불러올 수 없습니다.' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const { data: bots = [], isLoading: loading, isError, refetch: fetchBots } = useQuery({
|
||||
queryKey: ['admin', 'bots'],
|
||||
queryFn: botsApi.getBots,
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
// 할당량 경고 상태 조회
|
||||
const fetchQuotaWarning = async () => {
|
||||
try {
|
||||
const data = await botsApi.getQuotaWarning();
|
||||
if (data.active) {
|
||||
setQuotaWarning(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('할당량 경고 조회 오류:', error);
|
||||
const { data: quotaData } = useQuery({
|
||||
queryKey: ['admin', 'bots', 'quota'],
|
||||
queryFn: botsApi.getQuotaWarning,
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60000,
|
||||
});
|
||||
|
||||
// 에러 처리
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
setToast({ type: 'error', message: '봇 목록을 불러올 수 없습니다.' });
|
||||
}
|
||||
};
|
||||
}, [isError, setToast]);
|
||||
|
||||
// 할당량 경고 상태 업데이트
|
||||
useEffect(() => {
|
||||
if (quotaData?.active) {
|
||||
setQuotaWarning(quotaData);
|
||||
}
|
||||
}, [quotaData]);
|
||||
|
||||
// 할당량 경고 해제
|
||||
const handleDismissQuotaWarning = async () => {
|
||||
|
|
@ -106,12 +103,14 @@ function AdminScheduleBots() {
|
|||
await botsApi.stopBot(botId);
|
||||
}
|
||||
|
||||
// 로컬 상태만 업데이트 (전체 목록 새로고침 대신)
|
||||
setBots(prev => prev.map(bot =>
|
||||
bot.id === botId
|
||||
? { ...bot, status: action === 'start' ? 'running' : 'stopped' }
|
||||
: bot
|
||||
));
|
||||
// 캐시 업데이트 (전체 목록 새로고침 대신)
|
||||
queryClient.setQueryData(['admin', 'bots'], (prev) =>
|
||||
prev?.map(bot =>
|
||||
bot.id === botId
|
||||
? { ...bot, status: action === 'start' ? 'running' : 'stopped' }
|
||||
: bot
|
||||
)
|
||||
);
|
||||
setToast({
|
||||
type: 'success',
|
||||
message: action === 'start' ? `${botName} 봇이 시작되었습니다.` : `${botName} 봇이 정지되었습니다.`
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
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 Toast from '../../../components/Toast';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
|
|
@ -171,8 +172,8 @@ function WordItem({ id, word, pos, index, onUpdate, onDelete }) {
|
|||
function AdminScheduleDict() {
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
const { toast, setToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [entries, setEntries] = useState([]); // [{word, pos, isComment, id}]
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterPos, setFilterPos] = useState('all');
|
||||
|
|
@ -239,55 +240,59 @@ function AdminScheduleDict() {
|
|||
return stats;
|
||||
}, [entries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchDict();
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// 고유 ID 생성
|
||||
const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const generateId = useCallback(() => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, []);
|
||||
|
||||
// 사전 파일 파싱
|
||||
const parseDict = (content) => {
|
||||
const parseDict = useCallback((content) => {
|
||||
const lines = content.split('\n');
|
||||
return lines.map(line => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
return { isComment: true, raw: line, id: generateId() };
|
||||
return { isComment: true, raw: line, id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}` };
|
||||
}
|
||||
const parts = trimmed.split('\t');
|
||||
return {
|
||||
word: parts[0] || '',
|
||||
pos: parts[1] || 'NNP',
|
||||
isComment: false,
|
||||
id: generateId(),
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
};
|
||||
}).filter(e => e.isComment || e.word); // 빈 줄 제거하되 주석은 유지
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 사전 파일 생성
|
||||
const serializeDict = (entries) => {
|
||||
const serializeDict = useCallback((entries) => {
|
||||
return entries.map(e => {
|
||||
if (e.isComment) return e.raw;
|
||||
return `${e.word}\t${e.pos}`;
|
||||
}).join('\n');
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 사전 내용 조회
|
||||
const fetchDict = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 사전 내용 조회 (useQuery)
|
||||
const { data: dictContent, isLoading: loading, isError } = useQuery({
|
||||
queryKey: ['admin', 'dict'],
|
||||
queryFn: async () => {
|
||||
const data = await suggestionsApi.getDict();
|
||||
const parsed = parseDict(data.content || '');
|
||||
return data.content || '';
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// 사전 데이터 로드 후 파싱
|
||||
useEffect(() => {
|
||||
if (dictContent !== undefined) {
|
||||
const parsed = parseDict(dictContent);
|
||||
setEntries(parsed);
|
||||
} catch (error) {
|
||||
console.error('사전 조회 오류:', error);
|
||||
setToast({ type: 'error', message: '사전을 불러올 수 없습니다.' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [dictContent, parseDict]);
|
||||
|
||||
// 에러 처리
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
setToast({ type: 'error', message: '사전을 불러올 수 없습니다.' });
|
||||
}
|
||||
}, [isError, setToast]);
|
||||
|
||||
// 사전 저장 (entries 배열을 받아서 저장)
|
||||
const saveDict = async (newEntries) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate, Link, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { formatDate } from "../../../utils/date";
|
||||
import {
|
||||
|
|
@ -42,7 +43,6 @@ function AdminScheduleForm() {
|
|||
|
||||
const { toast, setToast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [members, setMembers] = useState([]);
|
||||
|
||||
// 폼 데이터 (날짜/시간 범위 지원)
|
||||
const [formData, setFormData] = useState({
|
||||
|
|
@ -77,8 +77,22 @@ function AdminScheduleForm() {
|
|||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetIndex, setDeleteTargetIndex] = useState(null);
|
||||
|
||||
// 카테고리 목록 (API에서 로드)
|
||||
const [categories, setCategories] = useState([]);
|
||||
// 멤버 목록 조회
|
||||
const { data: membersData = [] } = useQuery({
|
||||
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);
|
||||
|
|
@ -132,28 +146,16 @@ function AdminScheduleForm() {
|
|||
return days[date.getDay()];
|
||||
};
|
||||
|
||||
// 카테고리 로드
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// 첫 번째 카테고리를 기본값으로 설정
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
if (categories.length > 0 && !formData.category && !isEditMode) {
|
||||
setFormData((prev) => ({ ...prev, category: categories[0].id }));
|
||||
}
|
||||
}, [categories, isEditMode]);
|
||||
|
||||
fetchMembers();
|
||||
fetchCategories();
|
||||
|
||||
// 수정 모드일 경우 기존 데이터 로드
|
||||
if (isEditMode && id) {
|
||||
// 수정 모드일 경우 기존 데이터 로드
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && isEditMode && id) {
|
||||
fetchSchedule();
|
||||
}
|
||||
}, [isAuthenticated, isEditMode, id]);
|
||||
|
|
@ -223,15 +225,6 @@ 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 newMembers = formData.members.includes(memberId)
|
||||
|
|
|
|||
539
frontend/src/pages/pc/admin/schedule/edit/YouTubeEditForm.jsx
Normal file
539
frontend/src/pages/pc/admin/schedule/edit/YouTubeEditForm.jsx
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
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,10 +45,30 @@ function AlbumDetail() {
|
|||
}));
|
||||
}, [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(() => {
|
||||
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 스크롤 숨기기
|
||||
useEffect(() => {
|
||||
if (lightbox.open) {
|
||||
|
|
@ -98,7 +118,7 @@ function AlbumDetail() {
|
|||
goToNext();
|
||||
break;
|
||||
case 'Escape':
|
||||
closeLightbox();
|
||||
window.history.back();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
|
@ -202,11 +222,10 @@ function AlbumDetail() {
|
|||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-80 h-80 flex-shrink-0 rounded-2xl overflow-hidden shadow-lg cursor-pointer group"
|
||||
onClick={() => setLightbox({
|
||||
open: true,
|
||||
images: [album.cover_original_url || album.cover_medium_url],
|
||||
index: 0
|
||||
})}
|
||||
onClick={() => openLightbox(
|
||||
[album.cover_original_url || album.cover_medium_url],
|
||||
0
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={album.cover_medium_url || album.cover_original_url}
|
||||
|
|
@ -253,6 +272,7 @@ function AlbumDetail() {
|
|||
<button
|
||||
onClick={() => {
|
||||
setShowDescriptionModal(true);
|
||||
window.history.pushState({ description: true }, '');
|
||||
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"
|
||||
|
|
@ -293,14 +313,13 @@ function AlbumDetail() {
|
|||
{album.teasers.map((teaser, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => setLightbox({
|
||||
open: true,
|
||||
images: album.teasers.map(t =>
|
||||
onClick={() => openLightbox(
|
||||
album.teasers.map(t =>
|
||||
t.media_type === 'video' ? (t.video_url || t.original_url) : t.original_url
|
||||
),
|
||||
index,
|
||||
teasers: album.teasers // media_type 정보 전달
|
||||
})}
|
||||
{ teasers: album.teasers }
|
||||
)}
|
||||
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"
|
||||
>
|
||||
<>
|
||||
|
|
@ -333,7 +352,7 @@ function AlbumDetail() {
|
|||
transition={{ delay: 0.2, duration: 0.4 }}
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-4">수록곡</h2>
|
||||
<div className="bg-white rounded-2xl shadow-lg overflow-hidden">
|
||||
<div className="bg-white rounded-2xl shadow-md overflow-hidden">
|
||||
{album.tracks?.map((track, index) => (
|
||||
<div
|
||||
key={track.id}
|
||||
|
|
@ -399,7 +418,7 @@ function AlbumDetail() {
|
|||
{previewPhotos.map((photo, idx) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
onClick={() => setLightbox({ open: true, images: [photo.original_url], index: 0 })}
|
||||
onClick={() => openLightbox([photo.original_url], 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"
|
||||
>
|
||||
<img
|
||||
|
|
@ -447,7 +466,7 @@ function AlbumDetail() {
|
|||
{/* 닫기 버튼 */}
|
||||
<button
|
||||
className="text-white/70 hover:text-white transition-colors"
|
||||
onClick={closeLightbox}
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
<X size={32} />
|
||||
</button>
|
||||
|
|
@ -537,7 +556,7 @@ function AlbumDetail() {
|
|||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
|
||||
onClick={() => setShowDescriptionModal(false)}
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
|
|
@ -550,7 +569,7 @@ function AlbumDetail() {
|
|||
<div className="flex items-center justify-between p-5 border-b border-gray-100">
|
||||
<h3 className="text-lg font-bold">앨범 소개</h3>
|
||||
<button
|
||||
onClick={() => setShowDescriptionModal(false)}
|
||||
onClick={() => window.history.back()}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-full transition-colors"
|
||||
>
|
||||
<X size={20} className="text-gray-500" />
|
||||
|
|
|
|||
|
|
@ -70,17 +70,30 @@ function AlbumGallery() {
|
|||
return allPhotos;
|
||||
}, [album]);
|
||||
|
||||
// 라이트박스 열기
|
||||
const openLightbox = (index) => {
|
||||
// 라이트박스 열기 - 히스토리 추가
|
||||
const openLightbox = useCallback((index) => {
|
||||
setImageLoaded(false);
|
||||
setLightbox({ open: true, index });
|
||||
};
|
||||
window.history.pushState({ lightbox: true }, '');
|
||||
}, []);
|
||||
|
||||
// 라이트박스 닫기
|
||||
const closeLightbox = useCallback(() => {
|
||||
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 스크롤 숨기기
|
||||
useEffect(() => {
|
||||
if (lightbox.open) {
|
||||
|
|
@ -146,7 +159,7 @@ function AlbumGallery() {
|
|||
switch (e.key) {
|
||||
case 'ArrowLeft': goToPrev(); break;
|
||||
case 'ArrowRight': goToNext(); break;
|
||||
case 'Escape': closeLightbox(); break;
|
||||
case 'Escape': window.history.back(); break;
|
||||
default: break;
|
||||
}
|
||||
};
|
||||
|
|
@ -269,7 +282,7 @@ function AlbumGallery() {
|
|||
</button>
|
||||
<button
|
||||
className="text-white/70 hover:text-white transition-colors"
|
||||
onClick={closeLightbox}
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
<X size={32} />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -254,9 +254,10 @@ function Home() {
|
|||
// 멤버 처리
|
||||
const memberList = schedule.member_names
|
||||
? schedule.member_names.split(",")
|
||||
: [];
|
||||
const displayMembers =
|
||||
memberList.length >= 5 ? ["프로미스나인"] : memberList;
|
||||
: schedule.members?.map(m => m.name) || [];
|
||||
const displayMembers = memberList;
|
||||
|
||||
const categoryColor = schedule.category_color || '#6366f1';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
|
@ -269,71 +270,48 @@ function Home() {
|
|||
transition: { duration: 0.4, ease: "easeOut" },
|
||||
},
|
||||
}}
|
||||
className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||
className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer"
|
||||
>
|
||||
{/* 날짜 영역 - primary 색상 고정 */}
|
||||
<div className="w-20 flex flex-col items-center justify-center text-white py-5 bg-primary">
|
||||
{/* 현재 년도가 아니면 년.월 표시 */}
|
||||
{/* 날짜 영역 - 카테고리 색상 */}
|
||||
<div
|
||||
className="w-24 flex flex-col items-center justify-center text-white py-6"
|
||||
style={{ backgroundColor: categoryColor }}
|
||||
>
|
||||
{!isCurrentYear && (
|
||||
<span className="text-xs font-medium opacity-70">
|
||||
{scheduleYear}.{scheduleMonth + 1}
|
||||
</span>
|
||||
)}
|
||||
{/* 현재 달이 아니면 월 표시 (현재 년도일 때) */}
|
||||
{isCurrentYear && !isCurrentMonth && (
|
||||
<span className="text-xs font-medium opacity-70">
|
||||
{scheduleMonth + 1}월
|
||||
</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 className="flex-1 p-5 flex flex-col justify-center">
|
||||
<h3 className="font-bold text-lg text-gray-900 mb-2">
|
||||
{schedule.title}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-500">
|
||||
<div className="flex-1 p-6 flex flex-col justify-center">
|
||||
<h3 className="font-bold text-lg mb-2">{schedule.title}</h3>
|
||||
<div className="flex flex-wrap gap-3 text-base text-gray-500">
|
||||
{schedule.time && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock
|
||||
size={14}
|
||||
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">
|
||||
<Clock size={16} className="opacity-60" />
|
||||
{schedule.time.slice(0, 5)}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag size={16} className="opacity-60" />
|
||||
{schedule.category_name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 멤버 태그 */}
|
||||
{displayMembers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{displayMembers.map((name, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2.5 py-1 bg-primary/10 text-primary text-xs font-medium rounded-full"
|
||||
className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full"
|
||||
>
|
||||
{name.trim()}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -64,10 +64,9 @@ function Members() {
|
|||
|
||||
{/* 정보 */}
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
<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>
|
||||
<h3 className="text-xl font-bold mb-3">{member.name}</h3>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
|
||||
<Calendar size={14} />
|
||||
<span>{formatDate(member.birth_date, 'YYYY.MM.DD')}</span>
|
||||
</div>
|
||||
|
|
@ -123,8 +122,7 @@ function Members() {
|
|||
|
||||
{/* 정보 */}
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
<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>
|
||||
<h3 className="text-xl font-bold mb-3 text-gray-500">{member.name}</h3>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Calendar size={14} />
|
||||
|
|
|
|||
|
|
@ -274,7 +274,7 @@ function Schedule() {
|
|||
category_name: s.category?.name,
|
||||
category_color: s.category?.color,
|
||||
// members 배열 → 쉼표 구분 문자열 (기존 호환)
|
||||
member_names: Array.isArray(s.members) ? s.members.join(',') : s.member_names,
|
||||
member_names: Array.isArray(s.members) ? s.members.map(m => m.name).join(',') : s.member_names,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
|
@ -395,7 +395,11 @@ function Schedule() {
|
|||
return scheduleDateMap.has(dateStr);
|
||||
};
|
||||
|
||||
// 2017년 1월 이전으로 이동 불가
|
||||
const canGoPrevMonth = !(year === 2017 && month === 0);
|
||||
|
||||
const prevMonth = () => {
|
||||
if (!canGoPrevMonth) return;
|
||||
setSlideDirection(-1);
|
||||
const newDate = new Date(year, month - 1, 1);
|
||||
setCurrentDate(newDate);
|
||||
|
|
@ -567,8 +571,8 @@ function Schedule() {
|
|||
return year === now.getFullYear() && m === now.getMonth();
|
||||
};
|
||||
|
||||
// 연도 선택 범위 (2025년부터 시작)
|
||||
const MIN_YEAR = 2025;
|
||||
// 연도 선택 범위
|
||||
const MIN_YEAR = 2017;
|
||||
const [yearRangeStart, setYearRangeStart] = useState(MIN_YEAR);
|
||||
const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i);
|
||||
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
|
||||
|
|
@ -617,7 +621,7 @@ function Schedule() {
|
|||
|
||||
return (
|
||||
<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 py-8 w-full">
|
||||
<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-shrink-0 text-center mb-8">
|
||||
<motion.h1
|
||||
|
|
@ -652,7 +656,8 @@ function Schedule() {
|
|||
<div className="flex items-center justify-between mb-8">
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
|
||||
disabled={!canGoPrevMonth}
|
||||
className={`p-2 rounded-full transition-colors ${canGoPrevMonth ? 'hover:bg-gray-100' : 'opacity-30'}`}
|
||||
>
|
||||
<ChevronLeft size={24} />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { getSchedule } from '../../../api/public/schedules';
|
|||
import {
|
||||
YoutubeSection,
|
||||
XSection,
|
||||
ConcertSection,
|
||||
DefaultSection,
|
||||
CATEGORY_ID,
|
||||
decodeHtmlEntities,
|
||||
|
|
@ -118,27 +117,25 @@ function ScheduleDetail() {
|
|||
}
|
||||
|
||||
// 카테고리별 섹션 렌더링
|
||||
const categoryId = schedule.category?.id;
|
||||
const renderCategorySection = () => {
|
||||
switch (schedule.category_id) {
|
||||
switch (categoryId) {
|
||||
case CATEGORY_ID.YOUTUBE:
|
||||
return <YoutubeSection schedule={schedule} />;
|
||||
case CATEGORY_ID.X:
|
||||
return <XSection schedule={schedule} />;
|
||||
case CATEGORY_ID.CONCERT:
|
||||
return <ConcertSection schedule={schedule} />;
|
||||
default:
|
||||
return <DefaultSection schedule={schedule} />;
|
||||
}
|
||||
};
|
||||
|
||||
const isYoutube = schedule.category_id === CATEGORY_ID.YOUTUBE;
|
||||
const isX = schedule.category_id === CATEGORY_ID.X;
|
||||
const isConcert = schedule.category_id === CATEGORY_ID.CONCERT;
|
||||
const hasCustomLayout = isYoutube || isX || isConcert;
|
||||
const isYoutube = categoryId === CATEGORY_ID.YOUTUBE;
|
||||
const isX = categoryId === CATEGORY_ID.X;
|
||||
const hasCustomLayout = isYoutube || isX;
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-64px)] bg-gray-50">
|
||||
<div className={`${isYoutube ? 'max-w-5xl' : isConcert ? 'max-w-4xl' : 'max-w-3xl'} mx-auto px-6 py-8`}>
|
||||
<div className={`${isYoutube ? 'max-w-5xl' : 'max-w-3xl'} mx-auto px-6 py-8`}>
|
||||
{/* 브레드크럼 네비게이션 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
|
|
@ -151,9 +148,9 @@ function ScheduleDetail() {
|
|||
<ChevronRight size={14} />
|
||||
<span
|
||||
className="hover:text-primary transition-colors"
|
||||
style={{ color: schedule.category_color }}
|
||||
style={{ color: schedule.category?.color }}
|
||||
>
|
||||
{schedule.category_name}
|
||||
{schedule.category?.name}
|
||||
</span>
|
||||
<ChevronRight size={14} />
|
||||
<span className="text-gray-700 font-medium truncate max-w-md">
|
||||
|
|
|
|||
|
|
@ -1,389 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
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,30 +1,63 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { getXProfile } from '../../../../api/public/schedules';
|
||||
import { decodeHtmlEntities, formatXDateTime } from './utils';
|
||||
|
||||
// X URL에서 username 추출
|
||||
const extractXUsername = (url) => {
|
||||
if (!url) return null;
|
||||
const match = url.match(/(?:twitter\.com|x\.com)\/([^/]+)/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
import Linkify from 'react-linkify';
|
||||
import { decodeHtmlEntities } from './utils';
|
||||
import Lightbox from '../../../../components/common/Lightbox';
|
||||
import { formatXDateTime } from '../../../../utils/date';
|
||||
|
||||
// X(트위터) 섹션 컴포넌트
|
||||
function XSection({ schedule }) {
|
||||
const username = extractXUsername(schedule.source?.url);
|
||||
|
||||
// 프로필 정보 조회
|
||||
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 profile = schedule.profile;
|
||||
const username = profile?.username || 'realfromis_9';
|
||||
const displayName = profile?.displayName || username;
|
||||
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 (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* X 스타일 카드 */}
|
||||
|
|
@ -60,9 +93,7 @@ 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"/>
|
||||
</svg>
|
||||
</div>
|
||||
{username && (
|
||||
<span className="text-sm text-gray-500">@{username}</span>
|
||||
)}
|
||||
<span className="text-sm text-gray-500">@{username}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -70,32 +101,55 @@ function XSection({ schedule }) {
|
|||
{/* 본문 */}
|
||||
<div className="p-5">
|
||||
<p className="text-gray-900 text-[17px] leading-relaxed whitespace-pre-wrap">
|
||||
{decodeHtmlEntities(schedule.description || schedule.title)}
|
||||
<Linkify componentDecorator={linkDecorator}>
|
||||
{decodeHtmlEntities(schedule.content || schedule.title)}
|
||||
</Linkify>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 이미지 */}
|
||||
{schedule.image_url && (
|
||||
{schedule.imageUrls?.length > 0 && (
|
||||
<div className="px-5 pb-3">
|
||||
<img
|
||||
src={schedule.image_url}
|
||||
alt=""
|
||||
className="w-full rounded-2xl border border-gray-100"
|
||||
/>
|
||||
{schedule.imageUrls.length === 1 ? (
|
||||
<img
|
||||
src={schedule.imageUrls[0]}
|
||||
alt=""
|
||||
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 className="px-5 py-4 border-t border-gray-100">
|
||||
<span className="text-gray-500 text-[15px]">
|
||||
{formatXDateTime(schedule.date, schedule.time)}
|
||||
{formatXDateTime(schedule.datetime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* X에서 보기 버튼 */}
|
||||
<div className="px-5 py-4 border-t border-gray-100 bg-gray-50/50">
|
||||
<a
|
||||
href={schedule.source?.url}
|
||||
href={schedule.postUrl}
|
||||
target="_blank"
|
||||
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"
|
||||
|
|
@ -107,6 +161,15 @@ function XSection({ schedule }) {
|
|||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 라이트박스 */}
|
||||
<Lightbox
|
||||
images={schedule.imageUrls || []}
|
||||
currentIndex={lightboxIndex}
|
||||
isOpen={lightboxOpen}
|
||||
onClose={closeLightbox}
|
||||
onIndexChange={setLightboxIndex}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import { Calendar, Clock, Link2 } from 'lucide-react';
|
||||
import { decodeHtmlEntities, formatFullDate, formatTime } from './utils';
|
||||
import { decodeHtmlEntities } from './utils';
|
||||
import { formatXDateTime } from '../../../../utils/date';
|
||||
|
||||
// 영상 정보 컴포넌트 (공통)
|
||||
function VideoInfo({ schedule, isShorts }) {
|
||||
|
|
@ -8,7 +9,7 @@ function VideoInfo({ schedule, isShorts }) {
|
|||
const isFullGroup = members.length === 5;
|
||||
|
||||
return (
|
||||
<div className={`bg-gradient-to-br from-gray-50 to-gray-100/50 rounded-2xl p-6 ${isShorts ? 'flex-1' : ''}`}>
|
||||
<div className={`bg-gradient-to-br from-gray-100 to-gray-200/80 rounded-2xl p-6 ${isShorts ? 'flex-1' : ''}`}>
|
||||
{/* 제목 */}
|
||||
<h1 className={`font-bold text-gray-900 mb-4 leading-relaxed ${isShorts ? 'text-lg' : 'text-xl'}`}>
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
|
|
@ -16,30 +17,19 @@ function VideoInfo({ schedule, isShorts }) {
|
|||
|
||||
{/* 메타 정보 */}
|
||||
<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">
|
||||
<Calendar size={14} />
|
||||
<span>{formatFullDate(schedule.date)}</span>
|
||||
<span>{formatXDateTime(schedule.datetime)}</span>
|
||||
</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.source?.name && (
|
||||
{schedule.channelName && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-gray-300" />
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Link2 size={14} className="opacity-60" />
|
||||
<span className="font-medium">{schedule.source.name}</span>
|
||||
<span className="font-medium">{schedule.channelName}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -66,9 +56,9 @@ function VideoInfo({ schedule, isShorts }) {
|
|||
)}
|
||||
|
||||
{/* 유튜브에서 보기 버튼 */}
|
||||
<div className="mt-6 pt-5 border-t border-gray-200">
|
||||
<div className="mt-6 pt-5 border-t border-gray-300/60">
|
||||
<a
|
||||
href={schedule.source?.url}
|
||||
href={schedule.videoUrl}
|
||||
target="_blank"
|
||||
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"
|
||||
|
|
@ -83,22 +73,10 @@ 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 }) {
|
||||
const videoId = extractYoutubeVideoId(schedule.source?.url);
|
||||
const isShorts = schedule.source?.url?.includes('/shorts/');
|
||||
const videoId = schedule.videoId;
|
||||
const isShorts = schedule.videoType === 'shorts';
|
||||
|
||||
if (!videoId) return null;
|
||||
|
||||
|
|
@ -113,7 +91,7 @@ function YoutubeSection({ schedule }) {
|
|||
transition={{ delay: 0.1 }}
|
||||
className="w-[420px] flex-shrink-0"
|
||||
>
|
||||
<div className="relative aspect-[9/16] bg-gray-900 rounded-2xl overflow-hidden shadow-2xl shadow-black/20">
|
||||
<div className="relative aspect-[9/16] bg-gray-900 rounded-2xl overflow-hidden shadow-lg shadow-black/10">
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${videoId}?rel=0`}
|
||||
title={schedule.title}
|
||||
|
|
@ -147,7 +125,7 @@ function YoutubeSection({ schedule }) {
|
|||
transition={{ delay: 0.1 }}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="relative aspect-video bg-gray-900 rounded-2xl overflow-hidden shadow-2xl shadow-black/20">
|
||||
<div className="relative aspect-video bg-gray-900 rounded-2xl overflow-hidden shadow-lg shadow-black/10">
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${videoId}?rel=0`}
|
||||
title={schedule.title}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
export { default as YoutubeSection } from "./YoutubeSection";
|
||||
export { default as XSection } from "./XSection";
|
||||
export { default as ConcertSection } from "./ConcertSection";
|
||||
export { default as DefaultSection } from "./DefaultSection";
|
||||
export { default as KakaoMap } from "./KakaoMap";
|
||||
export * from "./utils";
|
||||
|
|
|
|||
|
|
@ -49,8 +49,4 @@ export const formatXDateTime = (dateStr, timeStr) => {
|
|||
export const CATEGORY_ID = {
|
||||
YOUTUBE: 2,
|
||||
X: 3,
|
||||
ALBUM: 4,
|
||||
FANSIGN: 5,
|
||||
CONCERT: 6,
|
||||
TICKET: 7,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -77,5 +77,30 @@ export const isToday = (date) => {
|
|||
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 (고급 사용용)
|
||||
export { dayjs };
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue