feat: 일정 봇 자동화 및 검색 기능 추가

- YouTube 일정 봇 서비스 추가 (youtube-bot.js, youtube-scheduler.js)
- 공개 일정 API 라우터 추가 (schedules.js)
- 관리자 일정 봇 관리 페이지 추가 (AdminScheduleBots.jsx)
- 백엔드 의존성 업데이트
This commit is contained in:
caadiq 2026-01-05 22:16:02 +09:00
parent 387db937b0
commit 1b01182028
9 changed files with 1088 additions and 10 deletions

View file

@ -14,6 +14,8 @@
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.11.0",
"node-cron": "^4.2.1",
"rss-parser": "^3.13.0",
"sharp": "^0.33.5"
}
},
@ -2339,6 +2341,15 @@
"node": ">= 0.8"
}
},
"node_modules/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
"license": "BSD-2-Clause",
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -2933,6 +2944,15 @@
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
@ -3071,6 +3091,16 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/rss-parser": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.13.0.tgz",
"integrity": "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==",
"license": "MIT",
"dependencies": {
"entities": "^2.0.3",
"xml2js": "^0.5.0"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -3097,6 +3127,12 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
"license": "BlueOak-1.0.0"
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@ -3405,6 +3441,28 @@
"node": ">= 0.8"
}
},
"node_modules/xml2js": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View file

@ -13,6 +13,8 @@
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.11.0",
"node-cron": "^4.2.1",
"rss-parser": "^3.13.0",
"sharp": "^0.33.5"
}
}

View file

