feat(admin): YouTube 봇 CRUD API 및 수정 다이얼로그 개선

- YouTube 봇 전용 API 라우트 추가 (GET/POST/PUT/DELETE /api/admin/youtube-bots)
- 봇 목록 API에 YouTube 봇 상세 정보 포함 (db_id, channel_id 등)
- 수정 다이얼로그에서 useQuery로 봇 데이터 조회
- 채널 배너 이미지 표시 추가
- Fastify 스키마에 additionalProperties 설정으로 auto_schedule_config 정상 반환

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-02-07 10:43:06 +09:00
parent a8c12aa76d
commit ec3839bcc7
7 changed files with 571 additions and 41 deletions

View file

@ -239,7 +239,7 @@ async function schedulerPlugin(fastify, opts) {
startAll, startAll,
stopAll, stopAll,
getStatus, getStatus,
getBots: () => getAllBots(), getBots: (forceRefresh = false) => getAllBots(forceRefresh),
invalidateCache, invalidateCache,
}); });

View file

@ -18,6 +18,17 @@ const botResponse = {
check_interval: { type: 'integer' }, check_interval: { type: 'integer' },
error_message: { type: 'string' }, error_message: { type: 'string' },
enabled: { type: 'boolean' }, enabled: { type: 'boolean' },
// YouTube 봇 전용 필드
db_id: { type: 'integer' },
channel_id: { type: 'string' },
channel_handle: { type: 'string' },
channel_name: { type: 'string' },
banner_url: { type: 'string' },
cron_interval: { type: 'integer' },
title_filters: { type: 'array', items: { type: 'string' } },
default_member_ids: { type: 'array', items: { type: 'integer' } },
extract_members_from_desc: { type: 'boolean' },
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
}, },
}; };
@ -56,7 +67,8 @@ export default async function botsRoutes(fastify) {
}, },
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
const allBots = await scheduler.getBots(); // API 호출 시에는 항상 fresh한 데이터 반환
const allBots = await scheduler.getBots(true);
const result = []; const result = [];
for (const bot of allBots) { for (const bot of allBots) {
@ -72,7 +84,7 @@ export default async function botsRoutes(fastify) {
checkInterval = 1440; // 24시간 = 1440분 checkInterval = 1440; // 24시간 = 1440분
} }
result.push({ const botData = {
id: bot.id, id: bot.id,
name: bot.name || bot.channelName || bot.username || bot.id, name: bot.name || bot.channelName || bot.username || bot.id,
type: bot.type, type: bot.type,
@ -84,7 +96,24 @@ export default async function botsRoutes(fastify) {
check_interval: checkInterval, check_interval: checkInterval,
error_message: status.errorMessage, error_message: status.errorMessage,
enabled: bot.enabled, enabled: bot.enabled,
}); };
// YouTube 봇인 경우 상세 정보 추가
if (bot.type === 'youtube') {
fastify.log.info(`YouTube bot dbId: ${bot.dbId}`);
botData.db_id = bot.dbId;
botData.channel_id = bot.channelId;
botData.channel_handle = bot.channelHandle;
botData.channel_name = bot.channelName;
botData.banner_url = bot.bannerUrl;
botData.cron_interval = checkInterval;
botData.title_filters = bot.titleFilters || [];
botData.default_member_ids = bot.defaultMemberIds || [];
botData.extract_members_from_desc = bot.extractMembersFromDesc || false;
botData.auto_schedule_config = bot.autoScheduleNext || null;
}
result.push(botData);
} }
return result; return result;

View file

