feat: YouTube 봇 추가/수정 다이얼로그 UI 구현
- 채널 핸들 입력 및 조회 기능 (UI만) - 동기화 간격 선택 - 예정 일정 자동 생성 설정 (요일, 시간, 제목 템플릿, 마감 요일) - 고급 설정 (제목 필터, 멤버 추출) - 추가/수정 모드 지원 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
802aacd22e
commit
3fa9f1520a
3 changed files with 412 additions and 3 deletions
388
frontend/src/components/pc/admin/bot/YouTubeBotDialog.jsx
Normal file
388
frontend/src/components/pc/admin/bot/YouTubeBotDialog.jsx
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
/**
|
||||
* YouTube 봇 추가/수정 다이얼로그
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Youtube, Search, X, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
// 동기화 간격 옵션
|
||||
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: '토요일' },
|
||||
];
|
||||
|
||||
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);
|
||||
const [titleFilter, setTitleFilter] = useState('');
|
||||
const [extractMembers, setExtractMembers] = useState(false);
|
||||
|
||||
// 수정 모드일 때 기존 데이터 로드
|
||||
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);
|
||||
}
|
||||
|
||||
setTitleFilter(bot.title_filter || '');
|
||||
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);
|
||||
setTitleFilter('');
|
||||
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"
|
||||
onClick={onClose}
|
||||
>
|
||||
<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>
|
||||
<select
|
||||
value={interval}
|
||||
onChange={(e) => setInterval(Number(e.target.value))}
|
||||
className="w-full px-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 bg-white"
|
||||
>
|
||||
{INTERVAL_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
<select
|
||||
value={scheduleDayOfWeek}
|
||||
onChange={(e) => setScheduleDayOfWeek(Number(e.target.value))}
|
||||
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 bg-white"
|
||||
>
|
||||
{DAY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">시간</label>
|
||||
<input
|
||||
type="time"
|
||||
value={scheduleTime}
|
||||
onChange={(e) => setScheduleTime(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</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>
|
||||
<select
|
||||
value={deadlineDayOfWeek}
|
||||
onChange={(e) => setDeadlineDayOfWeek(Number(e.target.value))}
|
||||
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 bg-white"
|
||||
>
|
||||
{DAY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={titleFilter}
|
||||
onChange={(e) => setTitleFilter(e.target.value)}
|
||||
placeholder="특정 키워드가 포함된 영상만 추가"
|
||||
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"
|
||||
/>
|
||||
</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;
|
||||
|
|
@ -1 +1,2 @@
|
|||
export { default as BotCard, XIcon, MeilisearchIcon, BotListItem, BotMiniCard, BotTableRow, BotTable } from './BotCard';
|
||||
export { default as YouTubeBotDialog } from './YouTubeBotDialog';
|
||||
|
|
|
|||
|
|
@ -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 } from '@/components/pc/admin';
|
||||
import { AdminLayout, XIcon, MeilisearchIcon, BotTableRow, BotTable, YouTubeBotDialog } from '@/components/pc/admin';
|
||||
import { useAdminAuth } from '@/hooks/pc/admin';
|
||||
import { useToast } from '@/hooks/common';
|
||||
import * as botsApi from '@/api/admin/bots';
|
||||
|
|
@ -60,6 +60,8 @@ function ScheduleBots() {
|
|||
const [isInitialLoad, setIsInitialLoad] = useState(true); // 첫 로드 여부 (애니메이션용)
|
||||
const [syncing, setSyncing] = useState(null); // 동기화 중인 봇 ID
|
||||
const [quotaWarning, setQuotaWarning] = useState(null); // 할당량 경고 상태
|
||||
const [botDialogOpen, setBotDialogOpen] = useState(false); // 봇 추가/수정 다이얼로그
|
||||
const [editingBot, setEditingBot] = useState(null); // 수정 중인 봇
|
||||
|
||||
// 봇 목록 조회
|
||||
const {
|
||||
|
|
@ -235,6 +237,18 @@ function ScheduleBots() {
|
|||
return (
|
||||
<AdminLayout user={user}>
|
||||
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||
<YouTubeBotDialog
|
||||
isOpen={botDialogOpen}
|
||||
onClose={() => {
|
||||
setBotDialogOpen(false);
|
||||
setEditingBot(null);
|
||||
}}
|
||||
bot={editingBot}
|
||||
onSubmit={(data) => {
|
||||
// TODO: API 호출
|
||||
console.log('submit', data);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<motion.div
|
||||
|
|
@ -354,7 +368,10 @@ function ScheduleBots() {
|
|||
<div className="flex items-center gap-2">
|
||||
{section.canAdd && (
|
||||
<button
|
||||
onClick={() => {/* TODO: 봇 추가 모달 */}}
|
||||
onClick={() => {
|
||||
setEditingBot(null);
|
||||
setBotDialogOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-white border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
|
|
@ -397,7 +414,10 @@ function ScheduleBots() {
|
|||
statusInfo={getStatusInfo(bot.status)}
|
||||
onSync={handleSyncAllVideos}
|
||||
onToggle={toggleBot}
|
||||
onEdit={(bot) => {/* TODO: 봇 수정 모달 */}}
|
||||
onEdit={(bot) => {
|
||||
setEditingBot(bot);
|
||||
setBotDialogOpen(true);
|
||||
}}
|
||||
onDelete={(bot) => {/* TODO: 봇 삭제 확인 */}}
|
||||
onAnimationComplete={() =>
|
||||
isInitialLoad && index === sectionBots.length - 1 && setIsInitialLoad(false)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue