/** * YouTube 봇 추가/수정 다이얼로그 */ 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 { Youtube, Search, X, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'; import { getMembers } from '@/api/public/members'; import { getYouTubeBot, createYouTubeBot, updateYouTubeBot } 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시간' }, ]; // 요일 옵션 const DAY_OPTIONS = [ { value: 0, label: '일요일' }, { value: 1, label: '월요일' }, { value: 2, label: '화요일' }, { value: 3, label: '수요일' }, { value: 4, label: '목요일' }, { value: 5, label: '금요일' }, { value: 6, label: '토요일' }, ]; // 시간 옵션 (00:00 ~ 23:00) const TIME_OPTIONS = Array.from({ length: 24 }, (_, i) => ({ value: `${String(i).padStart(2, '0')}:00`, label: `${String(i).padStart(2, '0')}:00`, })); /** * 커스텀 드롭다운 컴포넌트 (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 MultiSelect({ values = [], 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 selectedOptions = options.filter((opt) => values.includes(opt.value)); const displayText = selectedOptions.length > 0 ? selectedOptions.map((o) => o.label).join(', ') : placeholder; const toggleValue = (val) => { if (values.includes(val)) { onChange(values.filter((v) => v !== val)); } else { onChange([...values, val]); } }; return (
{createPortal( {isOpen && ( {options.map((opt) => ( ))} )} , document.body )}
); } function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) { const queryClient = useQueryClient(); const isEdit = !!botId; // 폼 상태 const [handle, setHandle] = useState(''); const [channelInfo, setChannelInfo] = useState(null); const [lookupLoading, setLookupLoading] = useState(false); const [interval, setInterval] = useState(2); const [submitting, setSubmitting] = useState(false); // 예정 일정 설정 const [autoScheduleEnabled, setAutoScheduleEnabled] = useState(false); const [scheduleDayOfWeek, setScheduleDayOfWeek] = useState(4); const [scheduleTime, setScheduleTime] = useState('18:00'); const [titleTemplate, setTitleTemplate] = useState('{channelName} {episode}화'); const [deadlineDayOfWeek, setDeadlineDayOfWeek] = useState(5); // 고급 설정 const [showAdvanced, setShowAdvanced] = useState(false); const [titleFilters, setTitleFilters] = useState([]); const [filterInput, setFilterInput] = useState(''); const [defaultMemberIds, setDefaultMemberIds] = useState([]); const [extractMembers, setExtractMembers] = useState(false); // 멤버 목록 (탈퇴 멤버 제외) 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(() => { if (isOpen) { getMembers() .then((data) => setMembers(data.filter((m) => !m.is_former))) .catch(console.error); } }, [isOpen]); // 다이얼로그 열릴 때 데이터 설정 (수정/추가 모드) useEffect(() => { if (!isOpen) { return; // 닫혀있으면 아무것도 안 함 } if (bot) { // 수정 모드: 기존 데이터 로드 setHandle(bot.channel_handle || ''); setChannelInfo({ channelId: bot.channel_id, title: bot.channel_name, bannerUrl: bot.banner_url, }); setInterval(bot.cron_interval || 2); console.log('bot.auto_schedule_config:', bot.auto_schedule_config); console.log('typeof:', typeof 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); setScheduleDayOfWeek(config.dayOfWeek); setScheduleTime(config.time?.slice(0, 5) || '18:00'); setTitleTemplate(config.titleTemplate || '{channelName} {episode}화'); setDeadlineDayOfWeek(config.deadlineDayOfWeek ?? 5); } else { setAutoScheduleEnabled(false); setScheduleDayOfWeek(4); setScheduleTime('18:00'); setTitleTemplate('{channelName} {episode}화'); setDeadlineDayOfWeek(5); } setTitleFilters(bot.title_filters || []); setDefaultMemberIds(bot.default_member_ids || []); setExtractMembers(bot.extract_members_from_desc || false); // 고급 설정이 있으면 펼침 if ((bot.title_filters && bot.title_filters.length > 0) || (bot.default_member_ids && bot.default_member_ids.length > 0) || bot.extract_members_from_desc) { setShowAdvanced(true); } else { setShowAdvanced(false); } } else if (!botId) { // 추가 모드: 초기값으로 리셋 setHandle(''); setChannelInfo(null); setInterval(2); setAutoScheduleEnabled(false); setScheduleDayOfWeek(4); setScheduleTime('18:00'); setTitleTemplate('{channelName} {episode}화'); setDeadlineDayOfWeek(5); setShowAdvanced(false); setTitleFilters([]); setFilterInput(''); setDefaultMemberIds([]); setExtractMembers(false); } }, [isOpen, bot, botId]); // 채널 조회 const handleLookup = async () => { if (!handle.trim()) return; setLookupLoading(true); // TODO: API 호출 setTimeout(() => { setChannelInfo({ channelId: 'UC_EXAMPLE_ID', title: '예시 채널명', thumbnailUrl: null, }); setLookupLoading(false); }, 1000); }; // 제출 const handleSubmit = async (e) => { e.preventDefault(); if (!channelInfo) return; 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( {isOpen && ( e.stopPropagation()} > {/* 헤더 */}

{isEdit ? 'YouTube 봇 수정' : 'YouTube 봇 추가'}

{/* 본문 */} {botLoading ? (
) : (
{/* 채널 핸들 */}
@ setHandle(e.target.value)} placeholder="studiofromis_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-red-500/20 focus:border-red-500 disabled:bg-gray-50 disabled:text-gray-500" />
{!isEdit && ( )}
{/* 채널 정보 표시 */} {channelInfo && (
{channelInfo.bannerUrl && (
채널 배너
)}

{channelInfo.title}

{channelInfo.channelId}

)}
{/* 동기화 간격 */}
{/* 예정 일정 자동 생성 */}
setAutoScheduleEnabled(!autoScheduleEnabled)} >

예정 일정 자동 생성

매주 특정 요일에 임시 일정을 미리 생성합니다

{autoScheduleEnabled && (
{/* 요일 & 시간 */}
{/* 제목 템플릿 */}
setTitleTemplate(e.target.value)} placeholder="{channelName} {episode}화" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-red-500/20 focus:border-red-500" />

{'{channelName}'}: 채널명, {'{episode}'}: 회차 번호

{/* 마감 요일 */}

이 요일까지 영상이 없으면 예정 일정을 삭제합니다

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

키워드 중 하나라도 포함된 영상만 추가됩니다

{/* 고정 멤버 */}
({ value: m.id, label: m.name }))} onChange={setDefaultMemberIds} placeholder="멤버 선택" />

모든 영상에 선택한 멤버를 자동으로 연결합니다

{/* 멤버 추출 */}
setExtractMembers(!extractMembers)} >

설명에서 멤버 추출

영상 설명에서 멤버 이름을 찾아 자동 연결

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