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:
parent
730da864a4
commit
a8c12aa76d
10 changed files with 409 additions and 163 deletions
25
backend/sql/youtube_bots.sql
Normal file
25
backend/sql/youtube_bots.sql
Normal 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)
|
||||
);
|
||||
20
backend/sql/youtube_bots_seed.sql
Normal file
20
backend/sql/youtube_bots_seed.sql
Normal 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';
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
// 앱 종료 시 모든 봇 정지
|
||||
|
|
|
|||
|
|
@ -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, '봇을 찾을 수 없습니다.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* 멤버 추출 */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue