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 [
|
export default [
|
||||||
{
|
{
|
||||||
id: 'meilisearch-sync',
|
id: 'meilisearch-sync',
|
||||||
|
|
@ -6,41 +7,6 @@ export default [
|
||||||
cron: '0 0 * * *', // 매일 00시 전체 동기화
|
cron: '0 0 * * *', // 매일 00시 전체 동기화
|
||||||
enabled: true,
|
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',
|
id: 'x-fromis9',
|
||||||
type: 'x',
|
type: 'x',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import fp from 'fastify-plugin';
|
import fp from 'fastify-plugin';
|
||||||
import cron from 'node-cron';
|
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 { syncAllSchedules } from '../services/meilisearch/index.js';
|
||||||
import { nowKST } from '../utils/date.js';
|
import { nowKST } from '../utils/date.js';
|
||||||
|
|
||||||
|
|
@ -9,6 +9,63 @@ const TIMEZONE = 'Asia/Seoul';
|
||||||
|
|
||||||
async function schedulerPlugin(fastify, opts) {
|
async function schedulerPlugin(fastify, opts) {
|
||||||
const tasks = new Map();
|
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에 저장
|
* 봇 상태 Redis에 저장
|
||||||
|
|
@ -56,10 +113,10 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 동기화 결과 처리 (중복 코드 제거)
|
* 동기화 결과 처리
|
||||||
*/
|
*/
|
||||||
async function handleSyncResult(botId, result, options = {}) {
|
async function handleSyncResult(botId, result, options = {}) {
|
||||||
const { setRunningStatus = false, setErrorOnFail = false } = options;
|
const { setRunningStatus = false } = options;
|
||||||
const status = await getStatus(botId);
|
const status = await getStatus(botId);
|
||||||
const updateData = {
|
const updateData = {
|
||||||
lastCheckAt: nowKST(),
|
lastCheckAt: nowKST(),
|
||||||
|
|
@ -80,7 +137,7 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
* 봇 시작
|
* 봇 시작
|
||||||
*/
|
*/
|
||||||
async function startBot(botId) {
|
async function startBot(botId) {
|
||||||
const bot = bots.find(b => b.id === botId);
|
const bot = await findBot(botId);
|
||||||
if (!bot) {
|
if (!bot) {
|
||||||
throw new Error(`봇을 찾을 수 없습니다: ${botId}`);
|
throw new Error(`봇을 찾을 수 없습니다: ${botId}`);
|
||||||
}
|
}
|
||||||
|
|
@ -145,7 +202,8 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
* 모든 활성 봇 시작
|
* 모든 활성 봇 시작
|
||||||
*/
|
*/
|
||||||
async function startAll() {
|
async function startAll() {
|
||||||
for (const bot of bots) {
|
const allBots = await getAllBots(true); // DB에서 새로 로드
|
||||||
|
for (const bot of allBots) {
|
||||||
if (bot.enabled) {
|
if (bot.enabled) {
|
||||||
try {
|
try {
|
||||||
await startBot(bot.id);
|
await startBot(bot.id);
|
||||||
|
|
@ -167,6 +225,13 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
tasks.clear();
|
tasks.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 봇 캐시 갱신 (봇 추가/수정/삭제 시 호출)
|
||||||
|
*/
|
||||||
|
function invalidateCache() {
|
||||||
|
cachedBots = null;
|
||||||
|
}
|
||||||
|
|
||||||
// 데코레이터 등록
|
// 데코레이터 등록
|
||||||
fastify.decorate('scheduler', {
|
fastify.decorate('scheduler', {
|
||||||
startBot,
|
startBot,
|
||||||
|
|
@ -174,7 +239,8 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
startAll,
|
startAll,
|
||||||
stopAll,
|
stopAll,
|
||||||
getStatus,
|
getStatus,
|
||||||
getBots: () => bots,
|
getBots: () => getAllBots(),
|
||||||
|
invalidateCache,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 앱 종료 시 모든 봇 정지
|
// 앱 종료 시 모든 봇 정지
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import bots from '../../config/bots.js';
|
|
||||||
import { errorResponse } from '../../schemas/index.js';
|
import { errorResponse } from '../../schemas/index.js';
|
||||||
import { syncAllSchedules } from '../../services/meilisearch/index.js';
|
import { syncAllSchedules } from '../../services/meilisearch/index.js';
|
||||||
import { badRequest, notFound, serverError } from '../../utils/error.js';
|
import { badRequest, notFound, serverError } from '../../utils/error.js';
|
||||||
|
|
@ -57,9 +56,10 @@ export default async function botsRoutes(fastify) {
|
||||||
},
|
},
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
|
const allBots = await scheduler.getBots();
|
||||||
const result = [];
|
const result = [];
|
||||||
|
|
||||||
for (const bot of bots) {
|
for (const bot of allBots) {
|
||||||
const status = await scheduler.getStatus(bot.id);
|
const status = await scheduler.getStatus(bot.id);
|
||||||
|
|
||||||
// cron 표현식에서 간격 추출 (분 단위, 일일 스케줄은 1440분)
|
// cron 표현식에서 간격 추출 (분 단위, 일일 스케줄은 1440분)
|
||||||
|
|
@ -187,7 +187,8 @@ export default async function botsRoutes(fastify) {
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { id } = request.params;
|
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) {
|
if (!bot) {
|
||||||
return notFound(reply, '봇을 찾을 수 없습니다.');
|
return notFound(reply, '봇을 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -154,18 +154,14 @@ export default async function schedulesRoutes(fastify) {
|
||||||
// 유튜브 카테고리인 경우 채널 배너 이미지 추가
|
// 유튜브 카테고리인 경우 채널 배너 이미지 추가
|
||||||
if (result.category?.id === CATEGORY_IDS.YOUTUBE) {
|
if (result.category?.id === CATEGORY_IDS.YOUTUBE) {
|
||||||
const [youtubeData] = await db.query(
|
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]
|
[request.params.id]
|
||||||
);
|
);
|
||||||
if (youtubeData.length > 0 && youtubeData[0].channel_id) {
|
if (youtubeData.length > 0 && youtubeData[0].banner_url) {
|
||||||
try {
|
result.bannerUrl = youtubeData[0].banner_url;
|
||||||
const channelInfo = await fastify.youtubeBot.getChannelInfo(youtubeData[0].channel_id);
|
|
||||||
if (channelInfo?.bannerUrl) {
|
|
||||||
result.bannerUrl = channelInfo.bannerUrl;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
fastify.log.warn(`채널 정보 조회 실패: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import fp from 'fastify-plugin';
|
||||||
import { fetchTweets, fetchAllTweets, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js';
|
import { fetchTweets, fetchAllTweets, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js';
|
||||||
import { fetchVideoInfo } from '../youtube/api.js';
|
import { fetchVideoInfo } from '../youtube/api.js';
|
||||||
import { formatDate, formatTime, nowKST } from '../../utils/date.js';
|
import { formatDate, formatTime, nowKST } from '../../utils/date.js';
|
||||||
import bots from '../../config/bots.js';
|
|
||||||
import { withTransaction } from '../../utils/transaction.js';
|
import { withTransaction } from '../../utils/transaction.js';
|
||||||
import { syncScheduleById } from '../meilisearch/index.js';
|
import { syncScheduleById } from '../meilisearch/index.js';
|
||||||
|
|
||||||
|
|
@ -13,12 +12,13 @@ const PROFILE_TTL = 604800; // 7일
|
||||||
|
|
||||||
async function xBotPlugin(fastify, opts) {
|
async function xBotPlugin(fastify, opts) {
|
||||||
/**
|
/**
|
||||||
* 관리 중인 YouTube 채널 ID 목록
|
* 관리 중인 YouTube 채널 ID 목록 (DB에서 조회)
|
||||||
*/
|
*/
|
||||||
function getManagedChannelIds() {
|
async function getManagedChannelIds() {
|
||||||
return bots
|
const [rows] = await fastify.db.query(
|
||||||
.filter(b => b.type === 'youtube')
|
'SELECT channel_id FROM youtube_bots WHERE enabled = 1'
|
||||||
.map(b => b.channelId);
|
);
|
||||||
|
return rows.map(r => r.channel_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -131,7 +131,7 @@ async function xBotPlugin(fastify, opts) {
|
||||||
const videoIds = extractYoutubeVideoIds(tweet.text);
|
const videoIds = extractYoutubeVideoIds(tweet.text);
|
||||||
if (videoIds.length === 0) return 0;
|
if (videoIds.length === 0) return 0;
|
||||||
|
|
||||||
const managedChannels = getManagedChannelIds();
|
const managedChannels = await getManagedChannelIds();
|
||||||
let addedCount = 0;
|
let addedCount = 0;
|
||||||
|
|
||||||
for (const videoId of videoIds) {
|
for (const videoId of videoIds) {
|
||||||
|
|
|
||||||
|
|
@ -97,9 +97,8 @@ async function getVideoDurations(videoIds) {
|
||||||
* 최근 N개 영상 조회 (Activities API 사용 - playlistItems보다 빠른 반영)
|
* 최근 N개 영상 조회 (Activities API 사용 - playlistItems보다 빠른 반영)
|
||||||
* @param {string} channelId - 채널 ID
|
* @param {string} channelId - 채널 ID
|
||||||
* @param {number} maxResults - 최대 결과 수
|
* @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배 조회)
|
// Activities API에 type=upload 지정 (다른 활동이 섞일 수 있어 2배 조회)
|
||||||
const fetchCount = Math.min(maxResults * 2, 50);
|
const fetchCount = Math.min(maxResults * 2, 50);
|
||||||
const url = `${API_BASE}/activities?part=snippet,contentDetails&channelId=${channelId}&type=upload&maxResults=${fetchCount}&key=${API_KEY}`;
|
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 fp from 'fastify-plugin';
|
||||||
import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId, getChannelInfo } from './api.js';
|
import { fetchRecentVideos, fetchAllVideos } from './api.js';
|
||||||
import bots from '../../config/bots.js';
|
|
||||||
import { CATEGORY_IDS } from '../../config/index.js';
|
import { CATEGORY_IDS } from '../../config/index.js';
|
||||||
import { withTransaction } from '../../utils/transaction.js';
|
import { withTransaction } from '../../utils/transaction.js';
|
||||||
import { syncScheduleById, deleteSchedule } from '../meilisearch/index.js';
|
import { syncScheduleById, deleteSchedule } from '../meilisearch/index.js';
|
||||||
|
|
||||||
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE;
|
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 기준)
|
* 다음 특정 요일 날짜 계산 (KST 기준)
|
||||||
* @param {number} targetDay - 목표 요일 (0=일, 4=목)
|
* @param {number} targetDay - 목표 요일 (0=일, 4=목)
|
||||||
|
|
@ -285,9 +244,13 @@ async function youtubeBotPlugin(fastify, opts) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 커스텀 설정 적용
|
// 커스텀 설정 적용
|
||||||
if (bot.titleFilter && !video.title.includes(bot.titleFilter)) {
|
// 제목 필터: 하나라도 포함되어야 통과
|
||||||
|
if (bot.titleFilters && bot.titleFilters.length > 0) {
|
||||||
|
const matchesFilter = bot.titleFilters.some((filter) => video.title.includes(filter));
|
||||||
|
if (!matchesFilter) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { autoScheduleNext } = bot;
|
const { autoScheduleNext } = bot;
|
||||||
const isVideoType = video.videoType === 'video'; // 쇼츠가 아닌 일반 영상
|
const isVideoType = video.videoType === 'video'; // 쇼츠가 아닌 일반 영상
|
||||||
|
|
@ -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 = [];
|
const memberIds = [];
|
||||||
if (bot.defaultMemberId) {
|
if (hasDefaultMembers) {
|
||||||
memberIds.push(bot.defaultMemberId);
|
memberIds.push(...bot.defaultMemberIds);
|
||||||
}
|
}
|
||||||
if (nameMap) {
|
if (nameMap) {
|
||||||
memberIds.push(...extractMemberIds(video.description, nameMap));
|
memberIds.push(...extractMemberIds(video.description, nameMap));
|
||||||
|
|
@ -366,8 +330,7 @@ async function youtubeBotPlugin(fastify, opts) {
|
||||||
await checkScheduledDeadline(bot);
|
await checkScheduledDeadline(bot);
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId);
|
const videos = await fetchRecentVideos(bot.channelId, 10);
|
||||||
const videos = await fetchRecentVideos(bot.channelId, 10, uploadsPlaylistId);
|
|
||||||
let addedCount = 0;
|
let addedCount = 0;
|
||||||
|
|
||||||
for (const video of videos) {
|
for (const video of videos) {
|
||||||
|
|
@ -386,8 +349,7 @@ async function youtubeBotPlugin(fastify, opts) {
|
||||||
* 전체 영상 동기화 (초기화)
|
* 전체 영상 동기화 (초기화)
|
||||||
*/
|
*/
|
||||||
async function syncAllVideos(bot) {
|
async function syncAllVideos(bot) {
|
||||||
const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId);
|
const videos = await fetchAllVideos(bot.channelId);
|
||||||
const videos = await fetchAllVideos(bot.channelId, uploadsPlaylistId);
|
|
||||||
let addedCount = 0;
|
let addedCount = 0;
|
||||||
|
|
||||||
for (const video of videos) {
|
for (const video of videos) {
|
||||||
|
|
@ -403,23 +365,23 @@ async function youtubeBotPlugin(fastify, opts) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리 중인 채널 ID 목록
|
* 관리 중인 채널 ID 목록 (DB에서 조회)
|
||||||
*/
|
*/
|
||||||
function getManagedChannelIds() {
|
async function getManagedChannelIds() {
|
||||||
return bots
|
const [rows] = await fastify.db.query(
|
||||||
.filter(b => b.type === 'youtube')
|
'SELECT channel_id FROM youtube_bots WHERE enabled = 1'
|
||||||
.map(b => b.channelId);
|
);
|
||||||
|
return rows.map(r => r.channel_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
fastify.decorate('youtubeBot', {
|
fastify.decorate('youtubeBot', {
|
||||||
syncNewVideos,
|
syncNewVideos,
|
||||||
syncAllVideos,
|
syncAllVideos,
|
||||||
getManagedChannelIds,
|
getManagedChannelIds,
|
||||||
getChannelInfo: getCachedChannelInfo,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default fp(youtubeBotPlugin, {
|
export default fp(youtubeBotPlugin, {
|
||||||
name: 'youtubeBot',
|
name: 'youtubeBot',
|
||||||
dependencies: ['db', 'redis'],
|
dependencies: ['db'],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
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 = [
|
const INTERVAL_OPTIONS = [
|
||||||
|
|
@ -34,15 +35,22 @@ const TIME_OPTIONS = Array.from({ length: 24 }, (_, i) => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 커스텀 드롭다운 컴포넌트
|
* 커스텀 드롭다운 컴포넌트 (Portal 사용)
|
||||||
*/
|
*/
|
||||||
function Dropdown({ value, options, onChange, placeholder = '선택', className = '' }) {
|
function Dropdown({ value, options, onChange, placeholder = '선택', className = '' }) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event) => {
|
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);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -53,11 +61,24 @@ function Dropdown({ value, options, onChange, placeholder = '선택', className
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, [isOpen]);
|
}, [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);
|
const selectedOption = options.find((opt) => opt.value === value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative ${className}`} ref={dropdownRef}>
|
<div className={`relative ${className}`}>
|
||||||
<button
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
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"
|
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,13 +91,23 @@ function Dropdown({ value, options, onChange, placeholder = '선택', className
|
||||||
className={`text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
className={`text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
{createPortal(
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -5 }}
|
ref={menuRef}
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -5 }}
|
exit={{ opacity: 0, y: -10 }}
|
||||||
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"
|
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) => (
|
{options.map((opt) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -95,7 +126,128 @@ function Dropdown({ value, options, onChange, placeholder = '선택', className
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -118,9 +270,23 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
|
||||||
|
|
||||||
// 고급 설정
|
// 고급 설정
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
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 [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(() => {
|
useEffect(() => {
|
||||||
if (bot) {
|
if (bot) {
|
||||||
|
|
@ -142,7 +308,8 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
|
||||||
setDeadlineDayOfWeek(config.deadlineDayOfWeek ?? 5);
|
setDeadlineDayOfWeek(config.deadlineDayOfWeek ?? 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTitleFilter(bot.title_filter || '');
|
setTitleFilters(bot.title_filters || []);
|
||||||
|
setDefaultMemberIds(bot.default_member_ids || []);
|
||||||
setExtractMembers(bot.extract_members_from_desc || false);
|
setExtractMembers(bot.extract_members_from_desc || false);
|
||||||
}
|
}
|
||||||
}, [bot]);
|
}, [bot]);
|
||||||
|
|
@ -159,7 +326,9 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
|
||||||
setTitleTemplate('{channelName} {episode}화');
|
setTitleTemplate('{channelName} {episode}화');
|
||||||
setDeadlineDayOfWeek(5);
|
setDeadlineDayOfWeek(5);
|
||||||
setShowAdvanced(false);
|
setShowAdvanced(false);
|
||||||
setTitleFilter('');
|
setTitleFilters([]);
|
||||||
|
setFilterInput('');
|
||||||
|
setDefaultMemberIds([]);
|
||||||
setExtractMembers(false);
|
setExtractMembers(false);
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
@ -194,7 +363,6 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
onClick={onClose}
|
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.95, opacity: 0 }}
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
|
@ -382,14 +550,57 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
|
||||||
{/* 제목 필터 */}
|
{/* 제목 필터 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-gray-600 mb-1">제목 필터</label>
|
<label className="block text-sm text-gray-600 mb-1">제목 필터</label>
|
||||||
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={titleFilter}
|
value={filterInput}
|
||||||
onChange={(e) => setTitleFilter(e.target.value)}
|
onChange={(e) => setFilterInput(e.target.value)}
|
||||||
placeholder="특정 키워드가 포함된 영상만 추가"
|
onKeyDown={(e) => {
|
||||||
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"
|
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>
|
</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>
|
||||||
|
|
||||||
{/* 멤버 추출 */}
|
{/* 멤버 추출 */}
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue