feat(festival-bot): 축제 봇 관리 UI 추가 (2단계)

- bot_festival, festival_crawl_log 테이블 SQL
- FestivalBotDialog: 봇 이름/크롤링 URL/동기화 간격(1~24시간) 입력
- 봇 관리 페이지에 '축제' 섹션 추가 (emerald, PartyPopper)
- BotCard: festival 타입 수정/삭제 버튼 표시
- API 클라이언트 함수 추가 (백엔드 라우트는 3단계)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-05-20 22:09:07 +09:00
parent 2e459995c3
commit 3827a23d75
6 changed files with 393 additions and 7 deletions

View file

@ -0,0 +1,20 @@
-- 대학 축제 크롤러 봇 설정
CREATE TABLE IF NOT EXISTS bot_festival (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL COMMENT '봇 이름',
search_url VARCHAR(500) NOT NULL COMMENT '크롤링할 검색 페이지 URL',
cron_interval INT NOT NULL DEFAULT 360 COMMENT '동기화 간격 (분)',
enabled TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='대학 축제 크롤러 봇 설정';
-- 축제 크롤러 처리 로그 (memogipost 글 URL 중복 방지)
CREATE TABLE IF NOT EXISTS festival_crawl_log (
id INT AUTO_INCREMENT PRIMARY KEY,
post_url VARCHAR(500) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'processed' COMMENT 'processed | no_event | error',
result_count INT DEFAULT 0 COMMENT '추출된 행사 수',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_post_url (post_url)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='축제 크롤러 처리 로그';

View file

@ -121,6 +121,49 @@ export async function deleteXBot(id) {
return fetchAuthApi(`/admin/x-bots/${id}`, { method: 'DELETE' });
}
/**
* 축제 상세 조회
* @param {number} id - 축제 DB ID
* @returns {Promise<object>}
*/
export async function getFestivalBot(id) {
return fetchAuthApi(`/admin/festival-bots/${id}`);
}
/**
* 축제 추가
* @param {object} data - 데이터
* @returns {Promise<object>}
*/
export async function createFestivalBot(data) {
return fetchAuthApi('/admin/festival-bots', {
method: 'POST',
body: JSON.stringify(data),
});
}
/**
* 축제 수정
* @param {number} id - 축제 DB ID
* @param {object} data - 업데이트할 데이터
* @returns {Promise<object>}
*/
export async function updateFestivalBot(id, data) {
return fetchAuthApi(`/admin/festival-bots/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
/**
* 축제 삭제
* @param {number} id - 축제 DB ID
* @returns {Promise<object>}
*/
export async function deleteFestivalBot(id) {
return fetchAuthApi(`/admin/festival-bots/${id}`, { method: 'DELETE' });
}
/**
* 시작
* @param {string} id - ID

View file

@ -270,8 +270,8 @@ export const BotTableRow = memo(function BotTableRow({
{bot.status === 'running' ? <Square size={16} /> : <Play size={16} />}
</button>
</Tooltip>
{/* 수정 (YouTube, X) */}
{(bot.type === 'youtube' || bot.type === 'x') && onEdit && (
{/* 수정 (YouTube, X, 축제) */}
{(bot.type === 'youtube' || bot.type === 'x' || bot.type === 'festival') && onEdit && (
<Tooltip text="수정">
<button
onClick={() => onEdit(bot)}
@ -281,8 +281,8 @@ export const BotTableRow = memo(function BotTableRow({
</button>
</Tooltip>
)}
{/* 삭제 (YouTube, X) */}
{(bot.type === 'youtube' || bot.type === 'x') && onDelete && (
{/* 삭제 (YouTube, X, 축제) */}
{(bot.type === 'youtube' || bot.type === 'x' || bot.type === 'festival') && onDelete && (
<Tooltip text="삭제">
<button
onClick={() => onDelete(bot)}

View file

@ -0,0 +1,295 @@
/**
* 축제 추가/수정 다이얼로그
*/
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 { X, ChevronDown, Loader2, PartyPopper } from 'lucide-react';
import { getFestivalBot, createFestivalBot, updateFestivalBot } from '@/api/admin/bots';
// ( )
const INTERVAL_OPTIONS = [
{ value: 60, label: '1시간' },
{ value: 180, label: '3시간' },
{ value: 360, label: '6시간' },
{ value: 720, label: '12시간' },
{ value: 1440, label: '24시간' },
];
/**
* 커스텀 드롭다운 (Portal 사용)
*/
function Dropdown({ value, options, onChange, placeholder = '선택' }) {
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">
<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-emerald-50 text-emerald-600' : ''
}`}
>
{opt.label}
</button>
))}
</motion.div>
)}
</AnimatePresence>,
document.body
)}
</div>
);
}
function FestivalBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
const queryClient = useQueryClient();
const isEdit = !!botId;
//
const [name, setName] = useState('');
const [searchUrl, setSearchUrl] = useState('');
const [interval, setInterval] = useState(360);
const [submitting, setSubmitting] = useState(false);
// ( )
const { data: bot, isLoading: botLoading } = useQuery({
queryKey: ['admin', 'festival-bot', botId],
queryFn: () => getFestivalBot(botId),
enabled: isOpen && !!botId,
staleTime: 0,
});
//
useEffect(() => {
if (!isOpen) return;
if (bot) {
//
setName(bot.name || '');
setSearchUrl(bot.search_url || '');
setInterval(bot.cron_interval || 360);
} else if (!botId) {
//
setName('');
setSearchUrl('');
setInterval(360);
}
}, [isOpen, bot, botId]);
//
const handleSubmit = async (e) => {
e.preventDefault();
if (!name.trim() || !searchUrl.trim()) return;
setSubmitting(true);
try {
const data = {
name: name.trim(),
search_url: searchUrl.trim(),
cron_interval: interval,
};
if (isEdit) {
await updateFestivalBot(botId, data);
} else {
await createFestivalBot(data);
}
queryClient.invalidateQueries({ queryKey: ['admin', 'bots'] });
queryClient.invalidateQueries({ queryKey: ['admin', 'festival-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-emerald-50 flex items-center justify-center">
<PartyPopper size={20} className="text-emerald-500" />
</div>
<h2 className="text-lg font-bold text-gray-900">
{isEdit ? '축제 봇 수정' : '축제 봇 추가'}
</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-emerald-500" />
</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>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="대학 축제 봇"
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500"
/>
</div>
{/* 검색 URL */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
크롤링 URL
</label>
<input
type="url"
value={searchUrl}
onChange={(e) => setSearchUrl(e.target.value)}
placeholder="https://memogipost.tistory.com/search/프로미스나인"
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500"
/>
<p className="text-xs text-gray-400 mt-1">
축제 정보를 수집할 검색 페이지 URL을 입력하세요
</p>
</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>
</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={!name.trim() || !searchUrl.trim() || submitting || botLoading}
className="px-4 py-2.5 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 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 FestivalBotDialog;

View file

@ -1,3 +1,4 @@
export { default as BotCard, XIcon, MeilisearchIcon, BotListItem, BotMiniCard, BotTableRow, BotTable } from './BotCard';
export { default as YouTubeBotDialog } from './YouTubeBotDialog';
export { default as XBotDialog } from './XBotDialog';
export { default as FestivalBotDialog } from './FestivalBotDialog';

View file

@ -2,9 +2,9 @@ import { useState, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
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 { Home, ChevronRight, Bot, CheckCircle, XCircle, RefreshCw, Plus, Youtube, PartyPopper } from 'lucide-react';
import { Toast, Tooltip, AnimatedNumber } from '@/components/common';
import { AdminLayout, XIcon, MeilisearchIcon, BotTableRow, BotTable, YouTubeBotDialog, XBotDialog } from '@/components/pc/admin';
import { AdminLayout, XIcon, MeilisearchIcon, BotTableRow, BotTable, YouTubeBotDialog, XBotDialog, FestivalBotDialog } from '@/components/pc/admin';
import { useAdminAuth } from '@/hooks/pc/admin';
import { useToast } from '@/hooks/common';
import * as botsApi from '@/api/admin/bots';
@ -34,6 +34,14 @@ const SECTIONS = {
borderColor: 'border-gray-200',
canAdd: true,
},
festival: {
title: '축제',
icon: PartyPopper,
color: 'text-emerald-500',
bgColor: 'bg-emerald-50',
borderColor: 'border-emerald-100',
canAdd: true,
},
};
// variants
@ -63,6 +71,7 @@ function ScheduleBots() {
const [quotaWarning, setQuotaWarning] = useState(null); //
const [youtubeDialogOpen, setYoutubeDialogOpen] = useState(false); // YouTube
const [xDialogOpen, setXDialogOpen] = useState(false); // X
const [festivalDialogOpen, setFestivalDialogOpen] = useState(false); //
const [editingBotId, setEditingBotId] = useState(null); // DB ID
const [editingBotType, setEditingBotType] = useState(null); //
const [deletingBot, setDeletingBot] = useState(null); //
@ -168,6 +177,8 @@ function ScheduleBots() {
await botsApi.deleteYouTubeBot(deletingBot.db_id);
} else if (deletingBot.type === 'x') {
await botsApi.deleteXBot(deletingBot.db_id);
} else if (deletingBot.type === 'festival') {
await botsApi.deleteFestivalBot(deletingBot.db_id);
}
queryClient.invalidateQueries({ queryKey: ['admin', 'bots'] });
setToast({ type: 'success', message: `${deletingBot.name} 봇이 삭제되었습니다.` });
@ -249,7 +260,7 @@ function ScheduleBots() {
//
const botsByType = useMemo(() => {
const grouped = { meilisearch: [], youtube: [], x: [] };
const grouped = { meilisearch: [], youtube: [], x: [], festival: [] };
bots.forEach((bot) => {
if (grouped[bot.type]) {
grouped[bot.type].push(bot);
@ -285,6 +296,18 @@ function ScheduleBots() {
setToast({ type: 'success', message: editingBotId ? '봇이 수정되었습니다.' : '봇이 추가되었습니다.' });
}}
/>
<FestivalBotDialog
isOpen={festivalDialogOpen}
onClose={() => {
setFestivalDialogOpen(false);
setEditingBotId(null);
setEditingBotType(null);
}}
botId={editingBotId}
onSuccess={() => {
setToast({ type: 'success', message: editingBotId ? '봇이 수정되었습니다.' : '봇이 추가되었습니다.' });
}}
/>
{/* 삭제 확인 다이얼로그 */}
<AnimatePresence>
@ -457,6 +480,8 @@ function ScheduleBots() {
setYoutubeDialogOpen(true);
} else if (type === 'x') {
setXDialogOpen(true);
} else if (type === 'festival') {
setFestivalDialogOpen(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"
@ -508,6 +533,8 @@ function ScheduleBots() {
setYoutubeDialogOpen(true);
} else if (bot.type === 'x') {
setXDialogOpen(true);
} else if (bot.type === 'festival') {
setFestivalDialogOpen(true);
}
}}
onDelete={(bot) => setDeletingBot(bot)}