fromis_9/frontend/src/components/pc/admin/bot/XBotDialog.jsx

503 lines
20 KiB
React
Raw Normal View History

/**
* 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 (
<div className={`relative ${className}`}>
<button
ref={buttonRef}
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 w-full px-4 py-2.5 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm transition-colors justify-between"
>
<span className={selectedOption ? 'text-gray-900' : 'text-gray-400'}>
{selectedOption?.label || placeholder}
</span>
<ChevronDown
size={14}
className={`text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
ref={menuRef}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.15 }}
style={{
position: 'fixed',
top: position.top,
left: position.left,
width: position.width,
zIndex: 9999,
}}
className="bg-white rounded-xl shadow-lg border border-gray-200 py-1 max-h-60 overflow-y-auto"
>
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
onChange(opt.value);
setIsOpen(false);
}}
className={`w-full px-4 py-2 text-left hover:bg-gray-50 transition-colors text-sm ${
value === opt.value ? 'bg-gray-100 text-gray-900' : ''
}`}
>
{opt.label}
</button>
))}
</motion.div>
)}
</AnimatePresence>,
document.body
)}
</div>
);
}
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(
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-white rounded-2xl w-full max-w-lg mx-4 shadow-xl max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center">
<XIcon size={20} fill="#000" />
</div>
<h2 className="text-lg font-bold text-gray-900">
{isEdit ? 'X 봇 수정' : 'X 봇 추가'}
</h2>
</div>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
{/* 본문 */}
{botLoading ? (
<div className="flex-1 flex items-center justify-center p-12">
<Loader2 size={32} className="animate-spin text-gray-900" />
</div>
) : (
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-6 space-y-5">
{/* Username */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Username
</label>
<div className="flex gap-2">
<div className="flex-1 relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">@</span>
<input
type="text"
value={username}
onChange={(e) => 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-black/15 focus:border-black disabled:bg-gray-50 disabled:text-gray-500"
/>
</div>
{!isEdit && (
<button
type="button"
onClick={handleLookup}
disabled={lookupLoading || !username.trim()}
className="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{lookupLoading ? (
<span className="w-4 h-4 border-2 border-gray-400/30 border-t-gray-400 rounded-full animate-spin" />
) : (
<Search size={18} />
)}
조회
</button>
)}
</div>
{/* 프로필 정보 표시 */}
{profileInfo && (
<div className="mt-3 p-4 bg-gray-50 rounded-lg flex items-center gap-4">
{profileInfo.avatarUrl ? (
<img
src={profileInfo.avatarUrl}
alt={profileInfo.displayName}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 bg-gray-200 rounded-full flex items-center justify-center">
<XIcon size={24} fill="#374151" />
</div>
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate">
{profileInfo.displayName}
</p>
<p className="text-sm text-gray-500">@{profileInfo.username}</p>
</div>
</div>
)}
</div>
{/* 동기화 간격 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
동기화 간격
</label>
<Dropdown
value={interval}
options={INTERVAL_OPTIONS}
onChange={setInterval}
placeholder="간격 선택"
/>
</div>
{/* 고급 설정 */}
<div className="border border-gray-200 rounded-lg overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
onClick={() => setShowAdvanced(!showAdvanced)}
>
<span className="font-medium text-gray-700">고급 설정</span>
{showAdvanced ? (
<ChevronUp size={20} className="text-gray-400" />
) : (
<ChevronDown size={20} className="text-gray-400" />
)}
</button>
{showAdvanced && (
<div className="p-4 space-y-4 border-t border-gray-100">
{/* 리트윗 포함 */}
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700">리트윗 포함</label>
<p className="text-xs text-gray-400">리트윗도 일정에 추가합니다</p>
</div>
<button
type="button"
onClick={() => setIncludeRetweets(!includeRetweets)}
className={`relative w-11 h-6 rounded-full transition-colors ${
includeRetweets ? 'bg-black' : 'bg-gray-300'
}`}
>
<span
className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${
includeRetweets ? 'translate-x-5' : ''
}`}
/>
</button>
</div>
{/* YouTube 영상 추출 */}
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700">YouTube 영상 추출</label>
<p className="text-xs text-gray-400">트윗에 YouTube 링크가 있으면 유튜브 일정에 추가합니다</p>
</div>
<button
type="button"
onClick={() => setExtractYoutube(!extractYoutube)}
className={`relative w-11 h-6 rounded-full transition-colors ${
extractYoutube ? 'bg-black' : 'bg-gray-300'
}`}
>
<span
className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${
extractYoutube ? 'translate-x-5' : ''
}`}
/>
</button>
</div>
{/* 관리 중인 채널 제외 (extractYoutube 활성 시만) */}
{extractYoutube && (
<div className="flex items-center justify-between pl-4 border-l-2 border-gray-100">
<div>
<label className="block text-sm font-medium text-gray-700">관리 채널 영상 제외</label>
<p className="text-xs text-gray-400">등록된 YouTube 채널의 영상은 트윗에서 중복 추가하지 않습니다</p>
</div>
<button
type="button"
onClick={() => setExcludeManagedChannels(!excludeManagedChannels)}
className={`relative w-11 h-6 rounded-full transition-colors ${
excludeManagedChannels ? 'bg-black' : 'bg-gray-300'
}`}
>
<span
className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${
excludeManagedChannels ? 'translate-x-5' : ''
}`}
/>
</button>
</div>
)}
{/* 텍스트 필터 */}
<div>
<label className="block text-sm text-gray-600 mb-1">텍스트 필터</label>
<div className="flex flex-wrap gap-2 p-2 border border-gray-200 rounded-lg min-h-[42px]">
{textFilters.map((filter, idx) => (
<span
key={idx}
className="inline-flex items-center gap-1 px-2 py-1 bg-black text-white rounded-md text-sm"
>
{filter}
<button
type="button"
onClick={() => setTextFilters(textFilters.filter((_, i) => i !== idx))}
className="text-white/60 hover:text-white"
>
<X size={14} />
</button>
</span>
))}
<input
type="text"
value={filterInput}
onChange={(e) => 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"
/>
</div>
<p className="text-xs text-gray-400 mt-1">
키워드 하나라도 포함된 트윗만 추가됩니다 (비어있으면 모든 트윗)
</p>
</div>
</div>
)}
</div>
</form>
)}
{/* 푸터 */}
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 bg-gray-50">
<button
type="button"
onClick={onClose}
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
type="submit"
onClick={handleSubmit}
disabled={!profileInfo || submitting || botLoading}
className="px-4 py-2.5 bg-black text-white rounded-lg hover:bg-neutral-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{submitting && <Loader2 size={16} className="animate-spin" />}
{isEdit ? '수정' : '추가'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body
);
}
export default XBotDialog;