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/api.md](docs/api.md) - API 명세
|
||||
- [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 {
|
||||
const result = await syncFn(bot);
|
||||
const status = await getStatus(botId);
|
||||
await updateStatus(botId, {
|
||||
const updateData = {
|
||||
status: 'running',
|
||||
lastCheckAt: new Date().toISOString(),
|
||||
lastAddedCount: result.addedCount,
|
||||
totalAdded: (status.totalAdded || 0) + result.addedCount,
|
||||
errorMessage: null,
|
||||
});
|
||||
};
|
||||
// 실제로 추가된 경우에만 lastAddedCount 업데이트
|
||||
if (result.addedCount > 0) {
|
||||
updateData.lastAddedCount = result.addedCount;
|
||||
}
|
||||
await updateStatus(botId, updateData);
|
||||
fastify.log.info(`[${botId}] 동기화 완료: ${result.addedCount}개 추가`);
|
||||
} catch (err) {
|
||||
await updateStatus(botId, {
|
||||
|
|
@ -98,11 +102,15 @@ async function schedulerPlugin(fastify, opts) {
|
|||
try {
|
||||
const result = await syncFn(bot);
|
||||
const status = await getStatus(botId);
|
||||
await updateStatus(botId, {
|
||||
const updateData = {
|
||||
lastCheckAt: new Date().toISOString(),
|
||||
lastAddedCount: 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}개 추가`);
|
||||
} catch (err) {
|
||||
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 statsRoutes from './stats/index.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' });
|
||||
|
||||
// 관리자 - 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' });
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 검색 모드: search 파라미터가 있으면 Meilisearch 검색
|
||||
|
|
@ -121,13 +137,53 @@ export default async function schedulesRoutes(fastify) {
|
|||
};
|
||||
} else if (s.category_id === 3 && s.x_post_id) {
|
||||
result.source = {
|
||||
name: 'X',
|
||||
name: '',
|
||||
url: `https://x.com/realfromis_9/status/${s.x_post_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
schedule.source = {
|
||||
name: 'X',
|
||||
name: '',
|
||||
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) {
|
||||
const birthDate = new Date(member.birth_date);
|
||||
const birthYear = birthDate.getFullYear();
|
||||
|
||||
// 조회 연도가 생년보다 이전이면 스킵
|
||||
if (year < birthYear) continue;
|
||||
|
||||
const birthdayThisYear = new Date(year, birthDate.getMonth(), birthDate.getDate());
|
||||
const dateKey = birthdayThisYear.toISOString().split('T')[0];
|
||||
|
||||
|
|
|
|||
|
|
@ -137,6 +137,16 @@ function formatScheduleResponse(hit) {
|
|||
? 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 {
|
||||
id: hit.id,
|
||||
title: hit.title,
|
||||
|
|
@ -146,7 +156,7 @@ function formatScheduleResponse(hit) {
|
|||
name: hit.category_name,
|
||||
color: hit.category_color,
|
||||
},
|
||||
source_name: hit.source_name || null,
|
||||
source,
|
||||
members,
|
||||
_rankingScore: hit._rankingScore,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ const inko = new Inko();
|
|||
const CONFIG = {
|
||||
// 추천 검색어 최소 검색 횟수 비율 (최대 대비)
|
||||
MIN_COUNT_RATIO: 0.01,
|
||||
// 최소 임계값 (데이터 적을 때)
|
||||
MIN_COUNT_FLOOR: 5,
|
||||
// 최소 임계값 (데이터 적을 때 오타 방지)
|
||||
MIN_COUNT_FLOOR: 10,
|
||||
// Redis 키 prefix
|
||||
REDIS_PREFIX: 'suggest:',
|
||||
// 캐시 TTL (초)
|
||||
|
|
@ -24,7 +24,6 @@ const CONFIG = {
|
|||
PREFIX: 3600, // prefix 검색: 1시간
|
||||
BIGRAM: 86400, // bi-gram: 24시간
|
||||
POPULAR: 600, // 인기 검색어: 10분
|
||||
MAX_COUNT: 3600, // 최대 횟수: 1시간
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -208,6 +207,7 @@ export class SuggestionService {
|
|||
|
||||
/**
|
||||
* Prefix 매칭
|
||||
* - GREATEST()로 동적 임계값 적용: MAX(count) * 1% 또는 최소 10 중 더 큰 값
|
||||
*/
|
||||
async getPrefixSuggestions(prefix, koreanPrefix, limit) {
|
||||
try {
|
||||
|
|
@ -216,19 +216,21 @@ export class SuggestionService {
|
|||
if (koreanPrefix) {
|
||||
// 영어 + 한글 변환 둘 다 검색
|
||||
[rows] = await this.db.query(
|
||||
`SELECT query, count FROM suggestion_queries
|
||||
WHERE query LIKE ? OR query LIKE ?
|
||||
`SELECT query FROM suggestion_queries
|
||||
WHERE (query LIKE ? OR query LIKE ?)
|
||||
AND count >= GREATEST((SELECT MAX(count) * ? FROM suggestion_queries), ?)
|
||||
ORDER BY count DESC, last_searched_at DESC
|
||||
LIMIT ?`,
|
||||
[`${prefix}%`, `${koreanPrefix}%`, limit]
|
||||
[`${prefix}%`, `${koreanPrefix}%`, CONFIG.MIN_COUNT_RATIO, CONFIG.MIN_COUNT_FLOOR, limit]
|
||||
);
|
||||
} else {
|
||||
[rows] = await this.db.query(
|
||||
`SELECT query, count FROM suggestion_queries
|
||||
`SELECT query FROM suggestion_queries
|
||||
WHERE query LIKE ?
|
||||
AND count >= GREATEST((SELECT MAX(count) * ? FROM suggestion_queries), ?)
|
||||
ORDER BY count DESC, last_searched_at DESC
|
||||
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) {
|
||||
try {
|
||||
const [rows] = await this.db.query(
|
||||
`SELECT word, count FROM suggestion_chosung
|
||||
`SELECT word FROM suggestion_chosung
|
||||
WHERE chosung LIKE ?
|
||||
AND count >= GREATEST((SELECT MAX(count) * ? FROM suggestion_chosung), ?)
|
||||
ORDER BY count DESC
|
||||
LIMIT ?`,
|
||||
[`${chosung}%`, limit]
|
||||
[`${chosung}%`, CONFIG.MIN_COUNT_RATIO, CONFIG.MIN_COUNT_FLOOR, limit]
|
||||
);
|
||||
|
||||
return rows.map(r => r.word);
|
||||
|
|
@ -261,6 +265,7 @@ export class SuggestionService {
|
|||
|
||||
/**
|
||||
* 인기 검색어 조회
|
||||
* - GREATEST()로 동적 임계값 적용
|
||||
*/
|
||||
async getPopularQueries(limit = 10) {
|
||||
try {
|
||||
|
|
@ -272,12 +277,13 @@ export class SuggestionService {
|
|||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
// DB 조회
|
||||
// DB 조회 (동적 임계값 이상만)
|
||||
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
|
||||
LIMIT ?`,
|
||||
[limit]
|
||||
[CONFIG.MIN_COUNT_RATIO, CONFIG.MIN_COUNT_FLOOR, limit]
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
// 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 = {
|
||||
username,
|
||||
displayName: profile.displayName,
|
||||
|
|
@ -139,8 +150,8 @@ async function xBotPlugin(fastify, opts) {
|
|||
async function syncNewTweets(bot) {
|
||||
const { tweets, profile } = await fetchTweets(bot.nitterUrl, bot.username);
|
||||
|
||||
// 프로필 캐시 업데이트
|
||||
await cacheProfile(bot.username, profile);
|
||||
// 프로필 저장 (DB + 캐시)
|
||||
await saveProfile(bot.username, profile);
|
||||
|
||||
let addedCount = 0;
|
||||
let ytAddedCount = 0;
|
||||
|
|
@ -178,11 +189,39 @@ async function xBotPlugin(fastify, opts) {
|
|||
}
|
||||
|
||||
/**
|
||||
* X 프로필 조회
|
||||
* X 프로필 조회 (Redis 캐시 → DB)
|
||||
*/
|
||||
async function getProfile(username) {
|
||||
const data = await fastify.redis.get(`${PROFILE_CACHE_PREFIX}${username}`);
|
||||
return data ? JSON.parse(data) : null;
|
||||
// Redis 캐시 확인
|
||||
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', {
|
||||
|
|
|
|||
|
|
@ -128,6 +128,58 @@ export function parseTweets(html, username) {
|
|||
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에서 트윗 수집 (첫 페이지만)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ function getVideoUrl(videoId, isShorts) {
|
|||
/**
|
||||
* 채널의 업로드 플레이리스트 ID 조회
|
||||
*/
|
||||
async function getUploadsPlaylistId(channelId) {
|
||||
export async function getUploadsPlaylistId(channelId) {
|
||||
const url = `${API_BASE}/channels?part=contentDetails&id=${channelId}&key=${API_KEY}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
|
@ -64,9 +64,12 @@ async function getVideoDurations(videoIds) {
|
|||
|
||||
/**
|
||||
* 최근 N개 영상 조회
|
||||
* @param {string} channelId - 채널 ID
|
||||
* @param {number} maxResults - 최대 결과 수
|
||||
* @param {string} uploadsPlaylistId - 캐싱된 uploads playlist ID (선택)
|
||||
*/
|
||||
export async function fetchRecentVideos(channelId, maxResults = 10) {
|
||||
const uploadsId = await getUploadsPlaylistId(channelId);
|
||||
export async function fetchRecentVideos(channelId, maxResults = 10, uploadsPlaylistId = null) {
|
||||
const uploadsId = uploadsPlaylistId || await getUploadsPlaylistId(channelId);
|
||||
|
||||
const url = `${API_BASE}/playlistItems?part=snippet&playlistId=${uploadsId}&maxResults=${maxResults}&key=${API_KEY}`;
|
||||
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) {
|
||||
const uploadsId = await getUploadsPlaylistId(channelId);
|
||||
export async function fetchAllVideos(channelId, uploadsPlaylistId = null) {
|
||||
const uploadsId = uploadsPlaylistId || await getUploadsPlaylistId(channelId);
|
||||
const videos = [];
|
||||
let pageToken = '';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,30 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { fetchRecentVideos, fetchAllVideos } from './api.js';
|
||||
import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.js';
|
||||
import bots from '../../config/bots.js';
|
||||
|
||||
const YOUTUBE_CATEGORY_ID = 2;
|
||||
const PLAYLIST_CACHE_PREFIX = 'yt_uploads:';
|
||||
|
||||
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) {
|
||||
const videos = await fetchRecentVideos(bot.channelId, 10);
|
||||
const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId);
|
||||
const videos = await fetchRecentVideos(bot.channelId, 10, uploadsPlaylistId);
|
||||
let addedCount = 0;
|
||||
|
||||
for (const video of videos) {
|
||||
|
|
@ -106,7 +127,8 @@ async function youtubeBotPlugin(fastify, opts) {
|
|||
* 전체 영상 동기화 (초기화)
|
||||
*/
|
||||
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;
|
||||
|
||||
for (const video of videos) {
|
||||
|
|
@ -137,5 +159,5 @@ async function youtubeBotPlugin(fastify, opts) {
|
|||
|
||||
export default fp(youtubeBotPlugin, {
|
||||
name: 'youtubeBot',
|
||||
dependencies: ['db'],
|
||||
dependencies: ['db', 'redis'],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,19 +1,26 @@
|
|||
services:
|
||||
fromis9-frontend:
|
||||
build: .
|
||||
build: ./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:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
env_file:
|
||||
- .env
|
||||
# 개발 모드
|
||||
volumes:
|
||||
- ./backend:/app/backend
|
||||
- ./frontend:/app/frontend
|
||||
- backend_modules:/app/backend/node_modules
|
||||
- frontend_modules:/app/frontend/node_modules
|
||||
# 배포 모드 (사용 시 위 volumes를 주석처리)
|
||||
# volumes: []
|
||||
- ./backend:/app
|
||||
networks:
|
||||
- app
|
||||
- db
|
||||
|
|
@ -39,10 +46,6 @@ services:
|
|||
- app
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
backend_modules:
|
||||
frontend_modules:
|
||||
|
||||
networks:
|
||||
app:
|
||||
external: true
|
||||
|
|
|
|||
157
docs/api.md
157
docs/api.md
|
|
@ -67,7 +67,7 @@ Base URL: `/api`
|
|||
|
||||
**source 객체 (카테고리별):**
|
||||
- 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 없음
|
||||
|
||||
**검색 응답:**
|
||||
|
|
@ -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
|
||||
일정 상세 조회
|
||||
|
||||
### DELETE /schedules/:id
|
||||
일정 삭제 (인증 필요)
|
||||
|
||||
### POST /schedules/sync-search
|
||||
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/ # 추천 검색어
|
||||
│ │ ├── app.js # Fastify 앱 설정
|
||||
│ │ └── server.js # 진입점
|
||||
│ ├── Dockerfile # 백엔드 컨테이너
|
||||
│ └── package.json
|
||||
│
|
||||
├── backend-backup/ # Express 백엔드 (참조용, 마이그레이션 원본)
|
||||
|
|
@ -47,9 +48,9 @@ fromis_9/
|
|||
│ │ ├── stores/ # Zustand 스토어
|
||||
│ │ └── App.jsx
|
||||
│ ├── vite.config.js
|
||||
│ ├── Dockerfile # 프론트엔드 컨테이너
|
||||
│ └── package.json
|
||||
│
|
||||
├── Dockerfile # 개발/배포 통합 (주석 전환)
|
||||
├── docker-compose.yml
|
||||
└── .env
|
||||
```
|
||||
|
|
@ -64,20 +65,24 @@ fromis_9/
|
|||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ fromis9-frontend (Docker) │
|
||||
│ ┌─────────────────┐ ┌─────────────────────────────┐ │
|
||||
│ │ Vite (:80) │───▶│ Fastify (:3000) │ │
|
||||
│ │ 프론트엔드 │ │ 백엔드 API │ │
|
||||
│ └─────────────────┘ └──────────┬──────────────────┘ │
|
||||
└─────────────────────────────────────┼───────────────────┘
|
||||
│
|
||||
┌────────────────────────────┼────────────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ MariaDB │ │ Meilisearch │ │ Redis │
|
||||
│ (외부 DB망) │ │ (검색 엔진) │ │ (캐시) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ fromis9-frontend (:80) │
|
||||
│ Vite 개발서버 │
|
||||
│ (프록시: /api → backend) │
|
||||
└─────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ fromis9-backend (:80) │
|
||||
│ Fastify API │
|
||||
└─────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌────────────┼────────────┬────────────┐
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
┌───────────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
|
||||
│ MariaDB │ │Meilisearch│ │ Redis │ │ Nitter │
|
||||
│ (외부 DB망) │ │ (검색엔진) │ │ (캐시) │ │ (X 스크랩) │
|
||||
└───────────────┘ └───────────┘ └───────────┘ └───────────┘
|
||||
```
|
||||
|
||||
## 데이터베이스
|
||||
|
|
|
|||
|
|
@ -8,47 +8,89 @@ cd /docker/fromis_9
|
|||
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
|
||||
# 전체 로그
|
||||
docker compose logs -f
|
||||
|
||||
# 백엔드만
|
||||
docker compose logs -f fromis9-backend
|
||||
|
||||
# 프론트엔드만
|
||||
docker compose logs -f fromis9-frontend
|
||||
```
|
||||
|
||||
### 코드 수정
|
||||
- `frontend/`, `backend/` 폴더가 볼륨 마운트됨
|
||||
- `frontend/`, `backend/` 폴더가 컨테이너에 마운트됨
|
||||
- `node_modules`도 호스트 폴더에 직접 설치됨
|
||||
- 코드 수정 시 자동 반영 (HMR, watch)
|
||||
|
||||
### 재시작
|
||||
```bash
|
||||
# 백엔드만 재시작
|
||||
docker compose restart fromis9-backend
|
||||
|
||||
# 프론트엔드만 재시작
|
||||
docker compose restart fromis9-frontend
|
||||
|
||||
# 전체 재시작
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 배포 모드 전환
|
||||
|
||||
### 1. Dockerfile 수정
|
||||
|
||||
**backend/Dockerfile:**
|
||||
```dockerfile
|
||||
# 개발 모드 주석처리
|
||||
# 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 수정
|
||||
```yaml
|
||||
# volumes 주석처리
|
||||
# volumes:
|
||||
# - ./backend:/app/backend
|
||||
# - ./frontend:/app/frontend
|
||||
# - backend_modules:/app/backend/node_modules
|
||||
# - frontend_modules:/app/frontend/node_modules
|
||||
**frontend/Dockerfile:**
|
||||
```dockerfile
|
||||
# 개발 모드 주석처리
|
||||
# FROM node:20-alpine
|
||||
# ...
|
||||
|
||||
# 배포 모드 주석해제
|
||||
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
|
||||
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)
|
||||
↓
|
||||
MariaDB, Redis, Meilisearch (내부 네트워크)
|
||||
인터넷 → Caddy (:443) → fromis9-frontend (:80) → fromis9-backend (:80)
|
||||
↓
|
||||
MariaDB, Redis, Meilisearch (내부 네트워크)
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -125,11 +167,11 @@ docker exec caddy caddy reload --config /etc/caddy/Caddyfile
|
|||
## 유용한 명령어
|
||||
|
||||
```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 동기화
|
||||
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 AdminSchedule from './pages/pc/admin/AdminSchedule';
|
||||
import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm';
|
||||
import ScheduleFormPage from './pages/pc/admin/schedule/form';
|
||||
import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory';
|
||||
import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots';
|
||||
import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict';
|
||||
|
|
@ -72,7 +73,8 @@ function App() {
|
|||
<Route path="/admin/albums/:id/edit" element={<AdminAlbumForm />} />
|
||||
<Route path="/admin/albums/:albumId/photos" element={<AdminAlbumPhotos />} />
|
||||
<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/categories" element={<AdminScheduleCategory />} />
|
||||
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
|
||||
|
|
|
|||
|
|
@ -48,5 +48,5 @@ export async function updateSchedule(id, formData) {
|
|||
|
||||
// 일정 삭제
|
||||
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 래퍼
|
||||
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, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -40,13 +40,15 @@ function CustomDatePicker({ value, onChange, placeholder = '날짜 선택', show
|
|||
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 canGoPrevYearRange = startYear > MIN_YEAR;
|
||||
|
||||
const prevMonth = () => 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 nextYearRange = () => 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 + 12, month, 1));
|
||||
|
||||
const selectDate = (day) => {
|
||||
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) => {
|
||||
setViewDate(new Date(y, month, 1));
|
||||
setViewMode('months');
|
||||
};
|
||||
|
||||
const selectMonth = (m) => {
|
||||
|
|
@ -124,7 +125,8 @@ function CustomDatePicker({ value, onChange, placeholder = '날짜 선택', show
|
|||
<button
|
||||
type="button"
|
||||
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" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -275,11 +275,12 @@ function MobileSchedule() {
|
|||
const categories = useMemo(() => {
|
||||
const categoryMap = new Map();
|
||||
schedules.forEach(s => {
|
||||
if (s.category_id && !categoryMap.has(s.category_id)) {
|
||||
categoryMap.set(s.category_id, {
|
||||
id: s.category_id,
|
||||
name: s.category_name,
|
||||
color: s.category_color,
|
||||
const catId = s.category?.id || s.category_id;
|
||||
if (catId && !categoryMap.has(catId)) {
|
||||
categoryMap.set(catId, {
|
||||
id: catId,
|
||||
name: s.category?.name || s.category_name,
|
||||
color: s.category?.color || s.category_color,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -717,7 +718,7 @@ function MobileSchedule() {
|
|||
{/* 일정 점 (최대 3개) */}
|
||||
<div className="flex gap-0.5 mt-1 h-1.5">
|
||||
{!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';
|
||||
return (
|
||||
<div
|
||||
|
|
@ -857,7 +858,7 @@ function MobileSchedule() {
|
|||
<div className={virtualItem.index < searchResults.length - 1 ? "pb-3" : ""}>
|
||||
<ScheduleCard
|
||||
schedule={schedule}
|
||||
categoryColor={getCategoryColor(schedule.category_id)}
|
||||
categoryColor={getCategoryColor(schedule.category?.id || schedule.category_id)}
|
||||
categories={categories}
|
||||
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
||||
/>
|
||||
|
|
@ -909,7 +910,7 @@ function MobileSchedule() {
|
|||
<TimelineScheduleCard
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
categoryColor={getCategoryColor(schedule.category_id)}
|
||||
categoryColor={getCategoryColor(schedule.category?.id || schedule.category_id)}
|
||||
categories={categories}
|
||||
delay={index * 0.05}
|
||||
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
||||
|
|
@ -926,7 +927,7 @@ function MobileSchedule() {
|
|||
|
||||
// 일정 카드 컴포넌트 (검색용) - 날짜 포함 모던 디자인
|
||||
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 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 }) {
|
||||
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 memberList = memberNames.split(',').filter(name => name.trim());
|
||||
|
||||
|
|
@ -1148,7 +1149,7 @@ function CalendarPicker({
|
|||
if (!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');
|
||||
});
|
||||
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 canGoPrevYearRange = yearRangeStart > MIN_YEAR;
|
||||
|
||||
// 배경 스크롤 막기
|
||||
useEffect(() => {
|
||||
|
|
@ -1330,7 +1333,7 @@ function CalendarPicker({
|
|||
{!isSelected(item.date) && daySchedules.length > 0 && (
|
||||
<div className="flex gap-0.5 mt-0.5 h-1.5">
|
||||
{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';
|
||||
return (
|
||||
<div
|
||||
|
|
@ -1363,16 +1366,17 @@ function CalendarPicker({
|
|||
>
|
||||
{/* 년도 범위 헤더 */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<button
|
||||
onClick={() => setYearRangeStart(yearRangeStart - 12)}
|
||||
className="p-1"
|
||||
<button
|
||||
onClick={() => canGoPrevYearRange && setYearRangeStart(Math.max(MIN_YEAR, yearRangeStart - 12))}
|
||||
disabled={!canGoPrevYearRange}
|
||||
className={`p-1 ${canGoPrevYearRange ? '' : 'opacity-30'}`}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="font-semibold text-sm">
|
||||
{yearRangeStart} - {yearRangeStart + 11}
|
||||
</span>
|
||||
<button
|
||||
<button
|
||||
onClick={() => setYearRangeStart(yearRangeStart + 12)}
|
||||
className="p-1"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -273,9 +273,11 @@ function AdminSchedule() {
|
|||
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
|
||||
|
||||
// 년도 범위 (현재 년도 기준 10년 단위 - Schedule.jsx와 동일)
|
||||
const startYear = Math.floor(year / 10) * 10 - 1;
|
||||
// 년도 범위 (2025년부터 시작, 12년 단위)
|
||||
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 canGoPrevYearRange = startYear > MIN_YEAR;
|
||||
|
||||
// 현재 년도/월 확인 함수
|
||||
const isCurrentYear = (y) => new Date().getFullYear() === y;
|
||||
|
|
@ -482,14 +484,13 @@ function AdminSchedule() {
|
|||
setSchedules([]); // 이전 달 데이터 즉시 초기화
|
||||
};
|
||||
|
||||
// 년도 범위 이동
|
||||
const prevYearRange = () => setCurrentDate(new Date(year - 10, month, 1));
|
||||
const nextYearRange = () => setCurrentDate(new Date(year + 10, month, 1));
|
||||
// 년도 범위 이동 (12년 단위, 2025년 이전 불가)
|
||||
const prevYearRange = () => canGoPrevYearRange && setCurrentDate(new Date(Math.max(MIN_YEAR, year - 12), month, 1));
|
||||
const nextYearRange = () => setCurrentDate(new Date(year + 12, month, 1));
|
||||
|
||||
// 년도 선택 시 월 선택 모드로 전환
|
||||
// 년도 선택
|
||||
const selectYear = (newYear) => {
|
||||
setCurrentDate(new Date(newYear, month, 1));
|
||||
setViewMode('months');
|
||||
};
|
||||
|
||||
// 월 선택 시 적용 후 닫기
|
||||
|
|
@ -730,7 +731,8 @@ function AdminSchedule() {
|
|||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
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" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,35 @@ import useAdminAuth from '../../../hooks/useAdminAuth';
|
|||
import useToast from '../../../hooks/useToast';
|
||||
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 = [
|
||||
{ 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">
|
||||
<Home size={16} />
|
||||
</Link>
|
||||
|
|
@ -415,36 +449,36 @@ function AdminScheduleDict() {
|
|||
</Link>
|
||||
<ChevronRight size={14} />
|
||||
<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>
|
||||
<p className="text-gray-500">형태소 분석기 사용자 사전을 관리합니다</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl p-4 border border-gray-100">
|
||||
<motion.div variants={itemVariants} className="grid grid-cols-4 gap-4 mb-6">
|
||||
<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-sm text-gray-500">전체 단어</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-4 border border-gray-100">
|
||||
</motion.div>
|
||||
<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-sm text-gray-500">고유명사</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-4 border border-gray-100">
|
||||
</motion.div>
|
||||
<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-sm text-gray-500">일반명사</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-4 border border-gray-100">
|
||||
</motion.div>
|
||||
<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-sm text-gray-500">외국어</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.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>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
|
|
@ -502,10 +536,10 @@ function AdminScheduleDict() {
|
|||
추가
|
||||
</button>
|
||||
</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="flex-1 relative">
|
||||
|
|
@ -579,7 +613,7 @@ function AdminScheduleDict() {
|
|||
) : (
|
||||
<div className="overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<thead className="bg-gray-50 sticky top-0 z-30">
|
||||
<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">단어</th>
|
||||
|
|
@ -616,8 +650,8 @@ function AdminScheduleDict() {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</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) => {
|
||||
setCurrentDate(new Date(newYear, month, 1));
|
||||
setViewMode('months');
|
||||
};
|
||||
|
||||
const selectMonth = (newMonth) => {
|
||||
|
|
@ -568,12 +567,14 @@ function Schedule() {
|
|||
return year === now.getFullYear() && m === now.getMonth();
|
||||
};
|
||||
|
||||
// 연도 선택 범위
|
||||
const [yearRangeStart, setYearRangeStart] = useState(currentYear - 1);
|
||||
// 연도 선택 범위 (2025년부터 시작)
|
||||
const MIN_YEAR = 2025;
|
||||
const [yearRangeStart, setYearRangeStart] = useState(MIN_YEAR);
|
||||
const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i);
|
||||
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
|
||||
const prevYearRange = () => setYearRangeStart(prev => prev - 3);
|
||||
const nextYearRange = () => setYearRangeStart(prev => prev + 3);
|
||||
const canGoPrevYearRange = yearRangeStart > MIN_YEAR;
|
||||
const prevYearRange = () => canGoPrevYearRange && setYearRangeStart(prev => Math.max(MIN_YEAR, prev - 12));
|
||||
const nextYearRange = () => setYearRangeStart(prev => prev + 12);
|
||||
|
||||
// 선택된 카테고리 이름
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
</button>
|
||||
<span className="font-medium text-gray-900">
|
||||
|
|
|
|||
|
|
@ -8,24 +8,14 @@ export default defineConfig({
|
|||
port: 80,
|
||||
allowedHosts: true,
|
||||
proxy: {
|
||||
// 개발 모드 (localhost:3000)
|
||||
"/api": {
|
||||
target: "http://localhost:3000",
|
||||
target: "http://fromis9-backend:80",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/docs": {
|
||||
target: "http://localhost:3000",
|
||||
target: "http://fromis9-backend:80",
|
||||
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