2026-02-06 18:22:56 +09:00
|
|
|
/**
|
|
|
|
|
* YouTube 봇 추가/수정 다이얼로그
|
|
|
|
|
*/
|
2026-02-06 18:30:34 +09:00
|
|
|
import { useState, useEffect, useRef } from 'react';
|
2026-02-06 18:22:56 +09:00
|
|
|
import { createPortal } from 'react-dom';
|
|
|
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
2026-02-07 10:15:07 +09:00
|
|
|
import { Youtube, Search, X, ChevronDown, ChevronUp } from 'lucide-react';
|
|
|
|
|
import { getMembers } from '@/api/public/members';
|
2026-02-06 18:22:56 +09:00
|
|
|
|
|
|
|
|
// 동기화 간격 옵션
|
|
|
|
|
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: '토요일' },
|
|
|
|
|
];
|
|
|
|
|
|
2026-02-06 18:30:34 +09:00
|
|
|
// 시간 옵션 (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`,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-07 10:15:07 +09:00
|
|
|
* 커스텀 드롭다운 컴포넌트 (Portal 사용)
|
2026-02-06 18:30:34 +09:00
|
|
|
*/
|
|
|
|
|
function Dropdown({ value, options, onChange, placeholder = '선택', className = '' }) {
|
|
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
2026-02-07 10:15:07 +09:00
|
|
|
const [position, setPosition] = useState({ top: 0, left: 0, width: 0 });
|
|
|
|
|
const buttonRef = useRef(null);
|
|
|
|
|
const menuRef = useRef(null);
|
2026-02-06 18:30:34 +09:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleClickOutside = (event) => {
|
2026-02-07 10:15:07 +09:00
|
|
|
if (
|
|
|
|
|
buttonRef.current &&
|
|
|
|
|
!buttonRef.current.contains(event.target) &&
|
|
|
|
|
menuRef.current &&
|
|
|
|
|
!menuRef.current.contains(event.target)
|
|
|
|
|
) {
|
2026-02-06 18:30:34 +09:00
|
|
|
setIsOpen(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (isOpen) {
|
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
|
|
|
}
|
|
|
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
|
|
|
}, [isOpen]);
|
|
|
|
|
|
2026-02-07 10:15:07 +09:00
|
|
|
// 버튼 위치 계산
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isOpen && buttonRef.current) {
|
|
|
|
|
const rect = buttonRef.current.getBoundingClientRect();
|
|
|
|
|
setPosition({
|
|
|
|
|
top: rect.bottom + 4,
|
|
|
|
|
left: rect.left,
|
|
|
|
|
width: rect.width,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}, [isOpen]);
|
|
|
|
|
|
2026-02-06 18:30:34 +09:00
|
|
|
const selectedOption = options.find((opt) => opt.value === value);
|
|
|
|
|
|
|
|
|
|
return (
|
2026-02-07 10:15:07 +09:00
|
|
|
<div className={`relative ${className}`}>
|
2026-02-06 18:30:34 +09:00
|
|
|
<button
|
2026-02-07 10:15:07 +09:00
|
|
|
ref={buttonRef}
|
2026-02-06 18:30:34 +09:00
|
|
|
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>
|
2026-02-07 10:15:07 +09:00
|
|
|
{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-red-50 text-red-600' : ''
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{opt.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</motion.div>
|
|
|
|
|
)}
|
|
|
|
|
</AnimatePresence>,
|
|
|
|
|
document.body
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 다중 선택 드롭다운 컴포넌트
|
|
|
|
|
*/
|
|
|
|
|
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 (
|
|
|
|
|
<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={selectedOptions.length > 0 ? 'text-gray-900 truncate' : 'text-gray-400'}>
|
|
|
|
|
{displayText}
|
|
|
|
|
</span>
|
|
|
|
|
<ChevronDown
|
|
|
|
|
size={14}
|
|
|
|
|
className={`text-gray-400 transition-transform flex-shrink-0 ${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={() => toggleValue(opt.value)}
|
|
|
|
|
className={`w-full px-4 py-2 text-left hover:bg-gray-50 transition-colors text-sm flex items-center gap-2 ${
|
|
|
|
|
values.includes(opt.value) ? 'bg-red-50 text-red-600' : ''
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className={`w-4 h-4 rounded border flex items-center justify-center ${
|
|
|
|
|
values.includes(opt.value)
|
|
|
|
|
? 'bg-red-500 border-red-500'
|
|
|
|
|
: 'border-gray-300'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{values.includes(opt.value) && (
|
|
|
|
|
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
|
|
|
|
</svg>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{opt.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</motion.div>
|
|
|
|
|
)}
|
|
|
|
|
</AnimatePresence>,
|
|
|
|
|
document.body
|
|
|
|
|
)}
|
2026-02-06 18:30:34 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 18:22:56 +09:00
|
|
|
function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
|
|
|
|
|
const isEdit = !!bot;
|
|
|
|
|
|
|
|
|
|
// 폼 상태
|
|
|
|
|
const [handle, setHandle] = useState('');
|
|
|
|
|
const [channelInfo, setChannelInfo] = useState(null);
|
|
|
|
|
const [lookupLoading, setLookupLoading] = useState(false);
|
|
|
|
|
const [interval, setInterval] = useState(2);
|
|
|
|
|
|
|
|
|
|
// 예정 일정 설정
|
|
|
|
|
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);
|
2026-02-07 10:15:07 +09:00
|
|
|
const [titleFilters, setTitleFilters] = useState([]);
|
|
|
|
|
const [filterInput, setFilterInput] = useState('');
|
|
|
|
|
const [defaultMemberIds, setDefaultMemberIds] = useState([]);
|
2026-02-06 18:22:56 +09:00
|
|
|
const [extractMembers, setExtractMembers] = useState(false);
|
|
|
|
|
|
2026-02-07 10:15:07 +09:00
|
|
|
// 멤버 목록 (탈퇴 멤버 제외)
|
|
|
|
|
const [members, setMembers] = useState([]);
|
|
|
|
|
|
|
|
|
|
// 멤버 목록 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isOpen) {
|
|
|
|
|
getMembers()
|
|
|
|
|
.then((data) => setMembers(data.filter((m) => !m.is_former)))
|
|
|
|
|
.catch(console.error);
|
|
|
|
|
}
|
|
|
|
|
}, [isOpen]);
|
|
|
|
|
|
2026-02-06 18:22:56 +09:00
|
|
|
// 수정 모드일 때 기존 데이터 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (bot) {
|
|
|
|
|
setHandle(bot.channel_handle || '');
|
|
|
|
|
setChannelInfo({
|
|
|
|
|
channelId: bot.channel_id,
|
|
|
|
|
title: bot.channel_name,
|
|
|
|
|
});
|
|
|
|
|
setInterval(bot.cron_interval || 2);
|
|
|
|
|
|
|
|
|
|
if (bot.auto_schedule_config) {
|
|
|
|
|
const config = typeof bot.auto_schedule_config === 'string'
|
|
|
|
|
? JSON.parse(bot.auto_schedule_config)
|
|
|
|
|
: bot.auto_schedule_config;
|
|
|
|
|
setAutoScheduleEnabled(true);
|
|
|
|
|
setScheduleDayOfWeek(config.dayOfWeek ?? 4);
|
|
|
|
|
setScheduleTime(config.time?.slice(0, 5) || '18:00');
|
|
|
|
|
setTitleTemplate(config.titleTemplate || '{channelName} {episode}화');
|
|
|
|
|
setDeadlineDayOfWeek(config.deadlineDayOfWeek ?? 5);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 10:15:07 +09:00
|
|
|
setTitleFilters(bot.title_filters || []);
|
|
|
|
|
setDefaultMemberIds(bot.default_member_ids || []);
|
2026-02-06 18:22:56 +09:00
|
|
|
setExtractMembers(bot.extract_members_from_desc || false);
|
|
|
|
|
}
|
|
|
|
|
}, [bot]);
|
|
|
|
|
|
|
|
|
|
// 다이얼로그 닫힐 때 초기화
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isOpen) {
|
|
|
|
|
setHandle('');
|
|
|
|
|
setChannelInfo(null);
|
|
|
|
|
setInterval(2);
|
|
|
|
|
setAutoScheduleEnabled(false);
|
|
|
|
|
setScheduleDayOfWeek(4);
|
|
|
|
|
setScheduleTime('18:00');
|
|
|
|
|
setTitleTemplate('{channelName} {episode}화');
|
|
|
|
|
setDeadlineDayOfWeek(5);
|
|
|
|
|
setShowAdvanced(false);
|
2026-02-07 10:15:07 +09:00
|
|
|
setTitleFilters([]);
|
|
|
|
|
setFilterInput('');
|
|
|
|
|
setDefaultMemberIds([]);
|
2026-02-06 18:22:56 +09:00
|
|
|
setExtractMembers(false);
|
|
|
|
|
}
|
|
|
|
|
}, [isOpen]);
|
|
|
|
|
|
|
|
|
|
// 채널 조회
|
|
|
|
|
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 = (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
// TODO: onSubmit 호출
|
|
|
|
|
onClose();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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-red-50 flex items-center justify-center">
|
|
|
|
|
<Youtube size={20} className="text-red-500" />
|
|
|
|
|
</div>
|
|
|
|
|
<h2 className="text-lg font-bold text-gray-900">
|
|
|
|
|
{isEdit ? 'YouTube 봇 수정' : 'YouTube 봇 추가'}
|
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
{/* 본문 */}
|
|
|
|
|
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-6 space-y-5">
|
|
|
|
|
{/* 채널 핸들 */}
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
|
|
|
|
채널 핸들
|
|
|
|
|
</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={handle}
|
|
|
|
|
onChange={(e) => 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"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
{!isEdit && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={handleLookup}
|
|
|
|
|
disabled={lookupLoading || !handle.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>
|
|
|
|
|
{/* 채널 정보 표시 */}
|
|
|
|
|
{channelInfo && (
|
|
|
|
|
<div className="mt-2 p-3 bg-gray-50 rounded-lg flex items-center gap-3">
|
|
|
|
|
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
|
|
|
|
|
<Youtube size={20} className="text-gray-400" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<p className="font-medium text-gray-900 truncate">{channelInfo.title}</p>
|
|
|
|
|
<p className="text-xs text-gray-500">{channelInfo.channelId}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 동기화 간격 */}
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
|
|
|
|
동기화 간격
|
|
|
|
|
</label>
|
2026-02-06 18:30:34 +09:00
|
|
|
<Dropdown
|
2026-02-06 18:22:56 +09:00
|
|
|
value={interval}
|
2026-02-06 18:30:34 +09:00
|
|
|
options={INTERVAL_OPTIONS}
|
|
|
|
|
onChange={setInterval}
|
|
|
|
|
placeholder="간격 선택"
|
|
|
|
|
/>
|
2026-02-06 18:22:56 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 예정 일정 자동 생성 */}
|
|
|
|
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
|
|
|
|
<div
|
|
|
|
|
className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50"
|
|
|
|
|
onClick={() => setAutoScheduleEnabled(!autoScheduleEnabled)}
|
|
|
|
|
>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="font-medium text-gray-900">예정 일정 자동 생성</p>
|
|
|
|
|
<p className="text-sm text-gray-500">매주 특정 요일에 임시 일정을 미리 생성합니다</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
className={`w-11 h-6 rounded-full transition-colors ${
|
|
|
|
|
autoScheduleEnabled ? 'bg-red-500' : 'bg-gray-200'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className={`w-5 h-5 bg-white rounded-full shadow-sm transform transition-transform mt-0.5 ${
|
|
|
|
|
autoScheduleEnabled ? 'translate-x-5.5 ml-0.5' : 'translate-x-0.5'
|
|
|
|
|
}`}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{autoScheduleEnabled && (
|
|
|
|
|
<div className="p-4 pt-0 space-y-4 border-t border-gray-100">
|
|
|
|
|
{/* 요일 & 시간 */}
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm text-gray-600 mb-1">요일</label>
|
2026-02-06 18:30:34 +09:00
|
|
|
<Dropdown
|
2026-02-06 18:22:56 +09:00
|
|
|
value={scheduleDayOfWeek}
|
2026-02-06 18:30:34 +09:00
|
|
|
options={DAY_OPTIONS}
|
|
|
|
|
onChange={setScheduleDayOfWeek}
|
|
|
|
|
placeholder="요일 선택"
|
|
|
|
|
/>
|
2026-02-06 18:22:56 +09:00
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm text-gray-600 mb-1">시간</label>
|
2026-02-06 18:30:34 +09:00
|
|
|
<Dropdown
|
2026-02-06 18:22:56 +09:00
|
|
|
value={scheduleTime}
|
2026-02-06 18:30:34 +09:00
|
|
|
options={TIME_OPTIONS}
|
|
|
|
|
onChange={setScheduleTime}
|
|
|
|
|
placeholder="시간 선택"
|
2026-02-06 18:22:56 +09:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 제목 템플릿 */}
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm text-gray-600 mb-1">제목 템플릿</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={titleTemplate}
|
|
|
|
|
onChange={(e) => 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"
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
|
|
|
{'{channelName}'}: 채널명, {'{episode}'}: 회차 번호
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 마감 요일 */}
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm text-gray-600 mb-1">마감 요일</label>
|
2026-02-06 18:30:34 +09:00
|
|
|
<Dropdown
|
2026-02-06 18:22:56 +09:00
|
|
|
value={deadlineDayOfWeek}
|
2026-02-06 18:30:34 +09:00
|
|
|
options={DAY_OPTIONS}
|
|
|
|
|
onChange={setDeadlineDayOfWeek}
|
|
|
|
|
placeholder="요일 선택"
|
|
|
|
|
/>
|
2026-02-06 18:22:56 +09:00
|
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
|
|
|
이 요일까지 영상이 없으면 예정 일정을 삭제합니다
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</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 pt-0 space-y-4 border-t border-gray-100">
|
|
|
|
|
{/* 제목 필터 */}
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm text-gray-600 mb-1">제목 필터</label>
|
2026-02-07 10:15:07 +09:00
|
|
|
<div className="flex flex-wrap gap-2 p-2 border border-gray-200 rounded-lg min-h-[42px]">
|
|
|
|
|
{titleFilters.map((filter, idx) => (
|
|
|
|
|
<span
|
|
|
|
|
key={idx}
|
|
|
|
|
className="inline-flex items-center gap-1 px-2 py-1 bg-red-50 text-red-600 rounded-md text-sm"
|
|
|
|
|
>
|
|
|
|
|
{filter}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setTitleFilters(titleFilters.filter((_, i) => i !== idx))}
|
|
|
|
|
className="hover:text-red-800"
|
|
|
|
|
>
|
|
|
|
|
<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 (!titleFilters.includes(filterInput.trim())) {
|
|
|
|
|
setTitleFilters([...titleFilters, filterInput.trim()]);
|
|
|
|
|
}
|
|
|
|
|
setFilterInput('');
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
placeholder={titleFilters.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>
|
|
|
|
|
<label className="block text-sm text-gray-600 mb-1">고정 멤버</label>
|
|
|
|
|
<MultiSelect
|
|
|
|
|
values={defaultMemberIds}
|
|
|
|
|
options={members.map((m) => ({ value: m.id, label: m.name }))}
|
|
|
|
|
onChange={setDefaultMemberIds}
|
|
|
|
|
placeholder="멤버 선택"
|
2026-02-06 18:22:56 +09:00
|
|
|
/>
|
2026-02-07 10:15:07 +09:00
|
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
|
|
|
모든 영상에 선택한 멤버를 자동으로 연결합니다
|
|
|
|
|
</p>
|
2026-02-06 18:22:56 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 멤버 추출 */}
|
|
|
|
|
<div
|
|
|
|
|
className="flex items-center justify-between cursor-pointer"
|
|
|
|
|
onClick={() => setExtractMembers(!extractMembers)}
|
|
|
|
|
>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-medium text-gray-700">설명에서 멤버 추출</p>
|
|
|
|
|
<p className="text-xs text-gray-500">영상 설명에서 멤버 이름을 찾아 자동 연결</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
className={`w-10 h-5 rounded-full transition-colors ${
|
|
|
|
|
extractMembers ? 'bg-red-500' : 'bg-gray-200'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className={`w-4 h-4 bg-white rounded-full shadow-sm transform transition-transform mt-0.5 ${
|
|
|
|
|
extractMembers ? 'translate-x-5' : 'translate-x-0.5'
|
|
|
|
|
}`}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</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}
|
|
|
|
|
className="px-4 py-2.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 rounded-lg transition-colors"
|
|
|
|
|
>
|
|
|
|
|
취소
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="submit"
|
|
|
|
|
onClick={handleSubmit}
|
|
|
|
|
disabled={!channelInfo}
|
|
|
|
|
className="px-4 py-2.5 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
>
|
|
|
|
|
{isEdit ? '수정' : '추가'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
)}
|
|
|
|
|
</AnimatePresence>,
|
|
|
|
|
document.body
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default YouTubeBotDialog;
|