feat: YouTube 봇 DB 기반 관리로 마이그레이션

- YouTube 봇 설정을 bots.js에서 youtube_bots 테이블로 이동
- 봇 ID를 AUTO_INCREMENT로 변경 (youtube-{id} 형식)
- 고정 멤버 다중 선택 지원 (default_member_ids JSON)
- 제목 필터 다중 키워드 지원 (title_filters JSON)
- Redis 캐싱 제거 (Activities API 사용으로 불필요)
- 채널 배너 URL DB 저장 (youtube_bots.banner_url)
- YouTubeBotDialog UI 개선:
  - Portal 기반 드롭다운 (overflow 문제 해결)
  - AnimatePresence 애니메이션 적용
  - 다중 선택 컴포넌트 추가
  - 태그 입력 형태의 제목 필터
  - 뒷배경 클릭 방지

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-02-07 10:15:07 +09:00
parent 730da864a4
commit a8c12aa76d
10 changed files with 409 additions and 163 deletions

View file

@ -0,0 +1,25 @@
-- YouTube 봇 테이블
CREATE TABLE IF NOT EXISTS youtube_bots (
id INT AUTO_INCREMENT PRIMARY KEY,
channel_id VARCHAR(30) NOT NULL,
channel_handle VARCHAR(50),
channel_name VARCHAR(100) NOT NULL,
banner_url VARCHAR(500),
cron_interval INT DEFAULT 2,
enabled TINYINT(1) DEFAULT 1,
-- 제목 필터 (선택, JSON 배열)
title_filters JSON,
-- 멤버 설정 (선택)
default_member_ids JSON,
extract_members_from_desc TINYINT(1) DEFAULT 0,
-- 다음 주 예정 일정 설정 (JSON)
auto_schedule_config JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_channel_id (channel_id)
);

View file

@ -0,0 +1,20 @@
-- YouTube 봇 시드 데이터
-- channel_handle은 봇 추가 시 YouTube API로 조회하여 저장
INSERT INTO youtube_bots (channel_id, channel_name, cron_interval, enabled) VALUES
('UCXbRURMKT3H_w8dT-DWLIxA', 'fromis_9', 2, 1),
('UCtfyAiqf095_0_ux8ruwGfA', 'MUSINSA TV', 2, 1),
('UCeUJ8B3krxw8zuDi19AlhaA', '스프 : 스튜디오 프로미스나인', 2, 1)
ON DUPLICATE KEY UPDATE channel_name = VALUES(channel_name);
-- 스프 : 스튜디오 프로미스나인 - 예정 일정 설정
UPDATE youtube_bots
SET auto_schedule_config = '{"dayOfWeek":4,"time":"18:00:00","titleTemplate":"{channelName} {episode}화","deadlineDayOfWeek":5,"excludeShorts":true}'
WHERE channel_id = 'UCeUJ8B3krxw8zuDi19AlhaA';
-- MUSINSA TV - 필터/멤버 설정
UPDATE youtube_bots
SET title_filters = '["성수기"]',
default_member_ids = '[7]',
extract_members_from_desc = 1
WHERE channel_id = 'UCtfyAiqf095_0_ux8ruwGfA';

View file

@ -1,3 +1,4 @@
// 정적 봇 설정 (YouTube 봇은 DB에서 관리)
export default [
{
id: 'meilisearch-sync',
@ -6,41 +7,6 @@ export default [
cron: '0 0 * * *', // 매일 00시 전체 동기화
enabled: true,
},
{
id: 'youtube-fromis9',
type: 'youtube',
channelId: 'UCXbRURMKT3H_w8dT-DWLIxA',
channelName: 'fromis_9',
cron: '*/2 * * * *',
enabled: true,
},
{
id: 'youtube-studio',
type: 'youtube',
channelId: 'UCeUJ8B3krxw8zuDi19AlhaA',
channelName: '스프 : 스튜디오 프로미스나인',
cron: '*/2 * * * *',
enabled: true,
// 다음 주 예정 일정 자동 생성
autoScheduleNext: {
dayOfWeek: 4, // 목요일 (0=일요일)
time: '18:00:00',
titleTemplate: '{channelName} {episode}화', // {episode}는 자동 계산
deadlineDayOfWeek: 5, // 금요일 00시까지 영상 없으면 삭제
excludeShorts: true, // 쇼츠는 제외
},
},
{
id: 'youtube-musinsa',
type: 'youtube',
channelId: 'UCtfyAiqf095_0_ux8ruwGfA',
channelName: 'MUSINSA TV',
cron: '*/2 * * * *',
enabled: true,
titleFilter: '성수기',
defaultMemberId: 7,
extractMembersFromDesc: true,
},
{
id: 'x-fromis9',
type: 'x',

View file

@ -1,6 +1,6 @@
import fp from 'fastify-plugin';
import cron from 'node-cron';
import bots from '../config/bots.js';
import staticBots from '../config/bots.js';
import { syncAllSchedules } from '../services/meilisearch/index.js';
import { nowKST } from '../utils/date.js';
@ -9,6 +9,63 @@ const TIMEZONE = 'Asia/Seoul';
async function schedulerPlugin(fastify, opts) {
const tasks = new Map();
let cachedBots = null;
/**
* DB에서 YouTube 목록 조회
*/
async function getYouTubeBotsFromDB() {
const [rows] = await fastify.db.query(
'SELECT * FROM youtube_bots WHERE enabled = 1'
);
return rows.map(row => ({
id: `youtube-${row.id}`, // DB ID를 문자열 형식으로 변환
dbId: row.id,
type: 'youtube',
channelId: row.channel_id,
channelHandle: row.channel_handle,
channelName: row.channel_name,
bannerUrl: row.banner_url,
cron: `*/${row.cron_interval} * * * *`,
enabled: row.enabled === 1,
titleFilters: row.title_filters
? (typeof row.title_filters === 'string'
? JSON.parse(row.title_filters)
: row.title_filters)
: [],
defaultMemberIds: row.default_member_ids
? (typeof row.default_member_ids === 'string'
? JSON.parse(row.default_member_ids)
: row.default_member_ids)
: [],
extractMembersFromDesc: row.extract_members_from_desc === 1,
autoScheduleNext: row.auto_schedule_config
? (typeof row.auto_schedule_config === 'string'
? JSON.parse(row.auto_schedule_config)
: row.auto_schedule_config)
: null,
}));
}
/**
* 모든 목록 가져오기 (정적 + DB)
*/
async function getAllBots(forceRefresh = false) {
if (cachedBots && !forceRefresh) {
return cachedBots;
}
const youtubeBots = await getYouTubeBotsFromDB();
cachedBots = [...staticBots, ...youtubeBots];
return cachedBots;
}
/**
* ID로 찾기
*/
async function findBot(botId) {
const allBots = await getAllBots();
return allBots.find(b => b.id === botId);
}
/**
* 상태 Redis에 저장
@ -56,10 +113,10 @@ async function schedulerPlugin(fastify, opts) {
}
/**
* 동기화 결과 처리 (중복 코드 제거)
* 동기화 결과 처리
*/
async function handleSyncResult(botId, result, options = {}) {
const { setRunningStatus = false, setErrorOnFail = false } = options;
const { setRunningStatus = false } = options;
const status = await getStatus(botId);
const updateData = {
lastCheckAt: nowKST(),
@ -80,7 +137,7 @@ async function schedulerPlugin(fastify, opts) {
* 시작
*/
async function startBot(botId) {
const bot = bots.find(b => b.id === botId);
const bot = await findBot(botId);
if (!bot) {
throw new Error(`봇을 찾을 수 없습니다: ${botId}`);
}
@ -145,7 +202,8 @@ async function schedulerPlugin(fastify, opts) {
* 모든 활성 시작
*/
async function startAll() {
for (const bot of bots) {
const allBots = await getAllBots(true); // DB에서 새로 로드
for (const bot of allBots) {
if (bot.enabled) {
try {
await startBot(bot.id);
@ -167,6 +225,13 @@ async function schedulerPlugin(fastify, opts) {
tasks.clear();
}
/**
* 캐시 갱신 ( 추가/수정/삭제 호출)
*/
function invalidateCache() {
cachedBots = null;
}
// 데코레이터 등록
fastify.decorate('scheduler', {
startBot,
@ -174,7 +239,8 @@ async function schedulerPlugin(fastify, opts) {
startAll,
stopAll,
getStatus,
getBots: () => bots,
getBots: () => getAllBots(),
invalidateCache,
});
// 앱 종료 시 모든 봇 정지

View file

@ -1,4 +1,3 @@
import bots from '../../config/bots.js';
import { errorResponse } from '../../schemas/index.js';
import { syncAllSchedules } from '../../services/meilisearch/index.js';
import { badRequest, notFound, serverError } from '../../utils/error.js';
@ -57,9 +56,10 @@ export default async function botsRoutes(fastify) {
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const allBots = await scheduler.getBots();
const result = [];
for (const bot of bots) {
for (const bot of allBots) {
const status = await scheduler.getStatus(bot.id);
// cron 표현식에서 간격 추출 (분 단위, 일일 스케줄은 1440분)
@ -187,7 +187,8 @@ export default async function botsRoutes(fastify) {
}, async (request, reply) => {
const { id } = request.params;
const bot = bots.find(b => b.id === id);
const allBots = await scheduler.getBots();
const bot = allBots.find(b => b.id === id);
if (!bot) {
return notFound(reply, '봇을 찾을 수 없습니다.');
}

View file

@ -154,18 +154,14 @@ export default async function schedulesRoutes(fastify) {
// 유튜브 카테고리인 경우 채널 배너 이미지 추가
if (result.category?.id === CATEGORY_IDS.YOUTUBE) {
const [youtubeData] = await db.query(
'SELECT channel_id FROM schedule_youtube WHERE schedule_id = ?',
`SELECT sy.channel_id, yb.banner_url
FROM schedule_youtube sy
LEFT JOIN youtube_bots yb ON sy.channel_id = yb.channel_id
WHERE sy.schedule_id = ?`,
[request.params.id]
);
if (youtubeData.length > 0 && youtubeData[0].channel_id) {
try {
const channelInfo = await fastify.youtubeBot.getChannelInfo(youtubeData[0].channel_id);
if (channelInfo?.bannerUrl) {
result.bannerUrl = channelInfo.bannerUrl;
}
} catch (err) {
fastify.log.warn(`채널 정보 조회 실패: ${err.message}`);
}
if (youtubeData.length > 0 && youtubeData[0].banner_url) {
result.bannerUrl = youtubeData[0].banner_url;
}
}

View file

@ -2,7 +2,6 @@ import fp from 'fastify-plugin';
import { fetchTweets, fetchAllTweets, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js';
import { fetchVideoInfo } from '../youtube/api.js';
import { formatDate, formatTime, nowKST } from '../../utils/date.js';
import bots from '../../config/bots.js';
import { withTransaction } from '../../utils/transaction.js';
import { syncScheduleById } from '../meilisearch/index.js';
@ -13,12 +12,13 @@ const PROFILE_TTL = 604800; // 7일
async function xBotPlugin(fastify, opts) {
/**
* 관리 중인 YouTube 채널 ID 목록
* 관리 중인 YouTube 채널 ID 목록 (DB에서 조회)
*/
function getManagedChannelIds() {
return bots
.filter(b => b.type === 'youtube')
.map(b => b.channelId);
async function getManagedChannelIds() {
const [rows] = await fastify.db.query(
'SELECT channel_id FROM youtube_bots WHERE enabled = 1'
);
return rows.map(r => r.channel_id);
}
/**
@ -131,7 +131,7 @@ async function xBotPlugin(fastify, opts) {
const videoIds = extractYoutubeVideoIds(tweet.text);
if (videoIds.length === 0) return 0;
const managedChannels = getManagedChannelIds();
const managedChannels = await getManagedChannelIds();
let addedCount = 0;
for (const videoId of videoIds) {

View file

@ -97,9 +97,8 @@ async function getVideoDurations(videoIds) {
* 최근 N개 영상 조회 (Activities API 사용 - playlistItems보다 빠른 반영)
* @param {string} channelId - 채널 ID
* @param {number} maxResults - 최대 결과
* @param {string} uploadsPlaylistId - 미사용 (하위 호환성 유지)
*/
export async function fetchRecentVideos(channelId, maxResults = 10, uploadsPlaylistId = null) {
export async function fetchRecentVideos(channelId, maxResults = 10) {
// Activities API에 type=upload 지정 (다른 활동이 섞일 수 있어 2배 조회)
const fetchCount = Math.min(maxResults * 2, 50);
const url = `${API_BASE}/activities?part=snippet,contentDetails&channelId=${channelId}&type=upload&maxResults=${fetchCount}&key=${API_KEY}`;

View file

@ -1,53 +1,12 @@
import fp from 'fastify-plugin';
import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId, getChannelInfo } from './api.js';
import bots from '../../config/bots.js';
import { fetchRecentVideos, fetchAllVideos } from './api.js';
import { CATEGORY_IDS } from '../../config/index.js';
import { withTransaction } from '../../utils/transaction.js';
import { syncScheduleById, deleteSchedule } from '../meilisearch/index.js';
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE;
const PLAYLIST_CACHE_PREFIX = 'yt_uploads:';
const CHANNEL_INFO_PREFIX = 'yt_channel:';
async function youtubeBotPlugin(fastify, opts) {
/**
* uploads playlist ID 조회 (Redis 캐싱)
*/
async function getCachedUploadsPlaylistId(channelId) {
const cacheKey = `${PLAYLIST_CACHE_PREFIX}${channelId}`;
// Redis 캐시 확인
const cached = await fastify.redis.get(cacheKey);
if (cached) {
return cached;
}
// API 호출 후 캐싱 (영구 저장 - 값이 변하지 않음)
const playlistId = await getUploadsPlaylistId(channelId);
await fastify.redis.set(cacheKey, playlistId);
return playlistId;
}
/**
* 채널 정보 조회 (Redis 캐싱, 24시간)
*/
async function getCachedChannelInfo(channelId) {
const cacheKey = `${CHANNEL_INFO_PREFIX}${channelId}`;
// Redis 캐시 확인
const cached = await fastify.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// API 호출 후 캐싱 (24시간)
const channelInfo = await getChannelInfo(channelId);
await fastify.redis.set(cacheKey, JSON.stringify(channelInfo), 'EX', 86400);
return channelInfo;
}
async function youtubeBotPlugin(fastify) {
/**
* 다음 특정 요일 날짜 계산 (KST 기준)
* @param {number} targetDay - 목표 요일 (0=, 4=)
@ -285,8 +244,12 @@ async function youtubeBotPlugin(fastify, opts) {
}
// 커스텀 설정 적용
if (bot.titleFilter && !video.title.includes(bot.titleFilter)) {
return null;
// 제목 필터: 하나라도 포함되어야 통과
if (bot.titleFilters && bot.titleFilters.length > 0) {
const matchesFilter = bot.titleFilters.some((filter) => video.title.includes(filter));
if (!matchesFilter) {
return null;
}
}
const { autoScheduleNext } = bot;
@ -328,10 +291,11 @@ async function youtubeBotPlugin(fastify, opts) {
);
// 멤버 연결 (커스텀 설정)
if (bot.defaultMemberId || bot.extractMembersFromDesc) {
const hasDefaultMembers = bot.defaultMemberIds && bot.defaultMemberIds.length > 0;
if (hasDefaultMembers || bot.extractMembersFromDesc) {
const memberIds = [];
if (bot.defaultMemberId) {
memberIds.push(bot.defaultMemberId);
if (hasDefaultMembers) {
memberIds.push(...bot.defaultMemberIds);
}
if (nameMap) {
memberIds.push(...extractMemberIds(video.description, nameMap));
@ -366,8 +330,7 @@ async function youtubeBotPlugin(fastify, opts) {
await checkScheduledDeadline(bot);
}
const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId);
const videos = await fetchRecentVideos(bot.channelId, 10, uploadsPlaylistId);
const videos = await fetchRecentVideos(bot.channelId, 10);
let addedCount = 0;
for (const video of videos) {
@ -386,8 +349,7 @@ async function youtubeBotPlugin(fastify, opts) {
* 전체 영상 동기화 (초기화)
*/
async function syncAllVideos(bot) {
const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId);
const videos = await fetchAllVideos(bot.channelId, uploadsPlaylistId);
const videos = await fetchAllVideos(bot.channelId);
let addedCount = 0;
for (const video of videos) {
@ -403,23 +365,23 @@ async function youtubeBotPlugin(fastify, opts) {
}
/**
* 관리 중인 채널 ID 목록
* 관리 중인 채널 ID 목록 (DB에서 조회)
*/
function getManagedChannelIds() {
return bots
.filter(b => b.type === 'youtube')
.map(b => b.channelId);
async function getManagedChannelIds() {
const [rows] = await fastify.db.query(
'SELECT channel_id FROM youtube_bots WHERE enabled = 1'
);
return rows.map(r => r.channel_id);
}
fastify.decorate('youtubeBot', {
syncNewVideos,
syncAllVideos,
getManagedChannelIds,
getChannelInfo: getCachedChannelInfo,
});
}
export default fp(youtubeBotPlugin, {
name: 'youtubeBot',
dependencies: ['db', 'redis'],
dependencies: ['db'],
});

View file

@ -4,7 +4,8 @@
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Youtube, Search, X, ChevronDown, ChevronUp, Clock } from 'lucide-react';
import { Youtube, Search, X, ChevronDown, ChevronUp } from 'lucide-react';
import { getMembers } from '@/api/public/members';
//
const INTERVAL_OPTIONS = [
@ -34,15 +35,22 @@ const TIME_OPTIONS = Array.from({ length: 24 }, (_, i) => ({
}));
/**
* 커스텀 드롭다운 컴포넌트
* 커스텀 드롭다운 컴포넌트 (Portal 사용)
*/
function Dropdown({ value, options, onChange, placeholder = '선택', className = '' }) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
const [position, setPosition] = useState({ top: 0, left: 0, width: 0 });
const buttonRef = useRef(null);
const menuRef = useRef(null);
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
if (
buttonRef.current &&
!buttonRef.current.contains(event.target) &&
menuRef.current &&
!menuRef.current.contains(event.target)
) {
setIsOpen(false);
}
};
@ -53,11 +61,24 @@ function Dropdown({ value, options, onChange, placeholder = '선택', className
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}`} ref={dropdownRef}>
<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"
@ -70,32 +91,163 @@ function Dropdown({ value, options, onChange, placeholder = '선택', className
className={`text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
className="absolute top-full left-0 mt-1 w-full bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-50 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>
{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
)}
</div>
);
}
@ -118,9 +270,23 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
//
const [showAdvanced, setShowAdvanced] = useState(false);
const [titleFilter, setTitleFilter] = useState('');
const [titleFilters, setTitleFilters] = useState([]);
const [filterInput, setFilterInput] = useState('');
const [defaultMemberIds, setDefaultMemberIds] = useState([]);
const [extractMembers, setExtractMembers] = useState(false);
// ( )
const [members, setMembers] = useState([]);
//
useEffect(() => {
if (isOpen) {
getMembers()
.then((data) => setMembers(data.filter((m) => !m.is_former)))
.catch(console.error);
}
}, [isOpen]);
//
useEffect(() => {
if (bot) {
@ -142,7 +308,8 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
setDeadlineDayOfWeek(config.deadlineDayOfWeek ?? 5);
}
setTitleFilter(bot.title_filter || '');
setTitleFilters(bot.title_filters || []);
setDefaultMemberIds(bot.default_member_ids || []);
setExtractMembers(bot.extract_members_from_desc || false);
}
}, [bot]);
@ -159,7 +326,9 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
setTitleTemplate('{channelName} {episode}화');
setDeadlineDayOfWeek(5);
setShowAdvanced(false);
setTitleFilter('');
setTitleFilters([]);
setFilterInput('');
setDefaultMemberIds([]);
setExtractMembers(false);
}
}, [isOpen]);
@ -194,7 +363,6 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
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 }}
@ -382,13 +550,56 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
{/* 제목 필터 */}
<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 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="멤버 선택"
/>
<p className="text-xs text-gray-400 mt-1">
모든 영상에 선택한 멤버를 자동으로 연결합니다
</p>
</div>
{/* 멤버 추출 */}