@ -0,0 +1,358 @@
import { errorResponse } from '../../schemas/index.js';
import { badRequest, notFound, serverError } from '../../utils/error.js';
/**
* YouTube 스키마
*/
const youtubeBotResponse = {
type: 'object',
properties: {
id: { type: 'integer' },
channel_id: { type: 'string' },
channel_handle: { type: 'string' },
channel_name: { type: 'string' },
banner_url: { type: 'string' },
cron_interval: { type: 'integer' },
enabled: { type: 'boolean' },
title_filters: { type: 'array', items: { type: 'string' } },
default_member_ids: { type: 'array', items: { type: 'integer' } },
extract_members_from_desc: { type: 'boolean' },
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
},
};
const youtubeBotIdParam = {
type: 'object',
properties: {
id: { type: 'integer', description: 'YouTube 봇 DB ID' },
},
required: ['id'],
};
/**
* DB row를 API 응답 형식으로 변환
*/
function formatBotResponse(row, fastify) {
if (fastify) {
fastify.log.info(`DB row.auto_schedule_config: ${JSON.stringify(row.auto_schedule_config)}, type: ${typeof row.auto_schedule_config}`);
}
return {
id: row.id,
channel_id: row.channel_id,
channel_handle: row.channel_handle,
channel_name: row.channel_name,
banner_url: row.banner_url,
cron_interval: row.cron_interval,
enabled: row.enabled === 1,
title_filters: row.title_filters
? (typeof row.title_filters === 'string'
? JSON.parse(row.title_filters)
: row.title_filters)
: [],
default_member_ids: row.default_member_ids
? (typeof row.default_member_ids === 'string'
? JSON.parse(row.default_member_ids)
: row.default_member_ids)
: [],
extract_members_from_desc: row.extract_members_from_desc === 1,
auto_schedule_config: row.auto_schedule_config
? (typeof row.auto_schedule_config === 'string'
? JSON.parse(row.auto_schedule_config)
: row.auto_schedule_config)
: null,
};
}
/**
* YouTube 관리 라우트
*/
export default async function youtubeBotsRoutes(fastify) {
const { db, scheduler } = fastify;
/**
* GET /api/admin/youtube-bots
* YouTube 목록 조회
*/
fastify.get('/', {
schema: {
tags: ['admin/youtube-bots'],
summary: 'YouTube 봇 목록 조회',
security: [{ bearerAuth: [] }],
response: {
200: {
type: 'array',
items: youtubeBotResponse,
},
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const [rows] = await db.query('SELECT * FROM youtube_bots ORDER BY id');
return rows.map(formatBotResponse);
});
/**
* GET /api/admin/youtube-bots/:id
* YouTube 상세 조회
*/
fastify.get('/:id', {
schema: {
tags: ['admin/youtube-bots'],
summary: 'YouTube 봇 상세 조회',
security: [{ bearerAuth: [] }],
params: youtubeBotIdParam,
response: {
200: youtubeBotResponse,
404: errorResponse,
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params;
const [rows] = await db.query('SELECT * FROM youtube_bots WHERE id = ?', [id]);
if (rows.length === 0) {
return notFound(reply, 'YouTube 봇을 찾을 수 없습니다.');
}
return formatBotResponse(rows[0], fastify);
});
/**
* POST /api/admin/youtube-bots
* YouTube 추가
*/
fastify.post('/', {
schema: {
tags: ['admin/youtube-bots'],
summary: 'YouTube 봇 추가',
security: [{ bearerAuth: [] }],
body: {
type: 'object',
properties: {
channel_id: { type: 'string' },
channel_handle: { type: 'string' },
channel_name: { type: 'string' },
banner_url: { type: 'string' },
cron_interval: { type: 'integer', default: 2 },
title_filters: { type: 'array', items: { type: 'string' } },
default_member_ids: { type: 'array', items: { type: 'integer' } },
extract_members_from_desc: { type: 'boolean', default: false },
auto_schedule_config: { type: 'object' },
},
required: ['channel_id', 'channel_name'],
},
response: {
201: youtubeBotResponse,
400: errorResponse,
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const {
channel_id,
channel_handle,
channel_name,
banner_url,
cron_interval = 2,
title_filters,
default_member_ids,
extract_members_from_desc = false,
auto_schedule_config,
} = request.body;
// 중복 체크
const [existing] = await db.query(
'SELECT id FROM youtube_bots WHERE channel_id = ?',
[channel_id]
);
if (existing.length > 0) {
return badRequest(reply, '이미 등록된 채널입니다.');
}
const [result] = await db.query(
`INSERT INTO youtube_bots
(channel_id, channel_handle, channel_name, banner_url, cron_interval,
title_filters, default_member_ids, extract_members_from_desc, auto_schedule_config, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
[
channel_id,
channel_handle || null,
channel_name,
banner_url || null,
cron_interval,
title_filters ? JSON.stringify(title_filters) : null,
default_member_ids ? JSON.stringify(default_member_ids) : null,
extract_members_from_desc ? 1 : 0,
auto_schedule_config ? JSON.stringify(auto_schedule_config) : null,
]
);
// 스케줄러 캐시 무효화 및 봇 시작
scheduler.invalidateCache();
const botId = `youtube-${result.insertId}`;
try {
await scheduler.startBot(botId);
} catch (err) {
fastify.log.error(`[${botId}] 봇 시작 실패:`, err);
}
const [newBot] = await db.query('SELECT * FROM youtube_bots WHERE id = ?', [result.insertId]);
reply.code(201);
return formatBotResponse(newBot[0]);
});
/**
* PUT /api/admin/youtube-bots/:id
* YouTube 수정
*/
fastify.put('/:id', {
schema: {
tags: ['admin/youtube-bots'],
summary: 'YouTube 봇 수정',
security: [{ bearerAuth: [] }],
params: youtubeBotIdParam,
body: {
type: 'object',
properties: {
channel_handle: { type: 'string' },
channel_name: { type: 'string' },
banner_url: { type: 'string' },
cron_interval: { type: 'integer' },
title_filters: { type: 'array', items: { type: 'string' } },
default_member_ids: { type: 'array', items: { type: 'integer' } },
extract_members_from_desc: { type: 'boolean' },
auto_schedule_config: { type: 'object' },
enabled: { type: 'boolean' },
},
},
response: {
200: youtubeBotResponse,
404: errorResponse,
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params;
const updates = request.body;
// 존재 확인
const [existing] = await db.query('SELECT * FROM youtube_bots WHERE id = ?', [id]);
if (existing.length === 0) {
return notFound(reply, 'YouTube 봇을 찾을 수 없습니다.');
}
// 동적 업데이트 쿼리 생성
const fields = [];
const values = [];
if (updates.channel_handle !== undefined) {
fields.push('channel_handle = ?');
values.push(updates.channel_handle);
}
if (updates.channel_name !== undefined) {
fields.push('channel_name = ?');
values.push(updates.channel_name);
}
if (updates.banner_url !== undefined) {
fields.push('banner_url = ?');
values.push(updates.banner_url);
}
if (updates.cron_interval !== undefined) {
fields.push('cron_interval = ?');
values.push(updates.cron_interval);
}
if (updates.title_filters !== undefined) {
fields.push('title_filters = ?');
values.push(JSON.stringify(updates.title_filters));
}
if (updates.default_member_ids !== undefined) {
fields.push('default_member_ids = ?');
values.push(JSON.stringify(updates.default_member_ids));
}
if (updates.extract_members_from_desc !== undefined) {
fields.push('extract_members_from_desc = ?');
values.push(updates.extract_members_from_desc ? 1 : 0);
}
if (updates.auto_schedule_config !== undefined) {
fields.push('auto_schedule_config = ?');
values.push(updates.auto_schedule_config ? JSON.stringify(updates.auto_schedule_config) : null);
}
if (updates.enabled !== undefined) {
fields.push('enabled = ?');
values.push(updates.enabled ? 1 : 0);
}
if (fields.length > 0) {
values.push(id);
await db.query(
`UPDATE youtube_bots SET ${fields.join(', ')} WHERE id = ?`,
values
);
// 스케줄러 캐시 무효화 및 봇 재시작
scheduler.invalidateCache();
const botId = `youtube-${id}`;
try {
await scheduler.stopBot(botId);
if (updates.enabled !== false && existing[0].enabled === 1) {
await scheduler.startBot(botId);
} else if (updates.enabled === true) {
await scheduler.startBot(botId);
}
} catch (err) {
fastify.log.error(`[${botId}] 봇 재시작 실패:`, err);
}
}
const [updatedBot] = await db.query('SELECT * FROM youtube_bots WHERE id = ?', [id]);
return formatBotResponse(updatedBot[0]);
});
/**
* DELETE /api/admin/youtube-bots/:id
* YouTube 삭제
*/
fastify.delete('/:id', {
schema: {
tags: ['admin/youtube-bots'],
summary: 'YouTube 봇 삭제',
security: [{ bearerAuth: [] }],
params: youtubeBotIdParam,
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
},
},
404: errorResponse,
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params;
// 존재 확인
const [existing] = await db.query('SELECT * FROM youtube_bots WHERE id = ?', [id]);
if (existing.length === 0) {
return notFound(reply, 'YouTube 봇을 찾을 수 없습니다.');
}
// 봇 정지
const botId = `youtube-${id}`;
try {
await scheduler.stopBot(botId);
} catch (err) {
// 이미 정지된 경우 무시
}
// DB에서 삭제
await db.query('DELETE FROM youtube_bots WHERE id = ?', [id]);
// 스케줄러 캐시 무효화
scheduler.invalidateCache();
return { success: true };
});
}

View file

@ -4,6 +4,7 @@ import albumsRoutes from './albums/index.js';
import schedulesRoutes from './schedules/index.js'; import schedulesRoutes from './schedules/index.js';
import statsRoutes from './stats/index.js'; import statsRoutes from './stats/index.js';
import botsRoutes from './admin/bots.js'; import botsRoutes from './admin/bots.js';
import youtubeBotsRoutes from './admin/youtube-bots.js';
import youtubeAdminRoutes from './admin/youtube.js'; import youtubeAdminRoutes from './admin/youtube.js';
import xAdminRoutes from './admin/x.js'; import xAdminRoutes from './admin/x.js';
import concertAdminRoutes from './admin/concert.js'; import concertAdminRoutes from './admin/concert.js';
@ -32,6 +33,9 @@ export default async function routes(fastify) {
// 관리자 - 봇 라우트 // 관리자 - 봇 라우트
fastify.register(botsRoutes, { prefix: '/admin/bots' }); fastify.register(botsRoutes, { prefix: '/admin/bots' });
// 관리자 - YouTube 봇 라우트
fastify.register(youtubeBotsRoutes, { prefix: '/admin/youtube-bots' });
// 관리자 - YouTube 라우트 // 관리자 - YouTube 라우트
fastify.register(youtubeAdminRoutes, { prefix: '/admin/youtube' }); fastify.register(youtubeAdminRoutes, { prefix: '/admin/youtube' });

View file

@ -11,6 +11,49 @@ export async function getBots() {
return fetchAuthApi('/admin/bots'); return fetchAuthApi('/admin/bots');
} }
/**
* YouTube 상세 조회
* @param {number} id - YouTube DB ID
* @returns {Promise<object>}
*/
export async function getYouTubeBot(id) {
return fetchAuthApi(`/admin/youtube-bots/${id}`);
}
/**
* YouTube 추가
* @param {object} data - 데이터
* @returns {Promise<object>}
*/
export async function createYouTubeBot(data) {
return fetchAuthApi('/admin/youtube-bots', {
method: 'POST',
body: JSON.stringify(data),
});
}
/**
* YouTube 수정
* @param {number} id - YouTube DB ID
* @param {object} data - 업데이트할 데이터
* @returns {Promise<object>}
*/
export async function updateYouTubeBot(id, data) {
return fetchAuthApi(`/admin/youtube-bots/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
/**
* YouTube 삭제
* @param {number} id - YouTube DB ID
* @returns {Promise<object>}
*/
export async function deleteYouTubeBot(id) {
return fetchAuthApi(`/admin/youtube-bots/${id}`, { method: 'DELETE' });
}
/** /**
* 시작 * 시작
* @param {string} id - ID * @param {string} id - ID

View file

@ -3,9 +3,11 @@
*/ */
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Youtube, Search, X, ChevronDown, ChevronUp } from 'lucide-react'; import { Youtube, Search, X, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
import { getMembers } from '@/api/public/members'; import { getMembers } from '@/api/public/members';
import { getYouTubeBot, createYouTubeBot, updateYouTubeBot } from '@/api/admin/bots';
// //
const INTERVAL_OPTIONS = [ const INTERVAL_OPTIONS = [
@ -252,14 +254,16 @@ function MultiSelect({ values = [], options, onChange, placeholder = '선택', c
); );
} }
function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) { function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
const isEdit = !!bot; const queryClient = useQueryClient();
const isEdit = !!botId;
// //
const [handle, setHandle] = useState(''); const [handle, setHandle] = useState('');
const [channelInfo, setChannelInfo] = useState(null); const [channelInfo, setChannelInfo] = useState(null);
const [lookupLoading, setLookupLoading] = useState(false); const [lookupLoading, setLookupLoading] = useState(false);
const [interval, setInterval] = useState(2); const [interval, setInterval] = useState(2);
const [submitting, setSubmitting] = useState(false);
// //
const [autoScheduleEnabled, setAutoScheduleEnabled] = useState(false); const [autoScheduleEnabled, setAutoScheduleEnabled] = useState(false);
@ -278,6 +282,14 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
// ( ) // ( )
const [members, setMembers] = useState([]); const [members, setMembers] = useState([]);
// YouTube ( )
const { data: bot, isLoading: botLoading } = useQuery({
queryKey: ['admin', 'youtube-bot', botId],
queryFn: () => getYouTubeBot(botId),
enabled: isOpen && !!botId,
staleTime: 0, // fresh
});
// //
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
@ -287,36 +299,62 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
} }
}, [isOpen]); }, [isOpen]);
// // (/ )
useEffect(() => { useEffect(() => {
if (!isOpen) {
return; //
}
if (bot) { if (bot) {
// :
setHandle(bot.channel_handle || ''); setHandle(bot.channel_handle || '');
setChannelInfo({ setChannelInfo({
channelId: bot.channel_id, channelId: bot.channel_id,
title: bot.channel_name, title: bot.channel_name,
bannerUrl: bot.banner_url,
}); });
setInterval(bot.cron_interval || 2); setInterval(bot.cron_interval || 2);
if (bot.auto_schedule_config) { console.log('bot.auto_schedule_config:', bot.auto_schedule_config);
const config = typeof bot.auto_schedule_config === 'string' console.log('typeof:', typeof bot.auto_schedule_config);
? JSON.parse(bot.auto_schedule_config)
: bot.auto_schedule_config; const config = bot.auto_schedule_config
? (typeof bot.auto_schedule_config === 'string'
? JSON.parse(bot.auto_schedule_config)
: bot.auto_schedule_config)
: null;
console.log('parsed config:', config);
// config dayOfWeek
if (config && config.dayOfWeek !== undefined) {
setAutoScheduleEnabled(true); setAutoScheduleEnabled(true);
setScheduleDayOfWeek(config.dayOfWeek ?? 4); setScheduleDayOfWeek(config.dayOfWeek);
setScheduleTime(config.time?.slice(0, 5) || '18:00'); setScheduleTime(config.time?.slice(0, 5) || '18:00');
setTitleTemplate(config.titleTemplate || '{channelName} {episode}화'); setTitleTemplate(config.titleTemplate || '{channelName} {episode}화');
setDeadlineDayOfWeek(config.deadlineDayOfWeek ?? 5); setDeadlineDayOfWeek(config.deadlineDayOfWeek ?? 5);
} else {
setAutoScheduleEnabled(false);
setScheduleDayOfWeek(4);
setScheduleTime('18:00');
setTitleTemplate('{channelName} {episode}화');
setDeadlineDayOfWeek(5);
} }
setTitleFilters(bot.title_filters || []); setTitleFilters(bot.title_filters || []);
setDefaultMemberIds(bot.default_member_ids || []); setDefaultMemberIds(bot.default_member_ids || []);
setExtractMembers(bot.extract_members_from_desc || false); setExtractMembers(bot.extract_members_from_desc || false);
}
}, [bot]);
// //
useEffect(() => { if ((bot.title_filters && bot.title_filters.length > 0) ||
if (!isOpen) { (bot.default_member_ids && bot.default_member_ids.length > 0) ||
bot.extract_members_from_desc) {
setShowAdvanced(true);
} else {
setShowAdvanced(false);
}
} else if (!botId) {
// :
setHandle(''); setHandle('');
setChannelInfo(null); setChannelInfo(null);
setInterval(2); setInterval(2);
@ -331,7 +369,7 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
setDefaultMemberIds([]); setDefaultMemberIds([]);
setExtractMembers(false); setExtractMembers(false);
} }
}, [isOpen]); }, [isOpen, bot, botId]);
// //
const handleLookup = async () => { const handleLookup = async () => {
@ -349,10 +387,48 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
}; };
// //
const handleSubmit = (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
// TODO: onSubmit if (!channelInfo) return;
onClose();
setSubmitting(true);
try {
const data = {
channel_handle: handle || null,
channel_name: channelInfo.title,
cron_interval: interval,
title_filters: titleFilters.length > 0 ? titleFilters : null,
default_member_ids: defaultMemberIds.length > 0 ? defaultMemberIds : null,
extract_members_from_desc: extractMembers,
auto_schedule_config: autoScheduleEnabled
? {
dayOfWeek: scheduleDayOfWeek,
time: `${scheduleTime}:00`,
titleTemplate,
deadlineDayOfWeek,
}
: null,
};
if (isEdit) {
await updateYouTubeBot(botId, data);
} else {
data.channel_id = channelInfo.channelId;
await createYouTubeBot(data);
}
//
queryClient.invalidateQueries({ queryKey: ['admin', 'bots'] });
queryClient.invalidateQueries({ queryKey: ['admin', 'youtube-bot'] });
onSuccess?.();
onClose();
} catch (error) {
console.error('봇 저장 실패:', error);
alert(error.message || '봇 저장에 실패했습니다.');
} finally {
setSubmitting(false);
}
}; };
return createPortal( return createPortal(
@ -390,6 +466,11 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
</div> </div>
{/* 본문 */} {/* 본문 */}
{botLoading ? (
<div className="flex-1 flex items-center justify-center p-12">
<Loader2 size={32} className="animate-spin text-red-500" />
</div>
) : (
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-6 space-y-5"> <form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-6 space-y-5">
{/* 채널 핸들 */} {/* 채널 핸들 */}
<div> <div>
@ -426,13 +507,24 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
</div> </div>
{/* 채널 정보 표시 */} {/* 채널 정보 표시 */}
{channelInfo && ( {channelInfo && (
<div className="mt-2 p-3 bg-gray-50 rounded-lg flex items-center gap-3"> <div className="mt-2 bg-gray-50 rounded-lg overflow-hidden">
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center"> {channelInfo.bannerUrl && (
<Youtube size={20} className="text-gray-400" /> <div className="h-20 overflow-hidden">
</div> <img
<div className="flex-1 min-w-0"> src={channelInfo.bannerUrl}
<p className="font-medium text-gray-900 truncate">{channelInfo.title}</p> alt="채널 배너"
<p className="text-xs text-gray-500">{channelInfo.channelId}</p> className="w-full h-full object-cover"
/>
</div>
)}
<div className="p-3 flex items-center gap-3">
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
<Youtube size={20} className="text-gray-400" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate">{channelInfo.title}</p>
<p className="text-xs text-gray-500">{channelInfo.channelId}</p>
</div>
</div> </div>
</div> </div>
)} )}
@ -627,22 +719,25 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
)} )}
</div> </div>
</form> </form>
)}
{/* 푸터 */} {/* 푸터 */}
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 bg-gray-50"> <div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 bg-gray-50">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="px-4 py-2.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 rounded-lg transition-colors" disabled={submitting}
className="px-4 py-2.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
> >
취소 취소
</button> </button>
<button <button
type="submit" type="submit"
onClick={handleSubmit} onClick={handleSubmit}
disabled={!channelInfo} disabled={!channelInfo || submitting || botLoading}
className="px-4 py-2.5 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2.5 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
> >
{submitting && <Loader2 size={16} className="animate-spin" />}
{isEdit ? '수정' : '추가'} {isEdit ? '수정' : '추가'}
</button> </button>
</div> </div>

