diff --git a/frontend/src/components/pc/admin/bot/XBotDialog.jsx b/frontend/src/components/pc/admin/bot/XBotDialog.jsx
new file mode 100644
index 0000000..9b3977e
--- /dev/null
+++ b/frontend/src/components/pc/admin/bot/XBotDialog.jsx
@@ -0,0 +1,353 @@
+/**
+ * X 봇 추가/수정 다이얼로그
+ */
+import { useState, useEffect, useRef } from 'react';
+import { createPortal } from 'react-dom';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { motion, AnimatePresence } from 'framer-motion';
+import { Twitter, Search, X, ChevronDown, Loader2 } from 'lucide-react';
+import { getXBot, createXBot, updateXBot, lookupXProfile } from '@/api/admin/bots';
+
+// 동기화 간격 옵션
+const INTERVAL_OPTIONS = [
+ { value: 1, label: '1분' },
+ { value: 2, label: '2분' },
+ { value: 5, label: '5분' },
+ { value: 10, label: '10분' },
+ { value: 30, label: '30분' },
+ { value: 60, label: '1시간' },
+];
+
+/**
+ * 커스텀 드롭다운 컴포넌트 (Portal 사용)
+ */
+function Dropdown({ value, options, onChange, placeholder = '선택', className = '' }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [position, setPosition] = useState({ top: 0, left: 0, width: 0 });
+ const buttonRef = useRef(null);
+ const menuRef = useRef(null);
+
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (
+ buttonRef.current &&
+ !buttonRef.current.contains(event.target) &&
+ menuRef.current &&
+ !menuRef.current.contains(event.target)
+ ) {
+ setIsOpen(false);
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ }
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [isOpen]);
+
+ useEffect(() => {
+ if (isOpen && buttonRef.current) {
+ const rect = buttonRef.current.getBoundingClientRect();
+ setPosition({
+ top: rect.bottom + 4,
+ left: rect.left,
+ width: rect.width,
+ });
+ }
+ }, [isOpen]);
+
+ const selectedOption = options.find((opt) => opt.value === value);
+
+ return (
+
+
+ {createPortal(
+
+ {isOpen && (
+
+ {options.map((opt) => (
+
+ ))}
+
+ )}
+ ,
+ document.body
+ )}
+
+ );
+}
+
+function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
+ const queryClient = useQueryClient();
+ const isEdit = !!botId;
+
+ // 폼 상태
+ const [username, setUsername] = useState('');
+ const [profileInfo, setProfileInfo] = useState(null);
+ const [lookupLoading, setLookupLoading] = useState(false);
+ const [interval, setInterval] = useState(1);
+ const [submitting, setSubmitting] = useState(false);
+
+ // X 봇 상세 조회 (수정 모드)
+ const { data: bot, isLoading: botLoading } = useQuery({
+ queryKey: ['admin', 'x-bot', botId],
+ queryFn: () => getXBot(botId),
+ enabled: isOpen && !!botId,
+ staleTime: 0,
+ });
+
+ // 다이얼로그 열릴 때 데이터 설정
+ useEffect(() => {
+ if (!isOpen) return;
+
+ if (bot) {
+ // 수정 모드
+ setUsername(bot.username || '');
+ setProfileInfo({
+ username: bot.username,
+ displayName: bot.display_name,
+ avatarUrl: bot.avatar_url,
+ });
+ setInterval(bot.cron_interval || 1);
+ } else if (!botId) {
+ // 추가 모드
+ setUsername('');
+ setProfileInfo(null);
+ setInterval(1);
+ }
+ }, [isOpen, bot, botId]);
+
+ // 프로필 조회
+ const handleLookup = async () => {
+ if (!username.trim()) return;
+ setLookupLoading(true);
+ try {
+ const data = await lookupXProfile(username);
+ setProfileInfo({
+ username: data.username,
+ displayName: data.displayName,
+ avatarUrl: data.avatarUrl,
+ });
+ } catch (error) {
+ console.error('프로필 조회 실패:', error);
+ alert(error.message || '프로필을 찾을 수 없습니다.');
+ } finally {
+ setLookupLoading(false);
+ }
+ };
+
+ // 제출
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!profileInfo) return;
+
+ setSubmitting(true);
+ try {
+ const data = {
+ username: profileInfo.username,
+ display_name: profileInfo.displayName,
+ avatar_url: profileInfo.avatarUrl,
+ cron_interval: interval,
+ };
+
+ if (isEdit) {
+ await updateXBot(botId, data);
+ } else {
+ await createXBot(data);
+ }
+
+ // 캐시 무효화
+ queryClient.invalidateQueries({ queryKey: ['admin', 'bots'] });
+ queryClient.invalidateQueries({ queryKey: ['admin', 'x-bot'] });
+
+ onSuccess?.();
+ onClose();
+ } catch (error) {
+ console.error('봇 저장 실패:', error);
+ alert(error.message || '봇 저장에 실패했습니다.');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return createPortal(
+
+ {isOpen && (
+
+ e.stopPropagation()}
+ >
+ {/* 헤더 */}
+
+
+
+
+
+
+ {isEdit ? 'X 봇 수정' : 'X 봇 추가'}
+
+
+
+
+
+ {/* 본문 */}
+ {botLoading ? (
+
+
+
+ ) : (
+
+ )}
+
+ {/* 푸터 */}
+
+
+
+
+
+
+ )}
+ ,
+ document.body
+ );
+}
+
+export default XBotDialog;
diff --git a/frontend/src/components/pc/admin/bot/index.js b/frontend/src/components/pc/admin/bot/index.js
index 0d93be3..9b869ee 100644
--- a/frontend/src/components/pc/admin/bot/index.js
+++ b/frontend/src/components/pc/admin/bot/index.js
@@ -1,2 +1,3 @@
export { default as BotCard, XIcon, MeilisearchIcon, BotListItem, BotMiniCard, BotTableRow, BotTable } from './BotCard';
export { default as YouTubeBotDialog } from './YouTubeBotDialog';
+export { default as XBotDialog } from './XBotDialog';
diff --git a/frontend/src/pages/pc/admin/schedules/ScheduleBots.jsx b/frontend/src/pages/pc/admin/schedules/ScheduleBots.jsx
index f4c298a..3565f4c 100644
--- a/frontend/src/pages/pc/admin/schedules/ScheduleBots.jsx
+++ b/frontend/src/pages/pc/admin/schedules/ScheduleBots.jsx
@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
import { Home, ChevronRight, Bot, CheckCircle, XCircle, RefreshCw, Plus, Youtube } from 'lucide-react';
import { Toast, Tooltip, AnimatedNumber } from '@/components/common';
-import { AdminLayout, XIcon, MeilisearchIcon, BotTableRow, BotTable, YouTubeBotDialog } from '@/components/pc/admin';
+import { AdminLayout, XIcon, MeilisearchIcon, BotTableRow, BotTable, YouTubeBotDialog, XBotDialog } from '@/components/pc/admin';
import { useAdminAuth } from '@/hooks/pc/admin';
import { useToast } from '@/hooks/common';
import * as botsApi from '@/api/admin/bots';
@@ -32,6 +32,7 @@ const SECTIONS = {
color: 'text-gray-700',
bgColor: 'bg-gray-50',
borderColor: 'border-gray-200',
+ canAdd: true,
},
};
@@ -60,8 +61,10 @@ function ScheduleBots() {
const [isInitialLoad, setIsInitialLoad] = useState(true); // 첫 로드 여부 (애니메이션용)
const [syncing, setSyncing] = useState(null); // 동기화 중인 봇 ID
const [quotaWarning, setQuotaWarning] = useState(null); // 할당량 경고 상태
- const [botDialogOpen, setBotDialogOpen] = useState(false); // 봇 추가/수정 다이얼로그
+ const [youtubeDialogOpen, setYoutubeDialogOpen] = useState(false); // YouTube 봇 다이얼로그
+ const [xDialogOpen, setXDialogOpen] = useState(false); // X 봇 다이얼로그
const [editingBotId, setEditingBotId] = useState(null); // 수정 중인 봇 DB ID
+ const [editingBotType, setEditingBotType] = useState(null); // 수정 중인 봇 타입
const [deletingBot, setDeletingBot] = useState(null); // 삭제할 봇
// 봇 목록 조회
@@ -161,7 +164,11 @@ function ScheduleBots() {
if (!deletingBot) return;
try {
- await botsApi.deleteYouTubeBot(deletingBot.db_id);
+ if (deletingBot.type === 'youtube') {
+ await botsApi.deleteYouTubeBot(deletingBot.db_id);
+ } else if (deletingBot.type === 'x') {
+ await botsApi.deleteXBot(deletingBot.db_id);
+ }
queryClient.invalidateQueries({ queryKey: ['admin', 'bots'] });
setToast({ type: 'success', message: `${deletingBot.name} 봇이 삭제되었습니다.` });
} catch (error) {
@@ -255,10 +262,23 @@ function ScheduleBots() {
setToast(null)} />
{
- setBotDialogOpen(false);
+ setYoutubeDialogOpen(false);
setEditingBotId(null);
+ setEditingBotType(null);
+ }}
+ botId={editingBotId}
+ onSuccess={() => {
+ setToast({ type: 'success', message: editingBotId ? '봇이 수정되었습니다.' : '봇이 추가되었습니다.' });
+ }}
+ />
+ {
+ setXDialogOpen(false);
+ setEditingBotId(null);
+ setEditingBotType(null);
}}
botId={editingBotId}
onSuccess={() => {
@@ -432,7 +452,12 @@ function ScheduleBots() {