feat: 일정 봇 자동화 및 검색 기능 추가
- YouTube 일정 봇 서비스 추가 (youtube-bot.js, youtube-scheduler.js) - 공개 일정 API 라우터 추가 (schedules.js) - 관리자 일정 봇 관리 페이지 추가 (AdminScheduleBots.jsx) - 백엔드 의존성 업데이트
This commit is contained in:
parent
387db937b0
commit
1b01182028
9 changed files with 1088 additions and 10 deletions
58
backend/package-lock.json
generated
58
backend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,20 +1147,32 @@ 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 (year && month) {
|
||||
whereClause = "WHERE YEAR(s.date) = ? AND MONTH(s.date) = ?";
|
||||
params = [parseInt(year), parseInt(month)];
|
||||
} else if (year) {
|
||||
whereClause = "WHERE YEAR(s.date) = ?";
|
||||
params = [parseInt(year)];
|
||||
// 검색어가 있으면 전체 일정에서 검색 (년/월 필터 무시)
|
||||
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) {
|
||||
whereConditions.push("YEAR(s.date) = ? AND MONTH(s.date) = ?");
|
||||
params.push(parseInt(year), parseInt(month));
|
||||
} else if (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
|
||||
s.id, s.title, s.date, s.time, s.end_date, s.end_time,
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
78
backend/routes/schedules.js
Normal file
78
backend/routes/schedules.js
Normal 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;
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
368
backend/services/youtube-bot.js
Normal file
368
backend/services/youtube-bot.js
Normal 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,
|
||||
};
|
||||
107
backend/services/youtube-scheduler.js
Normal file
107
backend/services/youtube-scheduler.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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={
|
||||
|
|
|
|||
376
frontend/src/pages/pc/admin/AdminScheduleBots.jsx
Normal file
376
frontend/src/pages/pc/admin/AdminScheduleBots.jsx
Normal 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;
|
||||
Loading…
Add table
Reference in a new issue