@ -9,6 +9,8 @@ import {
DeleteObjectCommand,
} from "@aws-sdk/client-s3";
import pool from "../lib/db.js";
import { syncNewVideos, syncAllVideos } from "../services/youtube-bot.js";
import { startBot, stopBot } from "../services/youtube-scheduler.js";
const router = express.Router();
@ -1145,19 +1147,31 @@ router.put(
// 일정 목록 조회
router.get("/schedules", async (req, res) => {
try {
const { year, month } = req.query;
const { year, month, search } = req.query;
let whereClause = "";
let whereConditions = [];
let params = [];
// 년/월 필터링
// 검색어가 있으면 전체 일정에서 검색 (년/월 필터 무시)
if (search && search.trim()) {
const searchTerm = `%${search.trim()}%`;
whereConditions.push("(s.title LIKE ? OR s.description LIKE ?)");
params.push(searchTerm, searchTerm);
} else {
// 년/월 필터링 (검색이 아닐 때만)
if (year && month) {
whereClause = "WHERE YEAR(s.date) = ? AND MONTH(s.date) = ?";
params = [parseInt(year), parseInt(month)];
whereConditions.push("YEAR(s.date) = ? AND MONTH(s.date) = ?");
params.push(parseInt(year), parseInt(month));
} else if (year) {
whereClause = "WHERE YEAR(s.date) = ?";
params = [parseInt(year)];
whereConditions.push("YEAR(s.date) = ?");
params.push(parseInt(year));
}
}
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
const [schedules] = await pool.query(
`SELECT
@ -1641,4 +1655,70 @@ router.delete("/schedules/:id", authenticateToken, async (req, res) => {
}
});
// =====================================================
// YouTube 봇 API
// =====================================================
// 봇 목록 조회
router.get("/bots", authenticateToken, async (req, res) => {
try {
const [bots] = await pool.query(
`SELECT b.*, c.channel_id, c.channel_name, c.rss_url, c.include_shorts
FROM bots b
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
ORDER BY b.created_at DESC`
);
res.json(bots);
} catch (error) {
console.error("봇 목록 조회 오류:", error);
res.status(500).json({ error: "봇 목록 조회 중 오류가 발생했습니다." });
}
});
// 봇 시작
router.post("/bots/:id/start", authenticateToken, async (req, res) => {
try {
const { id } = req.params;
await startBot(id);
res.json({ message: "봇이 시작되었습니다." });
} catch (error) {
console.error("봇 시작 오류:", error);
res
.status(500)
.json({ error: error.message || "봇 시작 중 오류가 발생했습니다." });
}
});
// 봇 정지
router.post("/bots/:id/stop", authenticateToken, async (req, res) => {
try {
const { id } = req.params;
await stopBot(id);
res.json({ message: "봇이 정지되었습니다." });
} catch (error) {
console.error("봇 정지 오류:", error);
res
.status(500)
.json({ error: error.message || "봇 정지 중 오류가 발생했습니다." });
}
});
// 전체 동기화 (초기화)
router.post("/bots/:id/sync-all", authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const result = await syncAllVideos(id);
res.json({
message: `${result.addedCount}개 일정이 추가되었습니다.`,
addedCount: result.addedCount,
total: result.total,
});
} catch (error) {
console.error("전체 동기화 오류:", error);
res
.status(500)
.json({ error: error.message || "전체 동기화 중 오류가 발생했습니다." });
}
});
export default router;

View file

@ -0,0 +1,78 @@
import express from "express";
import pool from "../lib/db.js";
const router = express.Router();
// 공개 일정 목록 조회
router.get("/", async (req, res) => {
try {
const [schedules] = await pool.query(`
SELECT
s.id,
s.title,
s.description,
s.date,
s.time,
s.category_id,
s.source_url,
s.location_name,
c.name as category_name,
c.color as category_color
FROM schedules s
LEFT JOIN schedule_categories c ON s.category_id = c.id
ORDER BY s.date DESC, s.time DESC
`);
res.json(schedules);
} catch (error) {
console.error("일정 목록 조회 오류:", error);
res.status(500).json({ error: "일정 목록 조회 중 오류가 발생했습니다." });
}
});
// 카테고리 목록 조회
router.get("/categories", async (req, res) => {
try {
const [categories] = await pool.query(`
SELECT id, name, color, sort_order
FROM schedule_categories
ORDER BY sort_order ASC
`);
res.json(categories);
} catch (error) {
console.error("카테고리 조회 오류:", error);
res.status(500).json({ error: "카테고리 조회 중 오류가 발생했습니다." });
}
});
// 개별 일정 조회
router.get("/:id", async (req, res) => {
try {
const { id } = req.params;
const [schedules] = await pool.query(
`
SELECT
s.*,
c.name as category_name,
c.color as category_color
FROM schedules s
LEFT JOIN schedule_categories c ON s.category_id = c.id
WHERE s.id = ?
`,
[id]
);
if (schedules.length === 0) {
return res.status(404).json({ error: "일정을 찾을 수 없습니다." });
}
res.json(schedules[0]);
} catch (error) {
console.error("일정 조회 오류:", error);
res.status(500).json({ error: "일정 조회 중 오류가 발생했습니다." });
}
});
export default router;

View file

@ -5,6 +5,7 @@ import membersRouter from "./routes/members.js";
import albumsRouter from "./routes/albums.js";
import statsRouter from "./routes/stats.js";
import adminRouter from "./routes/admin.js";
import schedulesRouter from "./routes/schedules.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@ -27,6 +28,12 @@ app.use("/api/members", membersRouter);
app.use("/api/albums", albumsRouter);
app.use("/api/stats", statsRouter);
app.use("/api/admin", adminRouter);
app.use("/api/schedules", schedulesRouter);
app.use("/api/schedule-categories", (req, res, next) => {
// /api/schedule-categories -> /api/schedules/categories로 리다이렉트
req.url = "/categories";
schedulesRouter(req, res, next);
});
// SPA 폴백 - 모든 요청을 index.html로
app.get("*", (req, res) => {

View file

@ -0,0 +1,368 @@
import Parser from "rss-parser";
import pool from "../lib/db.js";
// YouTube API 키
const YOUTUBE_API_KEY =
process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM";
// RSS 파서 설정
const rssParser = new Parser({
customFields: {
item: [
["yt:videoId", "videoId"],
["yt:channelId", "channelId"],
],
},
});
/**
* UTC KST 변환
*/
export function toKST(utcDate) {
const date = new Date(utcDate);
return new Date(date.getTime() + 9 * 60 * 60 * 1000);
}
/**
* 날짜를 YYYY-MM-DD 형식으로 변환
*/
export function formatDate(date) {
return date.toISOString().split("T")[0];
}
/**
* 시간을 HH:MM:SS 형식으로 변환
*/
export function formatTime(date) {
return date.toTimeString().split(" ")[0];
}
/**
* '유튜브' 카테고리 ID 조회 (없으면 생성)
*/
export async function getYoutubeCategory() {
const [rows] = await pool.query(
"SELECT id FROM schedule_categories WHERE name = '유튜브'"
);
if (rows.length > 0) {
return rows[0].id;
}
// 없으면 생성
const [result] = await pool.query(
"INSERT INTO schedule_categories (name, color, sort_order) VALUES ('유튜브', '#ff0033', 99)"
);
return result.insertId;
}
/**
* 영상 URL에서 유형 판별 (video/shorts)
*/
export function getVideoType(url) {
if (url.includes("/shorts/")) {
return "shorts";
}
return "video";
}
/**
* 영상 URL 생성
*/
export function getVideoUrl(videoId, videoType) {
if (videoType === "shorts") {
return `https://www.youtube.com/shorts/${videoId}`;
}
return `https://www.youtube.com/watch?v=${videoId}`;
}
/**
* RSS 피드 파싱하여 영상 목록 반환
*/
export async function parseRSSFeed(rssUrl) {
try {
const feed = await rssParser.parseURL(rssUrl);
return feed.items.map((item) => {
const videoId = item.videoId;
const link = item.link || "";
const videoType = getVideoType(link);
const publishedAt = toKST(new Date(item.pubDate));
return {
videoId,
title: item.title,
publishedAt,
date: formatDate(publishedAt),
time: formatTime(publishedAt),
videoUrl: link || getVideoUrl(videoId, videoType),
videoType,
};
});
} catch (error) {
console.error("RSS 파싱 오류:", error);
throw error;
}
}
/**
* ISO 8601 duration (PT1M30S) 변환
*/
function parseDuration(duration) {
const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
if (!match) return 0;
const hours = parseInt(match[1] || 0);
const minutes = parseInt(match[2] || 0);
const seconds = parseInt(match[3] || 0);
return hours * 3600 + minutes * 60 + seconds;
}
/**
* YouTube API로 전체 영상 수집 (초기 동기화용)
* Shorts 판별: duration이 60 이하이면 Shorts
*/
export async function fetchAllVideosFromAPI(channelId) {
const videos = [];
let pageToken = "";
try {
// 채널의 업로드 플레이리스트 ID 조회
const channelResponse = await fetch(
`https://www.googleapis.com/youtube/v3/channels?part=contentDetails&id=${channelId}&key=${YOUTUBE_API_KEY}`
);
const channelData = await channelResponse.json();
if (!channelData.items || channelData.items.length === 0) {
throw new Error("채널을 찾을 수 없습니다.");
}
const uploadsPlaylistId =
channelData.items[0].contentDetails.relatedPlaylists.uploads;
// 플레이리스트 아이템 조회 (페이징)
do {
const url = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&playlistId=${uploadsPlaylistId}&maxResults=50&key=${YOUTUBE_API_KEY}${
pageToken ? `&pageToken=${pageToken}` : ""
}`;
const response = await fetch(url);
const data = await response.json();
if (data.error) {
throw new Error(data.error.message);
}
// 영상 ID 목록 추출
const videoIds = data.items.map(
(item) => item.snippet.resourceId.videoId
);
// videos API로 duration 조회 (50개씩 배치)
const videosResponse = await fetch(
`https://www.googleapis.com/youtube/v3/videos?part=contentDetails&id=${videoIds.join(
","
)}&key=${YOUTUBE_API_KEY}`
);
const videosData = await videosResponse.json();
// duration으로 Shorts 판별 맵 생성
const durationMap = {};
if (videosData.items) {
for (const v of videosData.items) {
const duration = v.contentDetails.duration;
const seconds = parseDuration(duration);
durationMap[v.id] = seconds <= 60 ? "shorts" : "video";
}
}
for (const item of data.items) {
const snippet = item.snippet;
const videoId = snippet.resourceId.videoId;
const publishedAt = toKST(new Date(snippet.publishedAt));
const videoType = durationMap[videoId] || "video";
videos.push({
videoId,
title: snippet.title,
publishedAt,
date: formatDate(publishedAt),
time: formatTime(publishedAt),
videoUrl: getVideoUrl(videoId, videoType),
videoType,
});
}
pageToken = data.nextPageToken || "";
} while (pageToken);
// 과거순 정렬 (오래된 영상부터 추가)
videos.sort((a, b) => new Date(a.publishedAt) - new Date(b.publishedAt));
return videos;
} catch (error) {
console.error("YouTube API 오류:", error);
throw error;
}
}
/**
* 영상을 일정으로 추가 (source_url로 중복 체크)
*/
export async function createScheduleFromVideo(video, categoryId) {
try {
// source_url로 중복 체크
const [existing] = await pool.query(
"SELECT id FROM schedules WHERE source_url = ?",
[video.videoUrl]
);
if (existing.length > 0) {
return null; // 이미 존재
}
// 일정 생성
const [result] = await pool.query(
`INSERT INTO schedules (title, date, time, category_id, source_url)
VALUES (?, ?, ?, ?, ?)`,
[video.title, video.date, video.time, categoryId, video.videoUrl]
);
return result.insertId;
} catch (error) {
console.error("일정 생성 오류:", error);
throw error;
}
}
/**
* 봇의 영상 동기화 (RSS 기반)
*/
export async function syncNewVideos(botId) {
try {
// 봇 정보 조회
const [bots] = await pool.query(
`SELECT b.*, c.channel_id, c.rss_url, c.include_shorts
FROM bots b
JOIN bot_youtube_config c ON b.id = c.bot_id
WHERE b.id = ?`,
[botId]
);
if (bots.length === 0) {
throw new Error("봇을 찾을 수 없습니다.");
}
const bot = bots[0];
const categoryId = await getYoutubeCategory();
// RSS 피드 파싱
const videos = await parseRSSFeed(bot.rss_url);
let addedCount = 0;
for (const video of videos) {
// Shorts 필터링
if (!bot.include_shorts && video.videoType === "shorts") {
continue;
}
const scheduleId = await createScheduleFromVideo(video, categoryId);
if (scheduleId) {
addedCount++;
}
}
// 봇 상태 업데이트
await pool.query(
`UPDATE bots SET
last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR),
schedules_added = schedules_added + ?,
error_message = NULL
WHERE id = ?`,
[addedCount, botId]
);
return { addedCount, total: videos.length };
} catch (error) {
// 오류 상태 업데이트
await pool.query(
`UPDATE bots SET
last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR),
status = 'error',
error_message = ?
WHERE id = ?`,
[error.message, botId]
);
throw error;
}
}
/**
* 전체 영상 동기화 (API 기반, 초기화용)
*/
export async function syncAllVideos(botId) {
try {
// 봇 정보 조회
const [bots] = await pool.query(
`SELECT b.*, c.channel_id, c.include_shorts
FROM bots b
JOIN bot_youtube_config c ON b.id = c.bot_id
WHERE b.id = ?`,
[botId]
);
if (bots.length === 0) {
throw new Error("봇을 찾을 수 없습니다.");
}
const bot = bots[0];
const categoryId = await getYoutubeCategory();
// API로 전체 영상 수집
const videos = await fetchAllVideosFromAPI(bot.channel_id);
let addedCount = 0;
for (const video of videos) {
// Shorts 필터링
if (!bot.include_shorts && video.videoType === "shorts") {
continue;
}
const scheduleId = await createScheduleFromVideo(video, categoryId);
if (scheduleId) {
addedCount++;
}
}
// 봇 상태 업데이트
await pool.query(
`UPDATE bots SET
last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR),
schedules_added = schedules_added + ?,
error_message = NULL
WHERE id = ?`,
[addedCount, botId]
);
return { addedCount, total: videos.length };
} catch (error) {
await pool.query(
`UPDATE bots SET
status = 'error',
error_message = ?
WHERE id = ?`,
[error.message, botId]
);
throw error;
}
}
export default {
parseRSSFeed,
fetchAllVideosFromAPI,
syncNewVideos,
syncAllVideos,
getYoutubeCategory,
toKST,
};

View file

@ -0,0 +1,107 @@
import cron from "node-cron";
import pool from "../lib/db.js";
import { syncNewVideos } from "./youtube-bot.js";
// 봇별 스케줄러 인스턴스 저장
const schedulers = new Map();
/**
* 개별 스케줄 등록
*/
export function registerBot(botId, intervalMinutes = 2) {
// 기존 스케줄이 있으면 제거
unregisterBot(botId);
// cron 표현식 생성 (1분 시작, X분 간격: 1,3,5,7...)
const cronExpression = `1-59/${intervalMinutes} * * * *`;
const task = cron.schedule(cronExpression, async () => {
console.log(`[Bot ${botId}] 동기화 시작...`);
try {
const result = await syncNewVideos(botId);
console.log(`[Bot ${botId}] 동기화 완료: ${result.addedCount}개 추가`);
} catch (error) {
console.error(`[Bot ${botId}] 동기화 오류:`, error.message);
}
});
schedulers.set(botId, task);
console.log(
`[Bot ${botId}] 스케줄 등록됨 (${intervalMinutes}분 간격, 1분 오프셋)`
);
}
/**
* 개별 스케줄 해제
*/
export function unregisterBot(botId) {
if (schedulers.has(botId)) {
schedulers.get(botId).stop();
schedulers.delete(botId);
console.log(`[Bot ${botId}] 스케줄 해제됨`);
}
}
/**
* 서버 시작 실행 중인 봇들 스케줄 등록
*/
export async function initScheduler() {
try {
const [bots] = await pool.query(
"SELECT id, check_interval FROM bots WHERE status = 'running'"
);
for (const bot of bots) {
registerBot(bot.id, bot.check_interval);
}
console.log(`[Scheduler] ${bots.length}개 봇 스케줄 등록됨`);
} catch (error) {
console.error("[Scheduler] 초기화 오류:", error);
}
}
/**
* 시작
*/
export async function startBot(botId) {
const [bots] = await pool.query("SELECT * FROM bots WHERE id = ?", [botId]);
if (bots.length === 0) {
throw new Error("봇을 찾을 수 없습니다.");
}
const bot = bots[0];
// 스케줄 등록
registerBot(botId, bot.check_interval);
// 상태 업데이트
await pool.query(
"UPDATE bots SET status = 'running', error_message = NULL WHERE id = ?",
[botId]
);
// 즉시 1회 실행
try {
await syncNewVideos(botId);
} catch (error) {
console.error(`[Bot ${botId}] 초기 동기화 오류:`, error.message);
}
}
/**
* 정지
*/
export async function stopBot(botId) {
unregisterBot(botId);
await pool.query("UPDATE bots SET status = 'stopped' WHERE id = ?", [botId]);
}
export default {
initScheduler,
registerBot,
unregisterBot,
startBot,
stopBot,
};

View file

@ -20,6 +20,7 @@ import AdminAlbumPhotos from './pages/pc/admin/AdminAlbumPhotos';
import AdminSchedule from './pages/pc/admin/AdminSchedule';
import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm';
import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory';
import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots';
// PC
import PCLayout from './components/pc/Layout';
@ -42,6 +43,7 @@ function App() {
<Route path="/admin/schedule/new" element={<AdminScheduleForm />} />
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
{/* 일반 페이지 (레이아웃 포함) */}
<Route path="/*" element={

View file

@ -0,0 +1,376 @@
import { useState, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
LogOut, Home, ChevronRight, Bot, Play, Square,
Youtube, Calendar, Clock, CheckCircle, XCircle, RefreshCw, Download
} from 'lucide-react';
import Toast from '../../../components/Toast';
function AdminScheduleBots() {
const navigate = useNavigate();
const [user, setUser] = useState(null);
const [toast, setToast] = useState(null);
const [bots, setBots] = useState([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(null); // ID
// Toast
useEffect(() => {
if (toast) {
const timer = setTimeout(() => setToast(null), 3000);
return () => clearTimeout(timer);
}
}, [toast]);
useEffect(() => {
const token = localStorage.getItem('adminToken');
const userData = localStorage.getItem('adminUser');
if (!token || !userData) {
navigate('/admin');
return;
}
setUser(JSON.parse(userData));
fetchBots();
}, [navigate]);
//
const fetchBots = async () => {
try {
const token = localStorage.getItem('adminToken');
const response = await fetch('/api/admin/bots', {
headers: { Authorization: `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
setBots(data);
}
} catch (error) {
console.error('봇 목록 조회 오류:', error);
setToast({ type: 'error', message: '봇 목록을 불러올 수 없습니다.' });
} finally {
setLoading(false);
}
};
const handleLogout = () => {
localStorage.removeItem('adminToken');
localStorage.removeItem('adminUser');
navigate('/admin');
};
// /
const toggleBot = async (botId, currentStatus) => {
try {
const token = localStorage.getItem('adminToken');
const action = currentStatus === 'running' ? 'stop' : 'start';
const response = await fetch(`/api/admin/bots/${botId}/${action}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` }
});
if (response.ok) {
setToast({
type: 'success',
message: action === 'start' ? '봇이 시작되었습니다.' : '봇이 정지되었습니다.'
});
fetchBots(); //
} else {
const data = await response.json();
setToast({ type: 'error', message: data.error || '작업 실패' });
}
} catch (error) {
console.error('봇 토글 오류:', error);
setToast({ type: 'error', message: '작업 중 오류가 발생했습니다.' });
}
};
//
const syncAllVideos = async (botId) => {
setSyncing(botId);
try {
const token = localStorage.getItem('adminToken');
const response = await fetch(`/api/admin/bots/${botId}/sync-all`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
setToast({
type: 'success',
message: `${data.addedCount}개 일정이 추가되었습니다. (전체 ${data.total}개)`
});
fetchBots();
} else {
const data = await response.json();
setToast({ type: 'error', message: data.error || '동기화 실패' });
}
} catch (error) {
console.error('전체 동기화 오류:', error);
setToast({ type: 'error', message: '동기화 중 오류가 발생했습니다.' });
} finally {
setSyncing(null);
}
};
//
const getStatusInfo = (status) => {
switch (status) {
case 'running':
return {
icon: <CheckCircle size={16} />,
text: '실행 중',
color: 'text-green-500',
bg: 'bg-green-50',
dot: 'bg-green-500',
};
case 'stopped':
return {
icon: <XCircle size={16} />,
text: '정지됨',
color: 'text-gray-400',
bg: 'bg-gray-50',
dot: 'bg-gray-400',
};
case 'error':
return {
icon: <XCircle size={16} />,
text: '오류',
color: 'text-red-500',
bg: 'bg-red-50',
dot: 'bg-red-500',
};
default:
return {
icon: null,
text: '알 수 없음',
color: 'text-gray-400',
bg: 'bg-gray-50',
dot: 'bg-gray-400',
};
}
};
// (DB KST )
const formatTime = (dateString) => {
if (!dateString) return '-';
// DB KST UTC Z
const cleanDateString = dateString.replace('Z', '').replace('T', ' ');
const date = new Date(cleanDateString);
return date.toLocaleString('ko-KR', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
return (
<div className="min-h-screen bg-gray-50">
<Toast toast={toast} onClose={() => setToast(null)} />
{/* 헤더 */}
<header className="bg-white shadow-sm border-b border-gray-100">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link to="/admin/dashboard" className="text-2xl font-bold text-primary hover:opacity-80 transition-opacity">
fromis_9
</Link>
<span className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
Admin
</span>
</div>
<div className="flex items-center gap-4">
<span className="text-gray-500 text-sm">
안녕하세요, <span className="text-gray-900 font-medium">{user?.username}</span>
</span>
<button
onClick={handleLogout}
className="flex items-center gap-2 px-4 py-2 text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
<LogOut size={18} />
<span>로그아웃</span>
</button>
</div>
</div>
</header>
{/* 메인 콘텐츠 */}
<main className="max-w-7xl mx-auto px-6 py-8">
{/* 브레드크럼 */}
<div 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>
</div>
{/* 타이틀 */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2"> 관리</h1>
<p className="text-gray-500">일정 자동화 봇을 관리합니다</p>
</div>
{/* 봇 통계 */}
<div className="grid grid-cols-4 gap-4 mb-8">
<div className="bg-white rounded-xl p-5 border border-gray-100">
<div className="text-sm text-gray-500 mb-1">전체 </div>
<div className="text-2xl font-bold text-gray-900">{bots.length}</div>
</div>
<div className="bg-white rounded-xl p-5 border border-gray-100">
<div className="text-sm text-gray-500 mb-1">실행 </div>
<div className="text-2xl font-bold text-green-500">
{bots.filter(b => b.status === 'running').length}
</div>
</div>
<div className="bg-white rounded-xl p-5 border border-gray-100">
<div className="text-sm text-gray-500 mb-1">정지됨</div>
<div className="text-2xl font-bold text-gray-400">
{bots.filter(b => b.status === 'stopped').length}
</div>
</div>
<div className="bg-white rounded-xl p-5 border border-gray-100">
<div className="text-sm text-gray-500 mb-1">오류</div>
<div className="text-2xl font-bold text-red-500">
{bots.filter(b => b.status === 'error').length}
</div>
</div>
</div>
{/* 봇 목록 */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100">
<h2 className="font-bold text-gray-900"> 목록</h2>
</div>
{loading ? (
<div className="flex justify-center items-center py-20">
<div className="animate-spin rounded-full h-10 w-10 border-4 border-primary border-t-transparent"></div>
</div>
) : bots.length === 0 ? (
<div className="text-center py-20 text-gray-400">
<Bot size={48} className="mx-auto mb-4 opacity-30" />
<p>등록된 봇이 없습니다</p>
<p className="text-sm mt-1">위의 버튼을 클릭하여 봇을 추가하세요</p>
</div>
) : (
<div className="divide-y divide-gray-100">
{bots.map((bot, index) => {
const statusInfo = getStatusInfo(bot.status);
return (
<motion.div
key={bot.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className="p-6 hover:bg-gray-50 transition-colors"
>
<div className="flex items-start gap-4">
{/* 아이콘 */}
<div className={`w-12 h-12 rounded-xl ${statusInfo.bg} flex items-center justify-center flex-shrink-0`}>
<Youtube size={24} className="text-red-500" />
</div>
{/* 정보 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h3 className="font-bold text-gray-900">{bot.name}</h3>
<span className={`flex items-center gap-1 text-xs font-medium ${statusInfo.color}`}>
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot} ${bot.status === 'running' ? 'animate-pulse' : ''}`}></span>
{statusInfo.text}
</span>
<span className="px-2 py-0.5 text-xs font-medium bg-red-50 text-red-600 rounded-full">
YouTube
</span>
</div>
<p className="text-sm text-gray-500 mb-3">
채널: {bot.channel_name || bot.channel_id} |
{bot.include_shorts ? ' Shorts 포함' : ' Shorts 제외'} |
{bot.check_interval} 간격
</p>
{/* 메타 정보 */}
<div className="flex items-center gap-6 text-xs text-gray-400">
<span className="flex items-center gap-1">
<Clock size={12} />
마지막 체크: {formatTime(bot.last_check_at)}
</span>
<span className="flex items-center gap-1">
<Calendar size={12} />
추가된 일정: {bot.schedules_added}
</span>
</div>
{/* 오류 메시지 */}
{bot.status === 'error' && bot.error_message && (
<div className="mt-3 px-3 py-2 bg-red-50 text-red-600 text-xs rounded-lg">
{bot.error_message}
</div>
)}
</div>
{/* 액션 버튼 */}
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => syncAllVideos(bot.id)}
disabled={syncing === bot.id}
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded-lg font-medium transition-colors hover:bg-blue-600 disabled:opacity-50"
>
{syncing === bot.id ? (
<>
<RefreshCw size={16} className="animate-spin" />
동기화 ...
</>
) : (
<>
<Download size={16} />
전체 동기화
</>
)}
</button>
<button
onClick={() => toggleBot(bot.id, bot.status)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
bot.status === 'running'
? 'bg-gray-100 text-gray-600 hover:bg-gray-200'
: 'bg-green-500 text-white hover:bg-green-600'
}`}
>
{bot.status === 'running' ? (
<>
<Square size={16} />
정지
</>
) : (
<>
<Play size={16} />
시작
</>
)}
</button>
</div>
</div>
</motion.div>
);
})}
</div>
)}
</div>
</main>
</div>
);
}
export default AdminScheduleBots;