View file

@ -61,7 +61,7 @@ function ScheduleBots() {
const [syncing, setSyncing] = useState(null); // ID const [syncing, setSyncing] = useState(null); // ID
const [quotaWarning, setQuotaWarning] = useState(null); // const [quotaWarning, setQuotaWarning] = useState(null); //
const [botDialogOpen, setBotDialogOpen] = useState(false); // / const [botDialogOpen, setBotDialogOpen] = useState(false); // /
const [editingBot, setEditingBot] = useState(null); // const [editingBotId, setEditingBotId] = useState(null); // DB ID
// //
const { const {
@ -73,7 +73,7 @@ function ScheduleBots() {
queryKey: ['admin', 'bots'], queryKey: ['admin', 'bots'],
queryFn: botsApi.getBots, queryFn: botsApi.getBots,
enabled: isAuthenticated, enabled: isAuthenticated,
staleTime: 30000, staleTime: 0, // fresh
}); });
// //
@ -241,12 +241,11 @@ function ScheduleBots() {
isOpen={botDialogOpen} isOpen={botDialogOpen}
onClose={() => { onClose={() => {
setBotDialogOpen(false); setBotDialogOpen(false);
setEditingBot(null); setEditingBotId(null);
}} }}
bot={editingBot} botId={editingBotId}
onSubmit={(data) => { onSuccess={() => {
// TODO: API setToast({ type: 'success', message: editingBotId ? '봇이 수정되었습니다.' : '봇이 추가되었습니다.' });
console.log('submit', data);
}} }}
/> />
@ -369,7 +368,7 @@ function ScheduleBots() {
{section.canAdd && ( {section.canAdd && (
<button <button
onClick={() => { onClick={() => {
setEditingBot(null); setEditingBotId(null);
setBotDialogOpen(true); setBotDialogOpen(true);
}} }}
className="flex items-center gap-1.5 px-3 py-1.5 bg-white border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" className="flex items-center gap-1.5 px-3 py-1.5 bg-white border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
@ -415,7 +414,9 @@ function ScheduleBots() {
onSync={handleSyncAllVideos} onSync={handleSyncAllVideos}
onToggle={toggleBot} onToggle={toggleBot}
onEdit={(bot) => { onEdit={(bot) => {
setEditingBot(bot); console.log('Edit bot:', bot);
console.log('db_id:', bot.db_id);
setEditingBotId(bot.db_id);
setBotDialogOpen(true); setBotDialogOpen(true);
}} }}
onDelete={(bot) => {/* TODO: 봇 삭제 확인 */}} onDelete={(bot) => {/* TODO: 봇 삭제 확인 */}}