Compare commits
17 commits
841c3c8626
...
2d469739b7
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d469739b7 | |||
| 1a9fa54981 | |||
| 2576a244c0 | |||
| 84ed48fa78 | |||
| bc3f536ec7 | |||
| 0a73149849 | |||
| c7b0a51924 | |||
| 149e85ebd9 | |||
| c5f5639b11 | |||
| d7d0506b83 | |||
| e0f328803e | |||
| a9bdbd1ec2 | |||
| 7e9e51666a | |||
| 8a8af275a9 | |||
| b824c38815 | |||
| 72db9dcdc1 | |||
| 108265b1fd |
33 changed files with 1869 additions and 195 deletions
|
|
@ -29,3 +29,7 @@ DB 및 외부 서비스 접근 정보는 `.env` 파일 참조:
|
||||||
- [docs/architecture.md](docs/architecture.md) - 프로젝트 구조
|
- [docs/architecture.md](docs/architecture.md) - 프로젝트 구조
|
||||||
- [docs/api.md](docs/api.md) - API 명세
|
- [docs/api.md](docs/api.md) - API 명세
|
||||||
- [docs/development.md](docs/development.md) - 개발/배포 가이드
|
- [docs/development.md](docs/development.md) - 개발/배포 가이드
|
||||||
|
|
||||||
|
## 작업 시 주의사항
|
||||||
|
|
||||||
|
- **문서 업데이트 필수**: 작업이 완료되면 항상 `docs/` 폴더의 관련 문서를 업데이트할 것
|
||||||
|
|
|
||||||
27
Dockerfile
27
Dockerfile
|
|
@ -1,27 +0,0 @@
|
||||||
# ============================================
|
|
||||||
# 개발 모드
|
|
||||||
# ============================================
|
|
||||||
FROM node:20-alpine
|
|
||||||
WORKDIR /app
|
|
||||||
RUN apk add --no-cache ffmpeg
|
|
||||||
CMD ["sh", "-c", "cd /app/backend && npm install && cd /app/frontend && npm install --include=dev && (cd /app/backend && PORT=3000 npm run dev &) && sleep 3 && cd /app/frontend && npm run dev -- --host 0.0.0.0"]
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# 배포 모드 (사용 시 위 개발 모드를 주석처리)
|
|
||||||
# ============================================
|
|
||||||
# FROM node:20-alpine AS frontend-builder
|
|
||||||
# WORKDIR /frontend
|
|
||||||
# COPY frontend/package*.json ./
|
|
||||||
# RUN npm install
|
|
||||||
# COPY frontend/ ./
|
|
||||||
# RUN npm run build
|
|
||||||
#
|
|
||||||
# FROM node:20-alpine
|
|
||||||
# WORKDIR /app
|
|
||||||
# RUN apk add --no-cache ffmpeg
|
|
||||||
# COPY backend/package*.json ./
|
|
||||||
# RUN npm install --production
|
|
||||||
# COPY backend/ ./
|
|
||||||
# COPY --from=frontend-builder /frontend/dist ./dist
|
|
||||||
# EXPOSE 80
|
|
||||||
# CMD ["npm", "start"]
|
|
||||||
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# 개발 모드
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache ffmpeg
|
||||||
|
CMD ["sh", "-c", "npm install && npm run dev"]
|
||||||
|
|
||||||
|
# 배포 모드 (사용 시 위 개발 모드를 주석처리)
|
||||||
|
# FROM node:20-alpine
|
||||||
|
# WORKDIR /app
|
||||||
|
# RUN apk add --no-cache ffmpeg
|
||||||
|
# COPY package*.json ./
|
||||||
|
# RUN npm install --production
|
||||||
|
# COPY . .
|
||||||
|
# EXPOSE 3000
|
||||||
|
# CMD ["npm", "start"]
|
||||||
10
backend/sql/x_profiles.sql
Normal file
10
backend/sql/x_profiles.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- X 프로필 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS x_profiles (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
display_name VARCHAR(100),
|
||||||
|
avatar_url TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_username (username)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
@ -72,13 +72,17 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
try {
|
try {
|
||||||
const result = await syncFn(bot);
|
const result = await syncFn(bot);
|
||||||
const status = await getStatus(botId);
|
const status = await getStatus(botId);
|
||||||
await updateStatus(botId, {
|
const updateData = {
|
||||||
status: 'running',
|
status: 'running',
|
||||||
lastCheckAt: new Date().toISOString(),
|
lastCheckAt: new Date().toISOString(),
|
||||||
lastAddedCount: result.addedCount,
|
|
||||||
totalAdded: (status.totalAdded || 0) + result.addedCount,
|
totalAdded: (status.totalAdded || 0) + result.addedCount,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
});
|
};
|
||||||
|
// 실제로 추가된 경우에만 lastAddedCount 업데이트
|
||||||
|
if (result.addedCount > 0) {
|
||||||
|
updateData.lastAddedCount = result.addedCount;
|
||||||
|
}
|
||||||
|
await updateStatus(botId, updateData);
|
||||||
fastify.log.info(`[${botId}] 동기화 완료: ${result.addedCount}개 추가`);
|
fastify.log.info(`[${botId}] 동기화 완료: ${result.addedCount}개 추가`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await updateStatus(botId, {
|
await updateStatus(botId, {
|
||||||
|
|
@ -98,11 +102,15 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
try {
|
try {
|
||||||
const result = await syncFn(bot);
|
const result = await syncFn(bot);
|
||||||
const status = await getStatus(botId);
|
const status = await getStatus(botId);
|
||||||
await updateStatus(botId, {
|
const updateData = {
|
||||||
lastCheckAt: new Date().toISOString(),
|
lastCheckAt: new Date().toISOString(),
|
||||||
lastAddedCount: result.addedCount,
|
|
||||||
totalAdded: (status.totalAdded || 0) + result.addedCount,
|
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}개 추가`);
|
fastify.log.info(`[${botId}] 초기 동기화 완료: ${result.addedCount}개 추가`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(`[${botId}] 초기 동기화 오류: ${err.message}`);
|
fastify.log.error(`[${botId}] 초기 동기화 오류: ${err.message}`);
|
||||||
|
|
|
||||||
136
backend/src/routes/admin/x.js
Normal file
136
backend/src/routes/admin/x.js
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
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';
|
||||||
|
|
||||||
|
const X_CATEGORY_ID = 3;
|
||||||
|
const NITTER_URL = config.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080';
|
||||||
|
const DEFAULT_USERNAME = 'realfromis_9';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X(Twitter) 관련 관리자 라우트
|
||||||
|
*/
|
||||||
|
export default async function xRoutes(fastify) {
|
||||||
|
const { db, meilisearch } = fastify;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/x/post-info
|
||||||
|
* X 게시글 정보 조회
|
||||||
|
*/
|
||||||
|
fastify.get('/post-info', {
|
||||||
|
schema: {
|
||||||
|
tags: ['admin/x'],
|
||||||
|
summary: 'X 게시글 정보 조회',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
querystring: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
postId: { type: 'string', description: '게시글 ID' },
|
||||||
|
username: { type: 'string', description: '사용자명 (기본: realfromis_9)' },
|
||||||
|
},
|
||||||
|
required: ['postId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { postId, username = DEFAULT_USERNAME } = request.query;
|
||||||
|
|
||||||
|
// 게시글 ID 유효성 검사
|
||||||
|
if (!/^\d+$/.test(postId)) {
|
||||||
|
return reply.code(400).send({ error: '유효하지 않은 게시글 ID입니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tweet = await fetchSingleTweet(NITTER_URL, username, postId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
postId: tweet.id,
|
||||||
|
username,
|
||||||
|
text: tweet.text,
|
||||||
|
title: extractTitle(tweet.text),
|
||||||
|
imageUrls: tweet.imageUrls,
|
||||||
|
date: tweet.time ? formatDate(tweet.time) : null,
|
||||||
|
time: tweet.time ? formatTime(tweet.time) : null,
|
||||||
|
postUrl: tweet.url,
|
||||||
|
profile: tweet.profile,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(`X 게시글 조회 오류: ${err.message}`);
|
||||||
|
return reply.code(500).send({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/x/schedule
|
||||||
|
* X 일정 저장
|
||||||
|
*/
|
||||||
|
fastify.post('/schedule', {
|
||||||
|
schema: {
|
||||||
|
tags: ['admin/x'],
|
||||||
|
summary: 'X 일정 저장',
|
||||||
|
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' },
|
||||||
|
},
|
||||||
|
required: ['postId', 'title', 'date'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { postId, title, content, imageUrls, date, time } = request.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 중복 체크
|
||||||
|
const [existing] = await db.query(
|
||||||
|
'SELECT id FROM schedule_x WHERE post_id = ?',
|
||||||
|
[postId]
|
||||||
|
);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return reply.code(409).send({ error: '이미 등록된 게시글입니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// schedules 테이블에 저장
|
||||||
|
const [result] = await db.query(
|
||||||
|
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
||||||
|
[X_CATEGORY_ID, title, date, time || null]
|
||||||
|
);
|
||||||
|
const scheduleId = result.insertId;
|
||||||
|
|
||||||
|
// schedule_x 테이블에 저장
|
||||||
|
await db.query(
|
||||||
|
'INSERT INTO schedule_x (schedule_id, post_id, content, image_urls) VALUES (?, ?, ?, ?)',
|
||||||
|
[scheduleId, postId, content || null, imageUrls?.length > 0 ? JSON.stringify(imageUrls) : null]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Meilisearch 동기화
|
||||||
|
const [categoryRows] = await db.query(
|
||||||
|
'SELECT name, color FROM schedule_categories WHERE id = ?',
|
||||||
|
[X_CATEGORY_ID]
|
||||||
|
);
|
||||||
|
const category = categoryRows[0] || {};
|
||||||
|
|
||||||
|
await addOrUpdateSchedule(meilisearch, {
|
||||||
|
id: scheduleId,
|
||||||
|
title,
|
||||||
|
date,
|
||||||
|
time: time || '',
|
||||||
|
category_id: X_CATEGORY_ID,
|
||||||
|
category_name: category.name || '',
|
||||||
|
category_color: category.color || '',
|
||||||
|
source_name: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, scheduleId };
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(`X 일정 저장 오류: ${err.message}`);
|
||||||
|
return reply.code(500).send({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
164
backend/src/routes/admin/youtube.js
Normal file
164
backend/src/routes/admin/youtube.js
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { fetchVideoInfo } from '../../services/youtube/api.js';
|
||||||
|
import { addOrUpdateSchedule } from '../../services/meilisearch/index.js';
|
||||||
|
|
||||||
|
const YOUTUBE_CATEGORY_ID = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube 관련 관리자 라우트
|
||||||
|
*/
|
||||||
|
export default async function youtubeRoutes(fastify) {
|
||||||
|
const { db, meilisearch } = fastify;
|
||||||
|
/**
|
||||||
|
* GET /api/admin/youtube/video-info
|
||||||
|
* YouTube 영상 정보 조회
|
||||||
|
*/
|
||||||
|
fastify.get('/video-info', {
|
||||||
|
schema: {
|
||||||
|
tags: ['admin/youtube'],
|
||||||
|
summary: 'YouTube 영상 정보 조회',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
querystring: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
url: { type: 'string', description: 'YouTube URL' },
|
||||||
|
},
|
||||||
|
required: ['url'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { url } = request.query;
|
||||||
|
|
||||||
|
// YouTube URL에서 video ID 추출
|
||||||
|
const videoId = extractVideoId(url);
|
||||||
|
if (!videoId) {
|
||||||
|
return reply.code(400).send({ error: '유효하지 않은 YouTube URL입니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const video = await fetchVideoInfo(videoId);
|
||||||
|
if (!video) {
|
||||||
|
return reply.code(404).send({ error: '영상을 찾을 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
videoId: video.videoId,
|
||||||
|
title: video.title,
|
||||||
|
channelId: video.channelId,
|
||||||
|
channelName: video.channelTitle,
|
||||||
|
publishedAt: video.publishedAt,
|
||||||
|
date: video.date,
|
||||||
|
time: video.time,
|
||||||
|
videoType: video.videoType,
|
||||||
|
videoUrl: video.videoUrl,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(`YouTube 영상 조회 오류: ${err.message}`);
|
||||||
|
return reply.code(500).send({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/youtube/schedule
|
||||||
|
* YouTube 일정 저장
|
||||||
|
*/
|
||||||
|
fastify.post('/schedule', {
|
||||||
|
schema: {
|
||||||
|
tags: ['admin/youtube'],
|
||||||
|
summary: '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' },
|
||||||
|
},
|
||||||
|
required: ['videoId', 'title', 'date'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { videoId, title, channelId, channelName, date, time, videoType } = request.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 중복 체크
|
||||||
|
const [existing] = await db.query(
|
||||||
|
'SELECT id FROM schedule_youtube WHERE video_id = ?',
|
||||||
|
[videoId]
|
||||||
|
);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return reply.code(409).send({ error: '이미 등록된 영상입니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// schedules 테이블에 저장
|
||||||
|
const [result] = await db.query(
|
||||||
|
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
||||||
|
[YOUTUBE_CATEGORY_ID, title, date, time || null]
|
||||||
|
);
|
||||||
|
const scheduleId = result.insertId;
|
||||||
|
|
||||||
|
// schedule_youtube 테이블에 저장
|
||||||
|
await db.query(
|
||||||
|
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[scheduleId, videoId, videoType || 'video', channelId, channelName]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Meilisearch 동기화
|
||||||
|
const [categoryRows] = await db.query(
|
||||||
|
'SELECT name, color FROM schedule_categories WHERE id = ?',
|
||||||
|
[YOUTUBE_CATEGORY_ID]
|
||||||
|
);
|
||||||
|
const category = categoryRows[0] || {};
|
||||||
|
|
||||||
|
await addOrUpdateSchedule(meilisearch, {
|
||||||
|
id: scheduleId,
|
||||||
|
title,
|
||||||
|
date,
|
||||||
|
time: time || '',
|
||||||
|
category_id: YOUTUBE_CATEGORY_ID,
|
||||||
|
category_name: category.name || '',
|
||||||
|
category_color: category.color || '',
|
||||||
|
source_name: channelName || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, scheduleId };
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(`YouTube 일정 저장 오류: ${err.message}`);
|
||||||
|
return reply.code(500).send({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube URL에서 video ID 추출
|
||||||
|
*/
|
||||||
|
function extractVideoId(url) {
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
const patterns = [
|
||||||
|
// https://www.youtube.com/watch?v=VIDEO_ID
|
||||||
|
/(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/,
|
||||||
|
// https://youtu.be/VIDEO_ID
|
||||||
|
/(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/,
|
||||||
|
// https://www.youtube.com/shorts/VIDEO_ID
|
||||||
|
/(?:youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
|
||||||
|
// https://www.youtube.com/embed/VIDEO_ID
|
||||||
|
/(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
|
||||||
|
// https://www.youtube.com/v/VIDEO_ID
|
||||||
|
/(?:youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = url.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@ import albumsRoutes from './albums/index.js';
|
||||||
import schedulesRoutes from './schedules/index.js';
|
import schedulesRoutes from './schedules/index.js';
|
||||||
import statsRoutes from './stats/index.js';
|
import statsRoutes from './stats/index.js';
|
||||||
import botsRoutes from './admin/bots.js';
|
import botsRoutes from './admin/bots.js';
|
||||||
|
import youtubeAdminRoutes from './admin/youtube.js';
|
||||||
|
import xAdminRoutes from './admin/x.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 라우트 통합
|
* 라우트 통합
|
||||||
|
|
@ -27,4 +29,10 @@ export default async function routes(fastify) {
|
||||||
|
|
||||||
// 관리자 - 봇 라우트
|
// 관리자 - 봇 라우트
|
||||||
fastify.register(botsRoutes, { prefix: '/admin/bots' });
|
fastify.register(botsRoutes, { prefix: '/admin/bots' });
|
||||||
|
|
||||||
|
// 관리자 - YouTube 라우트
|
||||||
|
fastify.register(youtubeAdminRoutes, { prefix: '/admin/youtube' });
|
||||||
|
|
||||||
|
// 관리자 - X 라우트
|
||||||
|
fastify.register(xAdminRoutes, { prefix: '/admin/x' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,22 @@ export default async function schedulesRoutes(fastify) {
|
||||||
// 추천 검색어 라우트 등록
|
// 추천 검색어 라우트 등록
|
||||||
fastify.register(suggestionsRoutes, { prefix: '/suggestions' });
|
fastify.register(suggestionsRoutes, { prefix: '/suggestions' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/schedules/categories
|
||||||
|
* 카테고리 목록 조회
|
||||||
|
*/
|
||||||
|
fastify.get('/categories', {
|
||||||
|
schema: {
|
||||||
|
tags: ['schedules'],
|
||||||
|
summary: '카테고리 목록 조회',
|
||||||
|
},
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const [categories] = await db.query(
|
||||||
|
'SELECT id, name, color, sort_order FROM schedule_categories ORDER BY sort_order ASC, id ASC'
|
||||||
|
);
|
||||||
|
return categories;
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/schedules
|
* GET /api/schedules
|
||||||
* 검색 모드: search 파라미터가 있으면 Meilisearch 검색
|
* 검색 모드: search 파라미터가 있으면 Meilisearch 검색
|
||||||
|
|
@ -121,13 +137,53 @@ export default async function schedulesRoutes(fastify) {
|
||||||
};
|
};
|
||||||
} else if (s.category_id === 3 && s.x_post_id) {
|
} else if (s.category_id === 3 && s.x_post_id) {
|
||||||
result.source = {
|
result.source = {
|
||||||
name: 'X',
|
name: '',
|
||||||
url: `https://x.com/realfromis_9/status/${s.x_post_id}`,
|
url: `https://x.com/realfromis_9/status/${s.x_post_id}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/schedules/:id
|
||||||
|
* 일정 삭제 (인증 필요)
|
||||||
|
*/
|
||||||
|
fastify.delete('/:id', {
|
||||||
|
schema: {
|
||||||
|
tags: ['schedules'],
|
||||||
|
summary: '일정 삭제',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
|
||||||
|
// 일정 존재 확인
|
||||||
|
const [existing] = await db.query('SELECT id FROM schedules WHERE id = ?', [id]);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return reply.code(404).send({ error: '일정을 찾을 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관련 테이블 삭제 (외래 키)
|
||||||
|
await db.query('DELETE FROM schedule_youtube WHERE schedule_id = ?', [id]);
|
||||||
|
await db.query('DELETE FROM schedule_x WHERE schedule_id = ?', [id]);
|
||||||
|
await db.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]);
|
||||||
|
await db.query('DELETE FROM schedule_images WHERE schedule_id = ?', [id]);
|
||||||
|
|
||||||
|
// 메인 테이블 삭제
|
||||||
|
await db.query('DELETE FROM schedules WHERE id = ?', [id]);
|
||||||
|
|
||||||
|
// Meilisearch에서도 삭제
|
||||||
|
try {
|
||||||
|
const { deleteSchedule } = await import('../../services/meilisearch/index.js');
|
||||||
|
await deleteSchedule(meilisearch, id);
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(`Meilisearch 삭제 오류: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -247,7 +303,7 @@ async function handleMonthlySchedules(db, year, month) {
|
||||||
};
|
};
|
||||||
} else if (s.category_id === 3 && s.x_post_id) {
|
} else if (s.category_id === 3 && s.x_post_id) {
|
||||||
schedule.source = {
|
schedule.source = {
|
||||||
name: 'X',
|
name: '',
|
||||||
url: `https://x.com/realfromis_9/status/${s.x_post_id}`,
|
url: `https://x.com/realfromis_9/status/${s.x_post_id}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -271,6 +327,11 @@ async function handleMonthlySchedules(db, year, month) {
|
||||||
// 생일 일정 추가
|
// 생일 일정 추가
|
||||||
for (const member of birthdays) {
|
for (const member of birthdays) {
|
||||||
const birthDate = new Date(member.birth_date);
|
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 birthdayThisYear = new Date(year, birthDate.getMonth(), birthDate.getDate());
|
||||||
const dateKey = birthdayThisYear.toISOString().split('T')[0];
|
const dateKey = birthdayThisYear.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,16 @@ function formatScheduleResponse(hit) {
|
||||||
? hit.member_names.split(',').map(name => name.trim()).filter(Boolean)
|
? hit.member_names.split(',').map(name => name.trim()).filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
// source 객체 구성 (X는 name 비움)
|
||||||
|
let source = null;
|
||||||
|
if (hit.category_id === 2 && hit.source_name) {
|
||||||
|
// YouTube
|
||||||
|
source = { name: hit.source_name, url: null };
|
||||||
|
} else if (hit.category_id === 3) {
|
||||||
|
// X (name 비움)
|
||||||
|
source = { name: '', url: null };
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: hit.id,
|
id: hit.id,
|
||||||
title: hit.title,
|
title: hit.title,
|
||||||
|
|
@ -146,7 +156,7 @@ function formatScheduleResponse(hit) {
|
||||||
name: hit.category_name,
|
name: hit.category_name,
|
||||||
color: hit.category_color,
|
color: hit.category_color,
|
||||||
},
|
},
|
||||||
source_name: hit.source_name || null,
|
source,
|
||||||
members,
|
members,
|
||||||
_rankingScore: hit._rankingScore,
|
_rankingScore: hit._rankingScore,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ const inko = new Inko();
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
// 추천 검색어 최소 검색 횟수 비율 (최대 대비)
|
// 추천 검색어 최소 검색 횟수 비율 (최대 대비)
|
||||||
MIN_COUNT_RATIO: 0.01,
|
MIN_COUNT_RATIO: 0.01,
|
||||||
// 최소 임계값 (데이터 적을 때)
|
// 최소 임계값 (데이터 적을 때 오타 방지)
|
||||||
MIN_COUNT_FLOOR: 5,
|
MIN_COUNT_FLOOR: 10,
|
||||||
// Redis 키 prefix
|
// Redis 키 prefix
|
||||||
REDIS_PREFIX: 'suggest:',
|
REDIS_PREFIX: 'suggest:',
|
||||||
// 캐시 TTL (초)
|
// 캐시 TTL (초)
|
||||||
|
|
@ -24,7 +24,6 @@ const CONFIG = {
|
||||||
PREFIX: 3600, // prefix 검색: 1시간
|
PREFIX: 3600, // prefix 검색: 1시간
|
||||||
BIGRAM: 86400, // bi-gram: 24시간
|
BIGRAM: 86400, // bi-gram: 24시간
|
||||||
POPULAR: 600, // 인기 검색어: 10분
|
POPULAR: 600, // 인기 검색어: 10분
|
||||||
MAX_COUNT: 3600, // 최대 횟수: 1시간
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -208,6 +207,7 @@ export class SuggestionService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prefix 매칭
|
* Prefix 매칭
|
||||||
|
* - GREATEST()로 동적 임계값 적용: MAX(count) * 1% 또는 최소 10 중 더 큰 값
|
||||||
*/
|
*/
|
||||||
async getPrefixSuggestions(prefix, koreanPrefix, limit) {
|
async getPrefixSuggestions(prefix, koreanPrefix, limit) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -216,19 +216,21 @@ export class SuggestionService {
|
||||||
if (koreanPrefix) {
|
if (koreanPrefix) {
|
||||||
// 영어 + 한글 변환 둘 다 검색
|
// 영어 + 한글 변환 둘 다 검색
|
||||||
[rows] = await this.db.query(
|
[rows] = await this.db.query(
|
||||||
`SELECT query, count FROM suggestion_queries
|
`SELECT query FROM suggestion_queries
|
||||||
WHERE query LIKE ? OR query LIKE ?
|
WHERE (query LIKE ? OR query LIKE ?)
|
||||||
|
AND count >= GREATEST((SELECT MAX(count) * ? FROM suggestion_queries), ?)
|
||||||
ORDER BY count DESC, last_searched_at DESC
|
ORDER BY count DESC, last_searched_at DESC
|
||||||
LIMIT ?`,
|
LIMIT ?`,
|
||||||
[`${prefix}%`, `${koreanPrefix}%`, limit]
|
[`${prefix}%`, `${koreanPrefix}%`, CONFIG.MIN_COUNT_RATIO, CONFIG.MIN_COUNT_FLOOR, limit]
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
[rows] = await this.db.query(
|
[rows] = await this.db.query(
|
||||||
`SELECT query, count FROM suggestion_queries
|
`SELECT query FROM suggestion_queries
|
||||||
WHERE query LIKE ?
|
WHERE query LIKE ?
|
||||||
|
AND count >= GREATEST((SELECT MAX(count) * ? FROM suggestion_queries), ?)
|
||||||
ORDER BY count DESC, last_searched_at DESC
|
ORDER BY count DESC, last_searched_at DESC
|
||||||
LIMIT ?`,
|
LIMIT ?`,
|
||||||
[`${prefix}%`, limit]
|
[`${prefix}%`, CONFIG.MIN_COUNT_RATIO, CONFIG.MIN_COUNT_FLOOR, limit]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,15 +243,17 @@ export class SuggestionService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 초성 검색
|
* 초성 검색
|
||||||
|
* - GREATEST()로 동적 임계값 적용
|
||||||
*/
|
*/
|
||||||
async getChosungSuggestions(chosung, limit) {
|
async getChosungSuggestions(chosung, limit) {
|
||||||
try {
|
try {
|
||||||
const [rows] = await this.db.query(
|
const [rows] = await this.db.query(
|
||||||
`SELECT word, count FROM suggestion_chosung
|
`SELECT word FROM suggestion_chosung
|
||||||
WHERE chosung LIKE ?
|
WHERE chosung LIKE ?
|
||||||
|
AND count >= GREATEST((SELECT MAX(count) * ? FROM suggestion_chosung), ?)
|
||||||
ORDER BY count DESC
|
ORDER BY count DESC
|
||||||
LIMIT ?`,
|
LIMIT ?`,
|
||||||
[`${chosung}%`, limit]
|
[`${chosung}%`, CONFIG.MIN_COUNT_RATIO, CONFIG.MIN_COUNT_FLOOR, limit]
|
||||||
);
|
);
|
||||||
|
|
||||||
return rows.map(r => r.word);
|
return rows.map(r => r.word);
|
||||||
|
|
@ -261,6 +265,7 @@ export class SuggestionService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 인기 검색어 조회
|
* 인기 검색어 조회
|
||||||
|
* - GREATEST()로 동적 임계값 적용
|
||||||
*/
|
*/
|
||||||
async getPopularQueries(limit = 10) {
|
async getPopularQueries(limit = 10) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -272,12 +277,13 @@ export class SuggestionService {
|
||||||
return JSON.parse(cached);
|
return JSON.parse(cached);
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB 조회
|
// DB 조회 (동적 임계값 이상만)
|
||||||
const [rows] = await this.db.query(
|
const [rows] = await this.db.query(
|
||||||
`SELECT query, count FROM suggestion_queries
|
`SELECT query FROM suggestion_queries
|
||||||
|
WHERE count >= GREATEST((SELECT MAX(count) * ? FROM suggestion_queries), ?)
|
||||||
ORDER BY count DESC
|
ORDER BY count DESC
|
||||||
LIMIT ?`,
|
LIMIT ?`,
|
||||||
[limit]
|
[CONFIG.MIN_COUNT_RATIO, CONFIG.MIN_COUNT_FLOOR, limit]
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = rows.map(r => r.query);
|
const result = rows.map(r => r.query);
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,22 @@ async function xBotPlugin(fastify, opts) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* X 프로필 캐시 저장
|
* X 프로필 저장 (DB + Redis 캐시)
|
||||||
*/
|
*/
|
||||||
async function cacheProfile(username, profile) {
|
async function saveProfile(username, profile) {
|
||||||
if (!profile.displayName && !profile.avatarUrl) return;
|
if (!profile.displayName && !profile.avatarUrl) return;
|
||||||
|
|
||||||
|
// DB에 저장 (upsert)
|
||||||
|
await fastify.db.query(`
|
||||||
|
INSERT INTO x_profiles (username, display_name, avatar_url)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
display_name = VALUES(display_name),
|
||||||
|
avatar_url = VALUES(avatar_url),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`, [username, profile.displayName, profile.avatarUrl]);
|
||||||
|
|
||||||
|
// Redis 캐시에도 저장
|
||||||
const data = {
|
const data = {
|
||||||
username,
|
username,
|
||||||
displayName: profile.displayName,
|
displayName: profile.displayName,
|
||||||
|
|
@ -139,8 +150,8 @@ async function xBotPlugin(fastify, opts) {
|
||||||
async function syncNewTweets(bot) {
|
async function syncNewTweets(bot) {
|
||||||
const { tweets, profile } = await fetchTweets(bot.nitterUrl, bot.username);
|
const { tweets, profile } = await fetchTweets(bot.nitterUrl, bot.username);
|
||||||
|
|
||||||
// 프로필 캐시 업데이트
|
// 프로필 저장 (DB + 캐시)
|
||||||
await cacheProfile(bot.username, profile);
|
await saveProfile(bot.username, profile);
|
||||||
|
|
||||||
let addedCount = 0;
|
let addedCount = 0;
|
||||||
let ytAddedCount = 0;
|
let ytAddedCount = 0;
|
||||||
|
|
@ -178,11 +189,39 @@ async function xBotPlugin(fastify, opts) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* X 프로필 조회
|
* X 프로필 조회 (Redis 캐시 → DB)
|
||||||
*/
|
*/
|
||||||
async function getProfile(username) {
|
async function getProfile(username) {
|
||||||
const data = await fastify.redis.get(`${PROFILE_CACHE_PREFIX}${username}`);
|
// Redis 캐시 확인
|
||||||
return data ? JSON.parse(data) : null;
|
const cached = await fastify.redis.get(`${PROFILE_CACHE_PREFIX}${username}`);
|
||||||
|
if (cached) {
|
||||||
|
return JSON.parse(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB에서 조회
|
||||||
|
const [rows] = await fastify.db.query(
|
||||||
|
'SELECT username, display_name, avatar_url, updated_at FROM x_profiles WHERE username = ?',
|
||||||
|
[username]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
const row = rows[0];
|
||||||
|
const data = {
|
||||||
|
username: row.username,
|
||||||
|
displayName: row.display_name,
|
||||||
|
avatarUrl: row.avatar_url,
|
||||||
|
updatedAt: row.updated_at?.toISOString(),
|
||||||
|
};
|
||||||
|
// Redis 캐시에 저장
|
||||||
|
await fastify.redis.setex(
|
||||||
|
`${PROFILE_CACHE_PREFIX}${username}`,
|
||||||
|
PROFILE_TTL,
|
||||||
|
JSON.stringify(data)
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
fastify.decorate('xBot', {
|
fastify.decorate('xBot', {
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,58 @@ export function parseTweets(html, username) {
|
||||||
return tweets;
|
return tweets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nitter에서 단일 트윗 조회
|
||||||
|
*/
|
||||||
|
export async function fetchSingleTweet(nitterUrl, username, postId) {
|
||||||
|
const url = `${nitterUrl}/${username}/status/${postId}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`트윗을 찾을 수 없습니다 (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await res.text();
|
||||||
|
|
||||||
|
// 메인 트윗 파싱 (main-tweet ~ replies 사이)
|
||||||
|
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 timeMatch = container.match(/<span class="tweet-date"[^>]*><a[^>]*title="([^"]+)"/);
|
||||||
|
const time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null;
|
||||||
|
|
||||||
|
// 텍스트
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미지
|
||||||
|
const imageUrls = extractImageUrls(container);
|
||||||
|
|
||||||
|
// 프로필 정보
|
||||||
|
const profile = extractProfile(html);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: postId,
|
||||||
|
time,
|
||||||
|
text,
|
||||||
|
imageUrls,
|
||||||
|
url: `https://x.com/${username}/status/${postId}`,
|
||||||
|
profile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nitter에서 트윗 수집 (첫 페이지만)
|
* Nitter에서 트윗 수집 (첫 페이지만)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ function getVideoUrl(videoId, isShorts) {
|
||||||
/**
|
/**
|
||||||
* 채널의 업로드 플레이리스트 ID 조회
|
* 채널의 업로드 플레이리스트 ID 조회
|
||||||
*/
|
*/
|
||||||
async function getUploadsPlaylistId(channelId) {
|
export async function getUploadsPlaylistId(channelId) {
|
||||||
const url = `${API_BASE}/channels?part=contentDetails&id=${channelId}&key=${API_KEY}`;
|
const url = `${API_BASE}/channels?part=contentDetails&id=${channelId}&key=${API_KEY}`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
@ -64,9 +64,12 @@ async function getVideoDurations(videoIds) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 최근 N개 영상 조회
|
* 최근 N개 영상 조회
|
||||||
|
* @param {string} channelId - 채널 ID
|
||||||
|
* @param {number} maxResults - 최대 결과 수
|
||||||
|
* @param {string} uploadsPlaylistId - 캐싱된 uploads playlist ID (선택)
|
||||||
*/
|
*/
|
||||||
export async function fetchRecentVideos(channelId, maxResults = 10) {
|
export async function fetchRecentVideos(channelId, maxResults = 10, uploadsPlaylistId = null) {
|
||||||
const uploadsId = await getUploadsPlaylistId(channelId);
|
const uploadsId = uploadsPlaylistId || await getUploadsPlaylistId(channelId);
|
||||||
|
|
||||||
const url = `${API_BASE}/playlistItems?part=snippet&playlistId=${uploadsId}&maxResults=${maxResults}&key=${API_KEY}`;
|
const url = `${API_BASE}/playlistItems?part=snippet&playlistId=${uploadsId}&maxResults=${maxResults}&key=${API_KEY}`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
|
|
@ -102,9 +105,11 @@ export async function fetchRecentVideos(channelId, maxResults = 10) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 전체 영상 조회 (페이지네이션)
|
* 전체 영상 조회 (페이지네이션)
|
||||||
|
* @param {string} channelId - 채널 ID
|
||||||
|
* @param {string} uploadsPlaylistId - 캐싱된 uploads playlist ID (선택)
|
||||||
*/
|
*/
|
||||||
export async function fetchAllVideos(channelId) {
|
export async function fetchAllVideos(channelId, uploadsPlaylistId = null) {
|
||||||
const uploadsId = await getUploadsPlaylistId(channelId);
|
const uploadsId = uploadsPlaylistId || await getUploadsPlaylistId(channelId);
|
||||||
const videos = [];
|
const videos = [];
|
||||||
let pageToken = '';
|
let pageToken = '';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,30 @@
|
||||||
import fp from 'fastify-plugin';
|
import fp from 'fastify-plugin';
|
||||||
import { fetchRecentVideos, fetchAllVideos } from './api.js';
|
import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.js';
|
||||||
import bots from '../../config/bots.js';
|
import bots from '../../config/bots.js';
|
||||||
|
|
||||||
const YOUTUBE_CATEGORY_ID = 2;
|
const YOUTUBE_CATEGORY_ID = 2;
|
||||||
|
const PLAYLIST_CACHE_PREFIX = 'yt_uploads:';
|
||||||
|
|
||||||
async function youtubeBotPlugin(fastify, opts) {
|
async function youtubeBotPlugin(fastify, opts) {
|
||||||
|
/**
|
||||||
|
* uploads playlist ID 조회 (Redis 캐싱)
|
||||||
|
*/
|
||||||
|
async function getCachedUploadsPlaylistId(channelId) {
|
||||||
|
const cacheKey = `${PLAYLIST_CACHE_PREFIX}${channelId}`;
|
||||||
|
|
||||||
|
// Redis 캐시 확인
|
||||||
|
const cached = await fastify.redis.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 호출 후 캐싱 (영구 저장 - 값이 변하지 않음)
|
||||||
|
const playlistId = await getUploadsPlaylistId(channelId);
|
||||||
|
await fastify.redis.set(cacheKey, playlistId);
|
||||||
|
|
||||||
|
return playlistId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 멤버 이름 맵 조회
|
* 멤버 이름 맵 조회
|
||||||
*/
|
*/
|
||||||
|
|
@ -89,7 +109,8 @@ async function youtubeBotPlugin(fastify, opts) {
|
||||||
* 최근 영상 동기화 (정기 실행)
|
* 최근 영상 동기화 (정기 실행)
|
||||||
*/
|
*/
|
||||||
async function syncNewVideos(bot) {
|
async function syncNewVideos(bot) {
|
||||||
const videos = await fetchRecentVideos(bot.channelId, 10);
|
const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId);
|
||||||
|
const videos = await fetchRecentVideos(bot.channelId, 10, uploadsPlaylistId);
|
||||||
let addedCount = 0;
|
let addedCount = 0;
|
||||||
|
|
||||||
for (const video of videos) {
|
for (const video of videos) {
|
||||||
|
|
@ -106,7 +127,8 @@ async function youtubeBotPlugin(fastify, opts) {
|
||||||
* 전체 영상 동기화 (초기화)
|
* 전체 영상 동기화 (초기화)
|
||||||
*/
|
*/
|
||||||
async function syncAllVideos(bot) {
|
async function syncAllVideos(bot) {
|
||||||
const videos = await fetchAllVideos(bot.channelId);
|
const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId);
|
||||||
|
const videos = await fetchAllVideos(bot.channelId, uploadsPlaylistId);
|
||||||
let addedCount = 0;
|
let addedCount = 0;
|
||||||
|
|
||||||
for (const video of videos) {
|
for (const video of videos) {
|
||||||
|
|
@ -137,5 +159,5 @@ async function youtubeBotPlugin(fastify, opts) {
|
||||||
|
|
||||||
export default fp(youtubeBotPlugin, {
|
export default fp(youtubeBotPlugin, {
|
||||||
name: 'youtubeBot',
|
name: 'youtubeBot',
|
||||||
dependencies: ['db'],
|
dependencies: ['db', 'redis'],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,26 @@
|
||||||
services:
|
services:
|
||||||
fromis9-frontend:
|
fromis9-frontend:
|
||||||
build: .
|
build: ./frontend
|
||||||
container_name: fromis9-frontend
|
container_name: fromis9-frontend
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
depends_on:
|
||||||
|
- fromis9-backend
|
||||||
|
networks:
|
||||||
|
- app
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
fromis9-backend:
|
||||||
|
build: ./backend
|
||||||
|
container_name: fromis9-backend
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=false"
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
# 개발 모드
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app/backend
|
- ./backend:/app
|
||||||
- ./frontend:/app/frontend
|
|
||||||
- backend_modules:/app/backend/node_modules
|
|
||||||
- frontend_modules:/app/frontend/node_modules
|
|
||||||
# 배포 모드 (사용 시 위 volumes를 주석처리)
|
|
||||||
# volumes: []
|
|
||||||
networks:
|
networks:
|
||||||
- app
|
- app
|
||||||
- db
|
- db
|
||||||
|
|
@ -39,10 +46,6 @@ services:
|
||||||
- app
|
- app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
|
||||||
backend_modules:
|
|
||||||
frontend_modules:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app:
|
app:
|
||||||
external: true
|
external: true
|
||||||
|
|
|
||||||
157
docs/api.md
157
docs/api.md
|
|
@ -67,7 +67,7 @@ Base URL: `/api`
|
||||||
|
|
||||||
**source 객체 (카테고리별):**
|
**source 객체 (카테고리별):**
|
||||||
- YouTube (category_id=2): `{ name: "채널명", url: "https://www.youtube.com/..." }`
|
- YouTube (category_id=2): `{ name: "채널명", url: "https://www.youtube.com/..." }`
|
||||||
- X (category_id=3): `{ name: "X", url: "https://x.com/realfromis_9/status/..." }`
|
- X (category_id=3): `{ name: "", url: "https://x.com/realfromis_9/status/..." }` (name 빈 문자열)
|
||||||
- 기타 카테고리: source 없음
|
- 기타 카테고리: source 없음
|
||||||
|
|
||||||
**검색 응답:**
|
**검색 응답:**
|
||||||
|
|
@ -91,9 +91,23 @@ Base URL: `/api`
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### GET /schedules/categories
|
||||||
|
카테고리 목록 조회
|
||||||
|
|
||||||
|
**응답:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "id": 1, "name": "기타", "color": "#gray", "sort_order": 0 },
|
||||||
|
{ "id": 2, "name": "유튜브", "color": "#ff0033", "sort_order": 1 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
### GET /schedules/:id
|
### GET /schedules/:id
|
||||||
일정 상세 조회
|
일정 상세 조회
|
||||||
|
|
||||||
|
### DELETE /schedules/:id
|
||||||
|
일정 삭제 (인증 필요)
|
||||||
|
|
||||||
### POST /schedules/sync-search
|
### POST /schedules/sync-search
|
||||||
Meilisearch 전체 동기화 (인증 필요)
|
Meilisearch 전체 동기화 (인증 필요)
|
||||||
|
|
||||||
|
|
@ -117,10 +131,145 @@ Meilisearch 전체 동기화 (인증 필요)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 봇 상태
|
## 관리자 - 봇 관리 (인증 필요)
|
||||||
|
|
||||||
### GET /bots
|
### GET /admin/bots
|
||||||
봇 상태 조회
|
봇 목록 조회
|
||||||
|
|
||||||
|
**응답:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "youtube-fromis9",
|
||||||
|
"name": "fromis_9",
|
||||||
|
"type": "youtube",
|
||||||
|
"status": "running",
|
||||||
|
"last_check_at": "2026-01-18T10:30:00Z",
|
||||||
|
"last_added_count": 2,
|
||||||
|
"schedules_added": 150,
|
||||||
|
"check_interval": 2,
|
||||||
|
"error_message": null,
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /admin/bots/:id/start
|
||||||
|
봇 시작
|
||||||
|
|
||||||
|
### POST /admin/bots/:id/stop
|
||||||
|
봇 정지
|
||||||
|
|
||||||
|
### POST /admin/bots/:id/sync-all
|
||||||
|
전체 동기화 (모든 영상/트윗 수집)
|
||||||
|
|
||||||
|
**응답:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"addedCount": 25,
|
||||||
|
"total": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /admin/bots/quota-warning
|
||||||
|
YouTube API 할당량 경고 조회
|
||||||
|
|
||||||
|
**응답:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"active": true,
|
||||||
|
"message": "YouTube API 할당량 초과",
|
||||||
|
"timestamp": "2026-01-18T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DELETE /admin/bots/quota-warning
|
||||||
|
할당량 경고 해제
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관리자 - YouTube (인증 필요)
|
||||||
|
|
||||||
|
### GET /admin/youtube/video-info
|
||||||
|
YouTube 영상 정보 조회
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `url` - YouTube URL (watch, shorts, youtu.be 모두 지원)
|
||||||
|
|
||||||
|
**응답:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"videoId": "abc123",
|
||||||
|
"title": "영상 제목",
|
||||||
|
"channelId": "UCxxx",
|
||||||
|
"channelName": "채널명",
|
||||||
|
"date": "2026-01-19",
|
||||||
|
"time": "15:00:00",
|
||||||
|
"videoType": "video",
|
||||||
|
"videoUrl": "https://www.youtube.com/watch?v=abc123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /admin/youtube/schedule
|
||||||
|
YouTube 일정 저장
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"videoId": "abc123",
|
||||||
|
"title": "영상 제목",
|
||||||
|
"channelId": "UCxxx",
|
||||||
|
"channelName": "채널명",
|
||||||
|
"date": "2026-01-19",
|
||||||
|
"time": "15:00:00",
|
||||||
|
"videoType": "video"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관리자 - X (인증 필요)
|
||||||
|
|
||||||
|
### GET /admin/x/post-info
|
||||||
|
X 게시글 정보 조회 (Nitter 스크래핑)
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `postId` - 게시글 ID (필수)
|
||||||
|
- `username` - 사용자명 (기본: realfromis_9)
|
||||||
|
|
||||||
|
**응답:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"postId": "1234567890",
|
||||||
|
"username": "realfromis_9",
|
||||||
|
"text": "게시글 전체 내용",
|
||||||
|
"title": "첫 문단 (자동 추출)",
|
||||||
|
"imageUrls": ["https://pbs.twimg.com/media/..."],
|
||||||
|
"date": "2026-01-19",
|
||||||
|
"time": "15:00:00",
|
||||||
|
"postUrl": "https://x.com/realfromis_9/status/1234567890",
|
||||||
|
"profile": {
|
||||||
|
"displayName": "프로미스나인 (fromis_9)",
|
||||||
|
"avatarUrl": "https://..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /admin/x/schedule
|
||||||
|
X 일정 저장
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"postId": "1234567890",
|
||||||
|
"title": "게시글 제목",
|
||||||
|
"content": "게시글 내용",
|
||||||
|
"imageUrls": ["https://..."],
|
||||||
|
"date": "2026-01-19",
|
||||||
|
"time": "15:00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ fromis_9/
|
||||||
│ │ │ └── suggestions/ # 추천 검색어
|
│ │ │ └── suggestions/ # 추천 검색어
|
||||||
│ │ ├── app.js # Fastify 앱 설정
|
│ │ ├── app.js # Fastify 앱 설정
|
||||||
│ │ └── server.js # 진입점
|
│ │ └── server.js # 진입점
|
||||||
|
│ ├── Dockerfile # 백엔드 컨테이너
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
│
|
│
|
||||||
├── backend-backup/ # Express 백엔드 (참조용, 마이그레이션 원본)
|
├── backend-backup/ # Express 백엔드 (참조용, 마이그레이션 원본)
|
||||||
|
|
@ -47,9 +48,9 @@ fromis_9/
|
||||||
│ │ ├── stores/ # Zustand 스토어
|
│ │ ├── stores/ # Zustand 스토어
|
||||||
│ │ └── App.jsx
|
│ │ └── App.jsx
|
||||||
│ ├── vite.config.js
|
│ ├── vite.config.js
|
||||||
|
│ ├── Dockerfile # 프론트엔드 컨테이너
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
│
|
│
|
||||||
├── Dockerfile # 개발/배포 통합 (주석 전환)
|
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
└── .env
|
└── .env
|
||||||
```
|
```
|
||||||
|
|
@ -64,20 +65,24 @@ fromis_9/
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────┐
|
||||||
│ fromis9-frontend (Docker) │
|
│ fromis9-frontend (:80) │
|
||||||
│ ┌─────────────────┐ ┌─────────────────────────────┐ │
|
│ Vite 개발서버 │
|
||||||
│ │ Vite (:80) │───▶│ Fastify (:3000) │ │
|
│ (프록시: /api → backend) │
|
||||||
│ │ 프론트엔드 │ │ 백엔드 API │ │
|
└─────────────────────┬───────────────────────────────────┘
|
||||||
│ └─────────────────┘ └──────────┬──────────────────┘ │
|
│
|
||||||
└─────────────────────────────────────┼───────────────────┘
|
▼
|
||||||
│
|
┌─────────────────────────────────────────────────────────┐
|
||||||
┌────────────────────────────┼────────────────────────────┐
|
│ fromis9-backend (:80) │
|
||||||
│ │ │
|
│ Fastify API │
|
||||||
▼ ▼ ▼
|
└─────────────────────┬───────────────────────────────────┘
|
||||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
│
|
||||||
│ MariaDB │ │ Meilisearch │ │ Redis │
|
┌────────────┼────────────┬────────────┐
|
||||||
│ (외부 DB망) │ │ (검색 엔진) │ │ (캐시) │
|
│ │ │ │
|
||||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
▼ ▼ ▼ ▼
|
||||||
|
┌───────────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
|
||||||
|
│ MariaDB │ │Meilisearch│ │ Redis │ │ Nitter │
|
||||||
|
│ (외부 DB망) │ │ (검색엔진) │ │ (캐시) │ │ (X 스크랩) │
|
||||||
|
└───────────────┘ └───────────┘ └───────────┘ └───────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## 데이터베이스
|
## 데이터베이스
|
||||||
|
|
|
||||||
|
|
@ -8,47 +8,89 @@ cd /docker/fromis_9
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 구성
|
### 컨테이너 구성
|
||||||
- **Vite** (포트 80): 프론트엔드 개발 서버, HMR 지원
|
| 컨테이너 | 포트 | 설명 |
|
||||||
- **Fastify** (포트 3000): 백엔드 API, --watch 모드
|
|---------|------|------|
|
||||||
- Vite가 `/api`, `/docs` 요청을 localhost:3000으로 프록시
|
| `fromis9-frontend` | 80 | Vite 개발 서버, HMR 지원 |
|
||||||
|
| `fromis9-backend` | 80 | Fastify API, --watch 모드 |
|
||||||
|
| `fromis9-meilisearch` | 7700 | 검색 엔진 |
|
||||||
|
| `fromis9-redis` | 6379 | 캐시 |
|
||||||
|
|
||||||
|
- Vite가 `/api`, `/docs` 요청을 백엔드로 프록시
|
||||||
|
|
||||||
### 로그 확인
|
### 로그 확인
|
||||||
```bash
|
```bash
|
||||||
|
# 전체 로그
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# 백엔드만
|
||||||
|
docker compose logs -f fromis9-backend
|
||||||
|
|
||||||
|
# 프론트엔드만
|
||||||
docker compose logs -f fromis9-frontend
|
docker compose logs -f fromis9-frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
### 코드 수정
|
### 코드 수정
|
||||||
- `frontend/`, `backend/` 폴더가 볼륨 마운트됨
|
- `frontend/`, `backend/` 폴더가 컨테이너에 마운트됨
|
||||||
|
- `node_modules`도 호스트 폴더에 직접 설치됨
|
||||||
- 코드 수정 시 자동 반영 (HMR, watch)
|
- 코드 수정 시 자동 반영 (HMR, watch)
|
||||||
|
|
||||||
|
### 재시작
|
||||||
|
```bash
|
||||||
|
# 백엔드만 재시작
|
||||||
|
docker compose restart fromis9-backend
|
||||||
|
|
||||||
|
# 프론트엔드만 재시작
|
||||||
|
docker compose restart fromis9-frontend
|
||||||
|
|
||||||
|
# 전체 재시작
|
||||||
|
docker compose restart
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 배포 모드 전환
|
## 배포 모드 전환
|
||||||
|
|
||||||
### 1. Dockerfile 수정
|
### 1. Dockerfile 수정
|
||||||
|
|
||||||
|
**backend/Dockerfile:**
|
||||||
```dockerfile
|
```dockerfile
|
||||||
# 개발 모드 주석처리
|
# 개발 모드 주석처리
|
||||||
# FROM node:20-alpine
|
# FROM node:20-alpine
|
||||||
# WORKDIR /app
|
|
||||||
# ...
|
# ...
|
||||||
|
|
||||||
# 배포 모드 주석해제
|
# 배포 모드 주석해제
|
||||||
FROM node:20-alpine AS frontend-builder
|
FROM node:20-alpine
|
||||||
...
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache ffmpeg
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --production
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["npm", "start"]
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. docker-compose.yml 수정
|
**frontend/Dockerfile:**
|
||||||
```yaml
|
```dockerfile
|
||||||
# volumes 주석처리
|
# 개발 모드 주석처리
|
||||||
# volumes:
|
# FROM node:20-alpine
|
||||||
# - ./backend:/app/backend
|
# ...
|
||||||
# - ./frontend:/app/frontend
|
|
||||||
# - backend_modules:/app/backend/node_modules
|
# 배포 모드 주석해제
|
||||||
# - frontend_modules:/app/frontend/node_modules
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 빌드 및 실행
|
### 2. 빌드 및 실행
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
@ -115,9 +157,9 @@ docker exec caddy caddy reload --config /etc/caddy/Caddyfile
|
||||||
|
|
||||||
### 네트워크 구조
|
### 네트워크 구조
|
||||||
```
|
```
|
||||||
인터넷 → Caddy (:443) → fromis9-frontend (:80) → Fastify (:3000)
|
인터넷 → Caddy (:443) → fromis9-frontend (:80) → fromis9-backend (:80)
|
||||||
↓
|
↓
|
||||||
MariaDB, Redis, Meilisearch (내부 네트워크)
|
MariaDB, Redis, Meilisearch (내부 네트워크)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -125,11 +167,11 @@ docker exec caddy caddy reload --config /etc/caddy/Caddyfile
|
||||||
## 유용한 명령어
|
## 유용한 명령어
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 컨테이너 재시작
|
# 컨테이너 상태 확인
|
||||||
docker compose restart fromis9-frontend
|
docker compose ps
|
||||||
|
|
||||||
# 볼륨 포함 완전 재시작
|
# 완전 재시작
|
||||||
docker compose down -v && docker compose up -d --build
|
docker compose down && docker compose up -d --build
|
||||||
|
|
||||||
# Meilisearch 동기화
|
# Meilisearch 동기화
|
||||||
curl -X POST https://fromis9.caadiq.co.kr/api/schedules/sync-search \
|
curl -X POST https://fromis9.caadiq.co.kr/api/schedules/sync-search \
|
||||||
|
|
|
||||||
17
frontend/Dockerfile
Normal file
17
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# 개발 모드
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
CMD ["sh", "-c", "npm install --include=dev && npm run dev -- --host 0.0.0.0"]
|
||||||
|
|
||||||
|
# 배포 모드 (사용 시 위 개발 모드를 주석처리)
|
||||||
|
# FROM node:20-alpine AS builder
|
||||||
|
# WORKDIR /app
|
||||||
|
# COPY package*.json ./
|
||||||
|
# RUN npm install
|
||||||
|
# COPY . .
|
||||||
|
# RUN npm run build
|
||||||
|
#
|
||||||
|
# FROM nginx:alpine
|
||||||
|
# COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
# EXPOSE 80
|
||||||
|
# CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|
@ -38,6 +38,7 @@ import AdminAlbumForm from './pages/pc/admin/AdminAlbumForm';
|
||||||
import AdminAlbumPhotos from './pages/pc/admin/AdminAlbumPhotos';
|
import AdminAlbumPhotos from './pages/pc/admin/AdminAlbumPhotos';
|
||||||
import AdminSchedule from './pages/pc/admin/AdminSchedule';
|
import AdminSchedule from './pages/pc/admin/AdminSchedule';
|
||||||
import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm';
|
import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm';
|
||||||
|
import ScheduleFormPage from './pages/pc/admin/schedule/form';
|
||||||
import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory';
|
import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory';
|
||||||
import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots';
|
import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots';
|
||||||
import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict';
|
import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict';
|
||||||
|
|
@ -72,7 +73,8 @@ function App() {
|
||||||
<Route path="/admin/albums/:id/edit" element={<AdminAlbumForm />} />
|
<Route path="/admin/albums/:id/edit" element={<AdminAlbumForm />} />
|
||||||
<Route path="/admin/albums/:albumId/photos" element={<AdminAlbumPhotos />} />
|
<Route path="/admin/albums/:albumId/photos" element={<AdminAlbumPhotos />} />
|
||||||
<Route path="/admin/schedule" element={<AdminSchedule />} />
|
<Route path="/admin/schedule" element={<AdminSchedule />} />
|
||||||
<Route path="/admin/schedule/new" element={<AdminScheduleForm />} />
|
<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" element={<AdminScheduleForm />} />
|
||||||
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
|
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
|
||||||
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
|
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
|
||||||
|
|
|
||||||
|
|
@ -48,5 +48,5 @@ export async function updateSchedule(id, formData) {
|
||||||
|
|
||||||
// 일정 삭제
|
// 일정 삭제
|
||||||
export async function deleteSchedule(id) {
|
export async function deleteSchedule(id) {
|
||||||
return fetchAdminApi(`/api/admin/schedules/${id}`, { method: "DELETE" });
|
return fetchAdminApi(`/api/schedules/${id}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,16 @@
|
||||||
|
|
||||||
// 기본 fetch 래퍼
|
// 기본 fetch 래퍼
|
||||||
export async function fetchApi(url, options = {}) {
|
export async function fetchApi(url, options = {}) {
|
||||||
|
const headers = { ...options.headers };
|
||||||
|
|
||||||
|
// body가 있을 때만 Content-Type 설정 (DELETE 등 body 없는 요청 대응)
|
||||||
|
if (options.body) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers,
|
||||||
"Content-Type": "application/json",
|
|
||||||
...options.headers,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
|
|
@ -40,13 +40,15 @@ function CustomDatePicker({ value, onChange, placeholder = '날짜 선택', show
|
||||||
days.push(i);
|
days.push(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
const startYear = Math.floor(year / 10) * 10 - 1;
|
const MIN_YEAR = 2025;
|
||||||
|
const startYear = Math.max(MIN_YEAR, Math.floor(year / 12) * 12 - 1);
|
||||||
const years = Array.from({ length: 12 }, (_, i) => startYear + i);
|
const years = Array.from({ length: 12 }, (_, i) => startYear + i);
|
||||||
|
const canGoPrevYearRange = startYear > MIN_YEAR;
|
||||||
|
|
||||||
const prevMonth = () => setViewDate(new Date(year, month - 1, 1));
|
const prevMonth = () => setViewDate(new Date(year, month - 1, 1));
|
||||||
const nextMonth = () => setViewDate(new Date(year, month + 1, 1));
|
const nextMonth = () => setViewDate(new Date(year, month + 1, 1));
|
||||||
const prevYearRange = () => setViewDate(new Date(year - 10, month, 1));
|
const prevYearRange = () => canGoPrevYearRange && setViewDate(new Date(Math.max(MIN_YEAR, year - 12), month, 1));
|
||||||
const nextYearRange = () => setViewDate(new Date(year + 10, month, 1));
|
const nextYearRange = () => setViewDate(new Date(year + 12, month, 1));
|
||||||
|
|
||||||
const selectDate = (day) => {
|
const selectDate = (day) => {
|
||||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
|
@ -57,7 +59,6 @@ function CustomDatePicker({ value, onChange, placeholder = '날짜 선택', show
|
||||||
|
|
||||||
const selectYear = (y) => {
|
const selectYear = (y) => {
|
||||||
setViewDate(new Date(y, month, 1));
|
setViewDate(new Date(y, month, 1));
|
||||||
setViewMode('months');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectMonth = (m) => {
|
const selectMonth = (m) => {
|
||||||
|
|
@ -124,7 +125,8 @@ function CustomDatePicker({ value, onChange, placeholder = '날짜 선택', show
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={viewMode === 'years' ? prevYearRange : prevMonth}
|
onClick={viewMode === 'years' ? prevYearRange : prevMonth}
|
||||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
|
disabled={viewMode === 'years' && !canGoPrevYearRange}
|
||||||
|
className={`p-1.5 rounded-lg transition-colors ${viewMode === 'years' && !canGoPrevYearRange ? 'opacity-30' : 'hover:bg-gray-100'}`}
|
||||||
>
|
>
|
||||||
<ChevronLeft size={20} className="text-gray-600" />
|
<ChevronLeft size={20} className="text-gray-600" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -275,11 +275,12 @@ function MobileSchedule() {
|
||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
const categoryMap = new Map();
|
const categoryMap = new Map();
|
||||||
schedules.forEach(s => {
|
schedules.forEach(s => {
|
||||||
if (s.category_id && !categoryMap.has(s.category_id)) {
|
const catId = s.category?.id || s.category_id;
|
||||||
categoryMap.set(s.category_id, {
|
if (catId && !categoryMap.has(catId)) {
|
||||||
id: s.category_id,
|
categoryMap.set(catId, {
|
||||||
name: s.category_name,
|
id: catId,
|
||||||
color: s.category_color,
|
name: s.category?.name || s.category_name,
|
||||||
|
color: s.category?.color || s.category_color,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -717,7 +718,7 @@ function MobileSchedule() {
|
||||||
{/* 일정 점 (최대 3개) */}
|
{/* 일정 점 (최대 3개) */}
|
||||||
<div className="flex gap-0.5 mt-1 h-1.5">
|
<div className="flex gap-0.5 mt-1 h-1.5">
|
||||||
{!isSelected(date) && daySchedules.map((schedule, i) => {
|
{!isSelected(date) && daySchedules.map((schedule, i) => {
|
||||||
const cat = categories.find(c => c.id === schedule.category_id);
|
const cat = categories.find(c => c.id === (schedule.category?.id || schedule.category_id));
|
||||||
const color = cat?.color || '#6b7280';
|
const color = cat?.color || '#6b7280';
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -857,7 +858,7 @@ function MobileSchedule() {
|
||||||
<div className={virtualItem.index < searchResults.length - 1 ? "pb-3" : ""}>
|
<div className={virtualItem.index < searchResults.length - 1 ? "pb-3" : ""}>
|
||||||
<ScheduleCard
|
<ScheduleCard
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
categoryColor={getCategoryColor(schedule.category_id)}
|
categoryColor={getCategoryColor(schedule.category?.id || schedule.category_id)}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -909,7 +910,7 @@ function MobileSchedule() {
|
||||||
<TimelineScheduleCard
|
<TimelineScheduleCard
|
||||||
key={schedule.id}
|
key={schedule.id}
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
categoryColor={getCategoryColor(schedule.category_id)}
|
categoryColor={getCategoryColor(schedule.category?.id || schedule.category_id)}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
delay={index * 0.05}
|
delay={index * 0.05}
|
||||||
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
||||||
|
|
@ -926,7 +927,7 @@ function MobileSchedule() {
|
||||||
|
|
||||||
// 일정 카드 컴포넌트 (검색용) - 날짜 포함 모던 디자인
|
// 일정 카드 컴포넌트 (검색용) - 날짜 포함 모던 디자인
|
||||||
function ScheduleCard({ schedule, categoryColor, categories, delay = 0, onClick }) {
|
function ScheduleCard({ schedule, categoryColor, categories, delay = 0, onClick }) {
|
||||||
const categoryName = categories.find(c => c.id === schedule.category_id)?.name || '미분류';
|
const categoryName = categories.find(c => c.id === (schedule.category?.id || schedule.category_id))?.name || '미분류';
|
||||||
const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || '';
|
const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || '';
|
||||||
const memberList = memberNames.split(',').filter(name => name.trim());
|
const memberList = memberNames.split(',').filter(name => name.trim());
|
||||||
|
|
||||||
|
|
@ -1036,7 +1037,7 @@ function ScheduleCard({ schedule, categoryColor, categories, delay = 0, onClick
|
||||||
|
|
||||||
// 타임라인용 일정 카드 컴포넌트 - 모던 디자인
|
// 타임라인용 일정 카드 컴포넌트 - 모던 디자인
|
||||||
function TimelineScheduleCard({ schedule, categoryColor, categories, delay = 0, onClick }) {
|
function TimelineScheduleCard({ schedule, categoryColor, categories, delay = 0, onClick }) {
|
||||||
const categoryName = categories.find(c => c.id === schedule.category_id)?.name || '미분류';
|
const categoryName = categories.find(c => c.id === (schedule.category?.id || schedule.category_id))?.name || '미분류';
|
||||||
const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || '';
|
const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || '';
|
||||||
const memberList = memberNames.split(',').filter(name => name.trim());
|
const memberList = memberNames.split(',').filter(name => name.trim());
|
||||||
|
|
||||||
|
|
@ -1148,7 +1149,7 @@ function CalendarPicker({
|
||||||
if (!dateMap[date]) {
|
if (!dateMap[date]) {
|
||||||
dateMap[date] = [];
|
dateMap[date] = [];
|
||||||
}
|
}
|
||||||
const category = categories.find(c => c.id === schedule.category_id);
|
const category = categories.find(c => c.id === (schedule.category?.id || schedule.category_id));
|
||||||
dateMap[date].push(category?.color || '#6b7280');
|
dateMap[date].push(category?.color || '#6b7280');
|
||||||
});
|
});
|
||||||
return dateMap;
|
return dateMap;
|
||||||
|
|
@ -1238,8 +1239,10 @@ function CalendarPicker({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [yearRangeStart, setYearRangeStart] = useState(Math.floor(year / 12) * 12);
|
const MIN_YEAR = 2025;
|
||||||
|
const [yearRangeStart, setYearRangeStart] = useState(MIN_YEAR);
|
||||||
const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i);
|
const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i);
|
||||||
|
const canGoPrevYearRange = yearRangeStart > MIN_YEAR;
|
||||||
|
|
||||||
// 배경 스크롤 막기
|
// 배경 스크롤 막기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1330,7 +1333,7 @@ function CalendarPicker({
|
||||||
{!isSelected(item.date) && daySchedules.length > 0 && (
|
{!isSelected(item.date) && daySchedules.length > 0 && (
|
||||||
<div className="flex gap-0.5 mt-0.5 h-1.5">
|
<div className="flex gap-0.5 mt-0.5 h-1.5">
|
||||||
{daySchedules.map((schedule, i) => {
|
{daySchedules.map((schedule, i) => {
|
||||||
const cat = categories.find(c => c.id === schedule.category_id);
|
const cat = categories.find(c => c.id === (schedule.category?.id || schedule.category_id));
|
||||||
const color = cat?.color || '#6b7280';
|
const color = cat?.color || '#6b7280';
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -1364,8 +1367,9 @@ function CalendarPicker({
|
||||||
{/* 년도 범위 헤더 */}
|
{/* 년도 범위 헤더 */}
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setYearRangeStart(yearRangeStart - 12)}
|
onClick={() => canGoPrevYearRange && setYearRangeStart(Math.max(MIN_YEAR, yearRangeStart - 12))}
|
||||||
className="p-1"
|
disabled={!canGoPrevYearRange}
|
||||||
|
className={`p-1 ${canGoPrevYearRange ? '' : 'opacity-30'}`}
|
||||||
>
|
>
|
||||||
<ChevronLeft size={18} />
|
<ChevronLeft size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -273,9 +273,11 @@ function AdminSchedule() {
|
||||||
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
|
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
|
||||||
|
|
||||||
// 년도 범위 (현재 년도 기준 10년 단위 - Schedule.jsx와 동일)
|
// 년도 범위 (2025년부터 시작, 12년 단위)
|
||||||
const startYear = Math.floor(year / 10) * 10 - 1;
|
const MIN_YEAR = 2025;
|
||||||
|
const startYear = Math.max(MIN_YEAR, Math.floor(year / 12) * 12 - 1);
|
||||||
const yearRange = Array.from({ length: 12 }, (_, i) => startYear + i);
|
const yearRange = Array.from({ length: 12 }, (_, i) => startYear + i);
|
||||||
|
const canGoPrevYearRange = startYear > MIN_YEAR;
|
||||||
|
|
||||||
// 현재 년도/월 확인 함수
|
// 현재 년도/월 확인 함수
|
||||||
const isCurrentYear = (y) => new Date().getFullYear() === y;
|
const isCurrentYear = (y) => new Date().getFullYear() === y;
|
||||||
|
|
@ -482,14 +484,13 @@ function AdminSchedule() {
|
||||||
setSchedules([]); // 이전 달 데이터 즉시 초기화
|
setSchedules([]); // 이전 달 데이터 즉시 초기화
|
||||||
};
|
};
|
||||||
|
|
||||||
// 년도 범위 이동
|
// 년도 범위 이동 (12년 단위, 2025년 이전 불가)
|
||||||
const prevYearRange = () => setCurrentDate(new Date(year - 10, month, 1));
|
const prevYearRange = () => canGoPrevYearRange && setCurrentDate(new Date(Math.max(MIN_YEAR, year - 12), month, 1));
|
||||||
const nextYearRange = () => setCurrentDate(new Date(year + 10, month, 1));
|
const nextYearRange = () => setCurrentDate(new Date(year + 12, month, 1));
|
||||||
|
|
||||||
// 년도 선택 시 월 선택 모드로 전환
|
// 년도 선택
|
||||||
const selectYear = (newYear) => {
|
const selectYear = (newYear) => {
|
||||||
setCurrentDate(new Date(newYear, month, 1));
|
setCurrentDate(new Date(newYear, month, 1));
|
||||||
setViewMode('months');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 월 선택 시 적용 후 닫기
|
// 월 선택 시 적용 후 닫기
|
||||||
|
|
@ -730,7 +731,8 @@ function AdminSchedule() {
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<button
|
<button
|
||||||
onClick={prevYearRange}
|
onClick={prevYearRange}
|
||||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
|
disabled={!canGoPrevYearRange}
|
||||||
|
className={`p-1.5 rounded-lg transition-colors ${canGoPrevYearRange ? 'hover:bg-gray-100' : 'opacity-30'}`}
|
||||||
>
|
>
|
||||||
<ChevronLeft size={20} className="text-gray-600" />
|
<ChevronLeft size={20} className="text-gray-600" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,35 @@ import useAdminAuth from '../../../hooks/useAdminAuth';
|
||||||
import useToast from '../../../hooks/useToast';
|
import useToast from '../../../hooks/useToast';
|
||||||
import * as suggestionsApi from '../../../api/admin/suggestions';
|
import * as suggestionsApi from '../../../api/admin/suggestions';
|
||||||
|
|
||||||
|
// 애니메이션 variants
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.08,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.4, ease: "easeOut" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardVariants = {
|
||||||
|
hidden: { opacity: 0, scale: 0.95 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
transition: { duration: 0.3, ease: "easeOut" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// 품사 태그 옵션
|
// 품사 태그 옵션
|
||||||
const POS_TAGS = [
|
const POS_TAGS = [
|
||||||
{ value: 'NNP', label: '고유명사 (NNP)', description: '사람, 그룹, 프로그램 이름 등', examples: '프로미스나인, 송하영, 뮤직뱅크' },
|
{ value: 'NNP', label: '고유명사 (NNP)', description: '사람, 그룹, 프로그램 이름 등', examples: '프로미스나인, 송하영, 뮤직뱅크' },
|
||||||
|
|
@ -403,9 +432,14 @@ function AdminScheduleDict() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 메인 콘텐츠 */}
|
{/* 메인 콘텐츠 */}
|
||||||
<div className="max-w-5xl mx-auto px-6 py-8">
|
<motion.div
|
||||||
|
className="max-w-5xl mx-auto px-6 py-8"
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
>
|
||||||
{/* 브레드크럼 */}
|
{/* 브레드크럼 */}
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-400 mb-8">
|
<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">
|
<Link to="/admin/dashboard" className="hover:text-primary transition-colors">
|
||||||
<Home size={16} />
|
<Home size={16} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -415,36 +449,36 @@ function AdminScheduleDict() {
|
||||||
</Link>
|
</Link>
|
||||||
<ChevronRight size={14} />
|
<ChevronRight size={14} />
|
||||||
<span className="text-gray-700">사전 관리</span>
|
<span className="text-gray-700">사전 관리</span>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 타이틀 */}
|
{/* 타이틀 */}
|
||||||
<div className="mb-8">
|
<motion.div variants={itemVariants} className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">사전 관리</h1>
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">사전 관리</h1>
|
||||||
<p className="text-gray-500">형태소 분석기 사용자 사전을 관리합니다</p>
|
<p className="text-gray-500">형태소 분석기 사용자 사전을 관리합니다</p>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 통계 카드 */}
|
{/* 통계 카드 */}
|
||||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
<motion.div variants={itemVariants} className="grid grid-cols-4 gap-4 mb-6">
|
||||||
<div className="bg-white rounded-xl p-4 border border-gray-100">
|
<motion.div variants={cardVariants} className="bg-white rounded-xl p-4 border border-gray-100">
|
||||||
<div className="text-2xl font-bold text-gray-900">{posStats.total || 0}</div>
|
<div className="text-2xl font-bold text-gray-900">{posStats.total || 0}</div>
|
||||||
<div className="text-sm text-gray-500">전체 단어</div>
|
<div className="text-sm text-gray-500">전체 단어</div>
|
||||||
</div>
|
</motion.div>
|
||||||
<div className="bg-white rounded-xl p-4 border border-gray-100">
|
<motion.div variants={cardVariants} className="bg-white rounded-xl p-4 border border-gray-100">
|
||||||
<div className="text-2xl font-bold text-blue-500">{posStats.NNP || 0}</div>
|
<div className="text-2xl font-bold text-blue-500">{posStats.NNP || 0}</div>
|
||||||
<div className="text-sm text-gray-500">고유명사</div>
|
<div className="text-sm text-gray-500">고유명사</div>
|
||||||
</div>
|
</motion.div>
|
||||||
<div className="bg-white rounded-xl p-4 border border-gray-100">
|
<motion.div variants={cardVariants} className="bg-white rounded-xl p-4 border border-gray-100">
|
||||||
<div className="text-2xl font-bold text-green-500">{posStats.NNG || 0}</div>
|
<div className="text-2xl font-bold text-green-500">{posStats.NNG || 0}</div>
|
||||||
<div className="text-sm text-gray-500">일반명사</div>
|
<div className="text-sm text-gray-500">일반명사</div>
|
||||||
</div>
|
</motion.div>
|
||||||
<div className="bg-white rounded-xl p-4 border border-gray-100">
|
<motion.div variants={cardVariants} className="bg-white rounded-xl p-4 border border-gray-100">
|
||||||
<div className="text-2xl font-bold text-purple-500">{posStats.SL || 0}</div>
|
<div className="text-2xl font-bold text-purple-500">{posStats.SL || 0}</div>
|
||||||
<div className="text-sm text-gray-500">외국어</div>
|
<div className="text-sm text-gray-500">외국어</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 단어 추가 영역 */}
|
{/* 단어 추가 영역 */}
|
||||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-6">
|
<motion.div variants={itemVariants} className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-6">
|
||||||
<h3 className="font-bold text-gray-900 mb-4">단어 추가</h3>
|
<h3 className="font-bold text-gray-900 mb-4">단어 추가</h3>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|
@ -502,10 +536,10 @@ function AdminScheduleDict() {
|
||||||
추가
|
추가
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 단어 목록 */}
|
{/* 단어 목록 */}
|
||||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
<motion.div variants={itemVariants} className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
{/* 검색 및 필터 */}
|
{/* 검색 및 필터 */}
|
||||||
<div className="p-4 border-b border-gray-100 flex items-center gap-4">
|
<div className="p-4 border-b border-gray-100 flex items-center gap-4">
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
|
|
@ -579,7 +613,7 @@ function AdminScheduleDict() {
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto max-h-[500px] overflow-y-auto">
|
<div className="overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-gray-50 sticky top-0">
|
<thead className="bg-gray-50 sticky top-0 z-30">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-16">#</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-16">#</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">단어</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">단어</th>
|
||||||
|
|
@ -616,8 +650,8 @@ function AdminScheduleDict() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</motion.div>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
346
frontend/src/pages/pc/admin/schedule/form/XForm.jsx
Normal file
346
frontend/src/pages/pc/admin/schedule/form/XForm.jsx
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Hash,
|
||||||
|
Loader2,
|
||||||
|
Check,
|
||||||
|
AlertCircle,
|
||||||
|
Save,
|
||||||
|
Image as ImageIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import Toast from "../../../../../components/Toast";
|
||||||
|
import useToast from "../../../../../hooks/useToast";
|
||||||
|
|
||||||
|
// X 로고 아이콘
|
||||||
|
const XLogo = ({ size = 24, className = "" }) => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X(Twitter) 일정 추가 폼
|
||||||
|
* - 게시글 ID 입력 시 자동으로 정보 조회
|
||||||
|
*/
|
||||||
|
function XForm() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast, setToast } = useToast();
|
||||||
|
|
||||||
|
const [postId, setPostId] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [postInfo, setPostInfo] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// 게시글 ID 추출 (URL에서도 추출 가능)
|
||||||
|
const extractPostId = (input) => {
|
||||||
|
// 숫자만 있으면 그대로 반환
|
||||||
|
if (/^\d+$/.test(input.trim())) {
|
||||||
|
return input.trim();
|
||||||
|
}
|
||||||
|
// URL에서 추출
|
||||||
|
const match = input.match(/status\/(\d+)/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// X 게시글 정보 조회
|
||||||
|
const fetchPostInfo = async () => {
|
||||||
|
const id = extractPostId(postId);
|
||||||
|
if (!id) {
|
||||||
|
setError("게시글 ID 또는 URL을 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setPostInfo(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("adminToken");
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/admin/x/post-info?postId=${id}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "게시글 정보를 가져올 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setPostInfo(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 입력 후 엔터 키
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
fetchPostInfo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기화
|
||||||
|
const handleReset = () => {
|
||||||
|
setPostId("");
|
||||||
|
setPostInfo(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 폼 제출
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!postInfo) {
|
||||||
|
setError("먼저 게시글 ID를 입력하고 조회해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("adminToken");
|
||||||
|
|
||||||
|
const response = await fetch("/api/admin/x/schedule", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
postId: postInfo.postId,
|
||||||
|
title: postInfo.title,
|
||||||
|
content: postInfo.text,
|
||||||
|
imageUrls: postInfo.imageUrls,
|
||||||
|
date: postInfo.date,
|
||||||
|
time: postInfo.time,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "일정 저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStorage.setItem(
|
||||||
|
"scheduleToast",
|
||||||
|
JSON.stringify({
|
||||||
|
type: "success",
|
||||||
|
message: "X 일정이 추가되었습니다.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
navigate("/admin/schedule");
|
||||||
|
} catch (err) {
|
||||||
|
setToast({
|
||||||
|
type: "error",
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* 게시글 ID 입력 */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm p-8">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<XLogo size={24} className="text-black" />
|
||||||
|
<h2 className="text-lg font-bold text-gray-900">X 게시글</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* ID 입력 필드 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
게시글 ID 또는 URL *
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Hash
|
||||||
|
size={18}
|
||||||
|
className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={postId}
|
||||||
|
onChange={(e) => setPostId(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="1234567890 또는 https://x.com/realfromis_9/status/1234567890"
|
||||||
|
className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||||
|
disabled={loading || postInfo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!postInfo ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={fetchPostInfo}
|
||||||
|
disabled={loading || !postId.trim()}
|
||||||
|
className="px-6 py-3 bg-black text-white rounded-xl hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
조회 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"조회"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="px-6 py-3 bg-gray-100 text-gray-600 rounded-xl hover:bg-gray-200 transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
다시 입력
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="flex items-center gap-2 p-4 bg-red-50 text-red-600 rounded-xl"
|
||||||
|
>
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 게시글 정보 미리보기 */}
|
||||||
|
{postInfo && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="border border-green-200 bg-green-50 rounded-xl p-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Check size={18} className="text-green-500" />
|
||||||
|
<span className="text-sm font-medium text-green-600">
|
||||||
|
게시글 정보를 가져왔습니다
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 프로필 */}
|
||||||
|
{postInfo.profile?.displayName && (
|
||||||
|
<div className="flex items-center gap-3 mb-4 pb-4 border-b border-green-200">
|
||||||
|
{postInfo.profile.avatarUrl && (
|
||||||
|
<img
|
||||||
|
src={postInfo.profile.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-10 h-10 rounded-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-gray-900">
|
||||||
|
{postInfo.profile.displayName}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">@{postInfo.username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 제목 (첫 문단) */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-xs text-gray-400 mb-1">제목 (자동 추출)</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900">{postInfo.title}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 전체 내용 */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-xs text-gray-400 mb-1">전체 내용</p>
|
||||||
|
<p className="text-gray-700 whitespace-pre-wrap text-sm">
|
||||||
|
{postInfo.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이미지 */}
|
||||||
|
{postInfo.imageUrls?.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-xs text-gray-400 mb-2 flex items-center gap-1">
|
||||||
|
<ImageIcon size={12} />
|
||||||
|
이미지 ({postInfo.imageUrls.length}개)
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{postInfo.imageUrls.map((url, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="aspect-square bg-gray-200 rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={`이미지 ${index + 1}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 날짜/시간 */}
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
<span className="text-gray-400">게시:</span>{" "}
|
||||||
|
{postInfo.date} {postInfo.time}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div 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={!postInfo || 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>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default XForm;
|
||||||
301
frontend/src/pages/pc/admin/schedule/form/YouTubeForm.jsx
Normal file
301
frontend/src/pages/pc/admin/schedule/form/YouTubeForm.jsx
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Youtube,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Loader2,
|
||||||
|
Check,
|
||||||
|
AlertCircle,
|
||||||
|
Save,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Toast from "../../../../../components/Toast";
|
||||||
|
import useToast from "../../../../../hooks/useToast";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube 일정 추가 폼
|
||||||
|
* - URL 입력 시 자동으로 영상 정보 조회
|
||||||
|
* - 조회된 정보로 일정 저장
|
||||||
|
*/
|
||||||
|
function YouTubeForm() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast, setToast } = useToast();
|
||||||
|
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [videoInfo, setVideoInfo] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// YouTube URL에서 영상 정보 조회
|
||||||
|
const fetchVideoInfo = async () => {
|
||||||
|
if (!url.trim()) {
|
||||||
|
setError("YouTube URL을 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setVideoInfo(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("adminToken");
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/admin/youtube/video-info?url=${encodeURIComponent(url)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "영상 정보를 가져올 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setVideoInfo(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// URL 입력 후 엔터 키
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
fetchVideoInfo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기화
|
||||||
|
const handleReset = () => {
|
||||||
|
setUrl("");
|
||||||
|
setVideoInfo(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 폼 제출
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!videoInfo) {
|
||||||
|
setError("먼저 YouTube URL을 입력하고 조회해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("adminToken");
|
||||||
|
|
||||||
|
const response = await fetch("/api/admin/youtube/schedule", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
videoId: videoInfo.videoId,
|
||||||
|
title: videoInfo.title,
|
||||||
|
channelId: videoInfo.channelId,
|
||||||
|
channelName: videoInfo.channelName,
|
||||||
|
date: videoInfo.date,
|
||||||
|
time: videoInfo.time,
|
||||||
|
videoType: videoInfo.videoType,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "일정 저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성공 메시지를 sessionStorage에 저장하고 목록 페이지로 이동
|
||||||
|
sessionStorage.setItem(
|
||||||
|
"scheduleToast",
|
||||||
|
JSON.stringify({
|
||||||
|
type: "success",
|
||||||
|
message: "YouTube 일정이 추가되었습니다.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
navigate("/admin/schedule");
|
||||||
|
} catch (err) {
|
||||||
|
setToast({
|
||||||
|
type: "error",
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* YouTube URL 입력 */}
|
||||||
|
<div 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">YouTube 영상</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* URL 입력 필드 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
YouTube URL *
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<LinkIcon
|
||||||
|
size={18}
|
||||||
|
className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="https://www.youtube.com/watch?v=... 또는 https://youtu.be/..."
|
||||||
|
className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
||||||
|
disabled={loading || videoInfo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!videoInfo ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={fetchVideoInfo}
|
||||||
|
disabled={loading || !url.trim()}
|
||||||
|
className="px-6 py-3 bg-red-500 text-white rounded-xl hover:bg-red-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
조회 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"조회"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="px-6 py-3 bg-gray-100 text-gray-600 rounded-xl hover:bg-gray-200 transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
다시 입력
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="flex items-center gap-2 p-4 bg-red-50 text-red-600 rounded-xl"
|
||||||
|
>
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 영상 정보 미리보기 */}
|
||||||
|
{videoInfo && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="border border-green-200 bg-green-50 rounded-xl p-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* 썸네일 */}
|
||||||
|
<div className="flex-shrink-0 w-48 aspect-video bg-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={`https://img.youtube.com/vi/${videoInfo.videoId}/mqdefault.jpg`}
|
||||||
|
alt={videoInfo.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 정보 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Check size={18} className="text-green-500" />
|
||||||
|
<span className="text-sm font-medium text-green-600">
|
||||||
|
영상 정보를 가져왔습니다
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 mb-2 line-clamp-2">
|
||||||
|
{videoInfo.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-1 text-sm text-gray-600">
|
||||||
|
<p>
|
||||||
|
<span className="text-gray-400">채널:</span>{" "}
|
||||||
|
{videoInfo.channelName}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-gray-400">업로드:</span>{" "}
|
||||||
|
{videoInfo.date} {videoInfo.time}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-gray-400">유형:</span>{" "}
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
videoInfo.videoType === "shorts"
|
||||||
|
? "bg-pink-100 text-pink-600"
|
||||||
|
: "bg-blue-100 text-blue-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{videoInfo.videoType === "shorts" ? "Shorts" : "Video"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div 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={!videoInfo || 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>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default YouTubeForm;
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Settings } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 선택 컴포넌트
|
||||||
|
*/
|
||||||
|
function CategorySelector({ categories, selectedId, onChange }) {
|
||||||
|
// 색상 스타일 (기본 색상 또는 커스텀 HEX)
|
||||||
|
const getColorStyle = (color) => {
|
||||||
|
const colorMap = {
|
||||||
|
blue: "bg-blue-500",
|
||||||
|
green: "bg-green-500",
|
||||||
|
purple: "bg-purple-500",
|
||||||
|
red: "bg-red-500",
|
||||||
|
pink: "bg-pink-500",
|
||||||
|
yellow: "bg-yellow-500",
|
||||||
|
orange: "bg-orange-500",
|
||||||
|
gray: "bg-gray-500",
|
||||||
|
cyan: "bg-cyan-500",
|
||||||
|
indigo: "bg-indigo-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!color) return { className: "bg-gray-500" };
|
||||||
|
if (color.startsWith("#")) {
|
||||||
|
return { style: { backgroundColor: color } };
|
||||||
|
}
|
||||||
|
return { className: colorMap[color] || "bg-gray-500" };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm p-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900">카테고리 선택</h2>
|
||||||
|
<Link
|
||||||
|
to="/admin/schedule/categories"
|
||||||
|
className="flex items-center gap-1 text-xs text-gray-400 hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Settings size={12} />
|
||||||
|
카테고리 관리
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{categories.map((category) => {
|
||||||
|
const colorStyle = getColorStyle(category.color);
|
||||||
|
const isSelected = selectedId === category.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={category.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(category.id)}
|
||||||
|
className={`flex items-center justify-center gap-2 px-4 py-4 rounded-xl border-2 transition-all ${
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary/5 shadow-sm"
|
||||||
|
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-3 h-3 rounded-full ${colorStyle.className || ""}`}
|
||||||
|
style={colorStyle.style}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
isSelected ? "text-primary" : "text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CategorySelector;
|
||||||
181
frontend/src/pages/pc/admin/schedule/form/index.jsx
Normal file
181
frontend/src/pages/pc/admin/schedule/form/index.jsx
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Home, ChevronRight } from "lucide-react";
|
||||||
|
import AdminLayout from "../../../../../components/admin/AdminLayout";
|
||||||
|
import useAdminAuth from "../../../../../hooks/useAdminAuth";
|
||||||
|
import * as categoriesApi from "../../../../../api/admin/categories";
|
||||||
|
import CategorySelector from "./components/CategorySelector";
|
||||||
|
import YouTubeForm from "./YouTubeForm";
|
||||||
|
import XForm from "./XForm";
|
||||||
|
|
||||||
|
// 애니메이션 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" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카테고리 ID 상수
|
||||||
|
const CATEGORY_IDS = {
|
||||||
|
YOUTUBE: 2,
|
||||||
|
X: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일정 추가 페이지 (카테고리별 폼 분기)
|
||||||
|
*/
|
||||||
|
function ScheduleFormPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, isAuthenticated } = useAdminAuth();
|
||||||
|
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||||
|
|
||||||
|
// 카테고리 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try {
|
||||||
|
const data = await categoriesApi.getCategories();
|
||||||
|
setCategories(data);
|
||||||
|
// 첫 번째 카테고리를 기본값으로
|
||||||
|
if (data.length > 0) {
|
||||||
|
setSelectedCategory(data[0].id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("카테고리 로드 오류:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCategories();
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
// 카테고리에 따른 폼 렌더링
|
||||||
|
const renderForm = () => {
|
||||||
|
switch (selectedCategory) {
|
||||||
|
case CATEGORY_IDS.YOUTUBE:
|
||||||
|
return <YouTubeForm />;
|
||||||
|
|
||||||
|
case CATEGORY_IDS.X:
|
||||||
|
return <XForm />;
|
||||||
|
|
||||||
|
// 다른 카테고리는 기존 폼으로 리다이렉트 (추후 구현)
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm p-8 text-center">
|
||||||
|
<p className="text-gray-500 mb-4">
|
||||||
|
이 카테고리는 아직 전용 폼이 없습니다.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/admin/schedule/new-legacy")}
|
||||||
|
className="px-6 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
기존 폼으로 추가하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout user={user}>
|
||||||
|
<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">일정 추가</span>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 타이틀 */}
|
||||||
|
<motion.div variants={itemVariants} className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">일정 추가</h1>
|
||||||
|
<p className="text-gray-500">카테고리를 선택하고 일정을 등록하세요</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 카테고리 선택 */}
|
||||||
|
<motion.div variants={itemVariants} className="mb-6">
|
||||||
|
<CategorySelector
|
||||||
|
categories={categories}
|
||||||
|
selectedId={selectedCategory}
|
||||||
|
onChange={(id) => {
|
||||||
|
setSelectedCategory(id);
|
||||||
|
setIsInitialLoad(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 카테고리별 폼 */}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={selectedCategory}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: isInitialLoad ? 0.4 : 0.15,
|
||||||
|
ease: "easeOut",
|
||||||
|
delay: isInitialLoad ? 0.3 : 0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
exit={{ opacity: 0, y: -10, transition: { duration: 0.1 } }}
|
||||||
|
>
|
||||||
|
{renderForm()}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScheduleFormPage;
|
||||||
|
|
@ -431,7 +431,6 @@ function Schedule() {
|
||||||
|
|
||||||
const selectYear = (newYear) => {
|
const selectYear = (newYear) => {
|
||||||
setCurrentDate(new Date(newYear, month, 1));
|
setCurrentDate(new Date(newYear, month, 1));
|
||||||
setViewMode('months');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectMonth = (newMonth) => {
|
const selectMonth = (newMonth) => {
|
||||||
|
|
@ -568,12 +567,14 @@ function Schedule() {
|
||||||
return year === now.getFullYear() && m === now.getMonth();
|
return year === now.getFullYear() && m === now.getMonth();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 연도 선택 범위
|
// 연도 선택 범위 (2025년부터 시작)
|
||||||
const [yearRangeStart, setYearRangeStart] = useState(currentYear - 1);
|
const MIN_YEAR = 2025;
|
||||||
|
const [yearRangeStart, setYearRangeStart] = useState(MIN_YEAR);
|
||||||
const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i);
|
const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i);
|
||||||
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
|
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
|
||||||
const prevYearRange = () => setYearRangeStart(prev => prev - 3);
|
const canGoPrevYearRange = yearRangeStart > MIN_YEAR;
|
||||||
const nextYearRange = () => setYearRangeStart(prev => prev + 3);
|
const prevYearRange = () => canGoPrevYearRange && setYearRangeStart(prev => Math.max(MIN_YEAR, prev - 12));
|
||||||
|
const nextYearRange = () => setYearRangeStart(prev => prev + 12);
|
||||||
|
|
||||||
// 선택된 카테고리 이름
|
// 선택된 카테고리 이름
|
||||||
const getSelectedCategoryNames = () => {
|
const getSelectedCategoryNames = () => {
|
||||||
|
|
@ -680,7 +681,7 @@ function Schedule() {
|
||||||
className="absolute top-20 left-8 right-8 mx-auto w-80 bg-white rounded-xl shadow-lg border border-gray-200 p-4 z-10"
|
className="absolute top-20 left-8 right-8 mx-auto w-80 bg-white rounded-xl shadow-lg border border-gray-200 p-4 z-10"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<button onClick={prevYearRange} className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors">
|
<button onClick={prevYearRange} disabled={!canGoPrevYearRange} className={`p-1.5 rounded-lg transition-colors ${canGoPrevYearRange ? 'hover:bg-gray-100' : 'opacity-30'}`}>
|
||||||
<ChevronLeft size={20} className="text-gray-600" />
|
<ChevronLeft size={20} className="text-gray-600" />
|
||||||
</button>
|
</button>
|
||||||
<span className="font-medium text-gray-900">
|
<span className="font-medium text-gray-900">
|
||||||
|
|
|
||||||
|
|
@ -8,24 +8,14 @@ export default defineConfig({
|
||||||
port: 80,
|
port: 80,
|
||||||
allowedHosts: true,
|
allowedHosts: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
// 개발 모드 (localhost:3000)
|
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "http://localhost:3000",
|
target: "http://fromis9-backend:80",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
"/docs": {
|
"/docs": {
|
||||||
target: "http://localhost:3000",
|
target: "http://fromis9-backend:80",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
// 배포 모드 (사용 시 위를 주석처리)
|
|
||||||
// "/api": {
|
|
||||||
// target: "http://fromis9-backend:3000",
|
|
||||||
// changeOrigin: true,
|
|
||||||
// },
|
|
||||||
// "/docs": {
|
|
||||||
// target: "http://fromis9-backend:3000",
|
|
||||||
// changeOrigin: true,
|
|
||||||
// },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue