/** * 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 { Search, X, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'; import { getXBot, createXBot, updateXBot, lookupXProfile } from '@/api/admin/bots'; import { XIcon } from './BotCard'; // 동기화 간격 옵션 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); // 고급 설정 const [showAdvanced, setShowAdvanced] = useState(false); const [textFilters, setTextFilters] = useState([]); const [filterInput, setFilterInput] = useState(''); const [includeRetweets, setIncludeRetweets] = useState(false); const [extractYoutube, setExtractYoutube] = useState(false); const [excludeManagedChannels, setExcludeManagedChannels] = useState(true); // 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); setTextFilters(bot.text_filters || []); setIncludeRetweets(bot.include_retweets || false); setExtractYoutube(bot.extract_youtube || false); setExcludeManagedChannels(bot.exclude_managed_channels ?? true); setShowAdvanced((bot.text_filters && bot.text_filters.length > 0) || bot.include_retweets || bot.extract_youtube || false); } else if (!botId) { // 추가 모드 setUsername(''); setProfileInfo(null); setInterval(1); setTextFilters([]); setFilterInput(''); setIncludeRetweets(false); setExtractYoutube(false); setShowAdvanced(false); } }, [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, text_filters: textFilters.length > 0 ? textFilters : null, include_retweets: includeRetweets, extract_youtube: extractYoutube, exclude_managed_channels: excludeManagedChannels, 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}

)}
{/* 동기화 간격 */}
{/* 고급 설정 */}
{showAdvanced && (
{/* 리트윗 포함 */}

리트윗도 일정에 추가합니다

{/* YouTube 영상 추출 */}

트윗에 YouTube 링크가 있으면 유튜브 일정에 추가합니다

{/* 관리 중인 채널 제외 (extractYoutube 활성 시만) */} {extractYoutube && (

등록된 YouTube 봇 채널의 영상은 트윗에서 중복 추가하지 않습니다

)} {/* 텍스트 필터 */}
{textFilters.map((filter, idx) => ( {filter} ))} setFilterInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && filterInput.trim()) { e.preventDefault(); if (!textFilters.includes(filterInput.trim())) { setTextFilters([...textFilters, filterInput.trim()]); } setFilterInput(''); } }} placeholder={textFilters.length === 0 ? '키워드 입력 후 Enter' : ''} className="flex-1 min-w-[120px] outline-none text-sm" />

키워드 중 하나라도 포함된 트윗만 추가됩니다 (비어있으면 모든 트윗)

)}
)} {/* 푸터 */}
)}
, document.body ); } export default XBotDialog;