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 ? ( +
+ +
+ ) : ( +
+ {/* Username */} +
+ +
+
+ @ + setUsername(e.target.value)} + placeholder="realfromis_9" + disabled={isEdit} + className="w-full pl-8 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500/20 focus:border-sky-500 disabled:bg-gray-50 disabled:text-gray-500" + /> +
+ {!isEdit && ( + + )} +
+ {/* 프로필 정보 표시 */} + {profileInfo && ( +
+ {profileInfo.avatarUrl ? ( + {profileInfo.displayName} + ) : ( +
+ +
+ )} +
+

+ {profileInfo.displayName} +

+

@{profileInfo.username}

+
+
+ )} +
+ + {/* 동기화 간격 */} +
+ + +
+
+ )} + + {/* 푸터 */} +
+ + +
+
+
+ )} +
, + 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() {