Compare commits
75 commits
83c955f8a9
...
9d18449d3a
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d18449d3a | |||
| 8effebf681 | |||
| 159dd5c000 | |||
| 4005228270 | |||
| aa95f737ba | |||
| abf71d97d7 | |||
| 607a652c2b | |||
| aa6c05e6b5 | |||
| 414b798914 | |||
| 1f1d6987d1 | |||
| 357fd7fc88 | |||
| c6332c4f96 | |||
| 01cf083da2 | |||
| c4cd0dec30 | |||
| 9335720fa8 | |||
| 9bb6aedca7 | |||
| 45adaaf0dc | |||
| f8acb5450f | |||
| 3feb23f67f | |||
| 91d4442d30 | |||
| d4ed9ef66b | |||
| ba7def935c | |||
| 8ecc4e6263 | |||
| 406098d1b9 | |||
| d01f7e60dc | |||
| f90a5f4b17 | |||
| d50488d7e3 | |||
| 45da9c6277 | |||
| 9163ade56d | |||
| 7d140aa1f3 | |||
| c86cda00ae | |||
| 294018c93b | |||
| 5d44434e36 | |||
| 9ceef6c656 | |||
| eeb5e7234c | |||
| ec0e587434 | |||
| 86769f1edc | |||
| 25c2b45cf5 | |||
| 2355068c77 | |||
| 535fbb6768 | |||
| 4f11e14b12 | |||
| 2e7fe697fc | |||
| ec3839bcc7 | |||
| a8c12aa76d | |||
| 730da864a4 | |||
| f3f99c7428 | |||
| 3fa9f1520a | |||
| 802aacd22e | |||
| 2417cd287d | |||
| de3cb91191 | |||
| 0c9dd44c2b | |||
| 6b39cf043f | |||
| 47afd68921 | |||
| e729d33aee | |||
| b5118f2dea | |||
| dbfee503d5 | |||
| b3357e0663 | |||
| 68027f0654 | |||
| 1c04f4ed6d | |||
| 46295a5f15 | |||
| 78eb513c28 | |||
| 7d56531bee | |||
| 47cd93173c | |||
| 277c6a79c9 | |||
| f01b2b8054 | |||
| cb184e4fa5 | |||
| eb7d2005b7 | |||
| b16aa963cd | |||
| e759d14ed6 | |||
| 9735206da7 | |||
| 48f41c6db0 | |||
| 65b1d931f3 | |||
| ad8406fdd7 | |||
| 169c584d31 | |||
| 7f3fe7e251 |
81 changed files with 7438 additions and 671 deletions
7
.env
7
.env
|
|
@ -20,6 +20,9 @@ RUSTFS_BUCKET=fromis-9
|
|||
# Kakao API
|
||||
KAKAO_REST_KEY=e7a5516bf6cb1b398857789ee2ea6eea
|
||||
|
||||
# YouTube API
|
||||
YOUTUBE_API_KEY=AIzaSyC6l3nFlcHgLc0d1Q9WPyYQjVKTv21ZqFs
|
||||
# Google API
|
||||
GOOGLE_API_KEY=AIzaSyC6l3nFlcHgLc0d1Q9WPyYQjVKTv21ZqFs
|
||||
|
||||
# Meilisearch
|
||||
MEILI_MASTER_KEY=xMLNzlGX4xYji494JOb5IMlLHULcYw91
|
||||
|
||||
|
|
|
|||
|
|
@ -33,3 +33,4 @@ DB 및 외부 서비스 접근 정보는 `.env` 파일 참조:
|
|||
## 작업 시 주의사항
|
||||
|
||||
- **문서 업데이트 필수**: 작업이 완료되면 항상 `docs/` 폴더의 관련 문서를 업데이트할 것
|
||||
- **활동 로그 필수**: 새로운 관리자 라우트나 봇 기능을 추가할 때 `logActivity` 호출을 포함할 것 (자세한 사용법은 `docs/development.md` 참조)
|
||||
|
|
|
|||
15
backend/sql/bot_x.sql
Normal file
15
backend/sql/bot_x.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
-- X 봇 테이블
|
||||
CREATE TABLE IF NOT EXISTS bot_x (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL,
|
||||
display_name VARCHAR(100),
|
||||
avatar_url VARCHAR(500),
|
||||
text_filters LONGTEXT,
|
||||
include_retweets TINYINT(1) DEFAULT 0,
|
||||
extract_youtube TINYINT(1) NOT NULL DEFAULT 0,
|
||||
cron_interval INT DEFAULT 1,
|
||||
enabled TINYINT(1) DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_username (username)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
6
backend/sql/bot_x_seed.sql
Normal file
6
backend/sql/bot_x_seed.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
-- X 봇 초기 데이터
|
||||
-- 기존 config/bots.js에 하드코딩된 X 봇을 DB로 마이그레이션
|
||||
|
||||
INSERT INTO bot_x (username, display_name, cron_interval, enabled)
|
||||
VALUES ('realfromis_9', 'fromis_9', 1, 1)
|
||||
ON DUPLICATE KEY UPDATE display_name = VALUES(display_name);
|
||||
25
backend/sql/bot_youtube.sql
Normal file
25
backend/sql/bot_youtube.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
-- YouTube 봇 테이블
|
||||
CREATE TABLE IF NOT EXISTS bot_youtube (
|
||||
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/bot_youtube_seed.sql
Normal file
20
backend/sql/bot_youtube_seed.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-- YouTube 봇 시드 데이터
|
||||
-- channel_handle은 봇 추가 시 YouTube API로 조회하여 저장
|
||||
|
||||
INSERT INTO bot_youtube (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 bot_youtube
|
||||
SET auto_schedule_config = '{"dayOfWeek":4,"time":"18:00:00","titleTemplate":"{channelName} {episode}화","deadlineDayOfWeek":5,"excludeShorts":true}'
|
||||
WHERE channel_id = 'UCeUJ8B3krxw8zuDi19AlhaA';
|
||||
|
||||
-- MUSINSA TV - 필터/멤버 설정
|
||||
UPDATE bot_youtube
|
||||
SET title_filters = '["성수기"]',
|
||||
default_member_ids = '[7]',
|
||||
extract_members_from_desc = 1
|
||||
WHERE channel_id = 'UCtfyAiqf095_0_ux8ruwGfA';
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
-- X 프로필 테이블
|
||||
CREATE TABLE IF NOT EXISTS x_profiles (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
display_name VARCHAR(100),
|
||||
avatar_url TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_username (username)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
|
@ -1,44 +1,10 @@
|
|||
// 정적 봇 설정 (YouTube, X 봇은 DB에서 관리)
|
||||
export default [
|
||||
{
|
||||
id: 'meilisearch-sync',
|
||||
type: 'meilisearch',
|
||||
name: 'Meilisearch 동기화',
|
||||
cron: '0 12 * * *', // 매일 12시 전체 동기화
|
||||
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,
|
||||
},
|
||||
{
|
||||
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',
|
||||
username: 'realfromis_9',
|
||||
nitterUrl: process.env.NITTER_URL || 'http://nitter:8080',
|
||||
cron: '*/1 * * * *',
|
||||
cron: '0 0 * * *', // 매일 00시 전체 동기화
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
export const CATEGORY_IDS = {
|
||||
YOUTUBE: 2,
|
||||
X: 3,
|
||||
CONCERT: 6,
|
||||
BIRTHDAY: 8,
|
||||
DEBUT: 9,
|
||||
};
|
||||
|
|
@ -46,8 +47,8 @@ export default {
|
|||
host: process.env.REDIS_HOST || 'fromis9-redis',
|
||||
port: parseInt(process.env.REDIS_PORT) || 6379,
|
||||
},
|
||||
youtube: {
|
||||
apiKey: process.env.YOUTUBE_API_KEY,
|
||||
google: {
|
||||
apiKey: process.env.GOOGLE_API_KEY,
|
||||
},
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,100 @@
|
|||
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';
|
||||
import { logActivity } from '../utils/log.js';
|
||||
|
||||
const REDIS_PREFIX = 'bot:status:';
|
||||
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 bot_youtube'
|
||||
);
|
||||
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에서 X 봇 목록 조회
|
||||
*/
|
||||
async function getXBotsFromDB() {
|
||||
const [rows] = await fastify.db.query(
|
||||
'SELECT * FROM bot_x'
|
||||
);
|
||||
return rows.map(row => ({
|
||||
id: `x-${row.id}`,
|
||||
dbId: row.id,
|
||||
type: 'x',
|
||||
username: row.username,
|
||||
displayName: row.display_name,
|
||||
avatarUrl: row.avatar_url,
|
||||
nitterUrl: process.env.NITTER_URL || 'http://nitter:8080',
|
||||
cron: `*/${row.cron_interval} * * * *`,
|
||||
enabled: row.enabled === 1,
|
||||
textFilters: row.text_filters
|
||||
? (typeof row.text_filters === 'string'
|
||||
? JSON.parse(row.text_filters)
|
||||
: row.text_filters)
|
||||
: [],
|
||||
includeRetweets: row.include_retweets === 1,
|
||||
extractYoutube: row.extract_youtube === 1,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 봇 목록 가져오기 (정적 + DB)
|
||||
*/
|
||||
async function getAllBots(forceRefresh = false) {
|
||||
if (cachedBots && !forceRefresh) {
|
||||
return cachedBots;
|
||||
}
|
||||
const youtubeBots = await getYouTubeBotsFromDB();
|
||||
const xBots = await getXBotsFromDB();
|
||||
cachedBots = [...staticBots, ...youtubeBots, ...xBots];
|
||||
return cachedBots;
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 ID로 봇 찾기
|
||||
*/
|
||||
async function findBot(botId) {
|
||||
const allBots = await getAllBots();
|
||||
return allBots.find(b => b.id === botId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 상태 Redis에 저장
|
||||
|
|
@ -56,10 +142,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(),
|
||||
|
|
@ -76,11 +162,23 @@ async function schedulerPlugin(fastify, opts) {
|
|||
return result.addedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* DB의 enabled 필드 업데이트 (정적 봇은 무시)
|
||||
*/
|
||||
async function setEnabled(botId, enabled) {
|
||||
const match = botId.match(/^(youtube|x)-(\d+)$/);
|
||||
if (!match) return; // 정적 봇 (meilisearch 등)
|
||||
const table = match[1] === 'x' ? 'bot_x' : 'bot_youtube';
|
||||
const dbId = match[2];
|
||||
await fastify.db.query(`UPDATE ${table} SET enabled = ? WHERE id = ?`, [enabled ? 1 : 0, dbId]);
|
||||
invalidateCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 시작
|
||||
*/
|
||||
async function startBot(botId) {
|
||||
const bot = bots.find(b => b.id === botId);
|
||||
const bot = await findBot(botId);
|
||||
if (!bot) {
|
||||
throw new Error(`봇을 찾을 수 없습니다: ${botId}`);
|
||||
}
|
||||
|
|
@ -91,6 +189,9 @@ async function schedulerPlugin(fastify, opts) {
|
|||
tasks.delete(botId);
|
||||
}
|
||||
|
||||
// DB enabled 활성화
|
||||
await setEnabled(botId, true);
|
||||
|
||||
const syncFn = getSyncFunction(bot);
|
||||
if (!syncFn) {
|
||||
throw new Error(`지원하지 않는 봇 타입: ${bot.type}`);
|
||||
|
|
@ -103,6 +204,15 @@ async function schedulerPlugin(fastify, opts) {
|
|||
const result = await syncFn(bot);
|
||||
const addedCount = await handleSyncResult(botId, result, { setRunningStatus: true });
|
||||
fastify.log.info(`[${botId}] 동기화 완료: ${addedCount}개 추가`);
|
||||
if (addedCount > 0) {
|
||||
logActivity(fastify.db, {
|
||||
actor: botId,
|
||||
action: 'sync_complete',
|
||||
category: 'sync',
|
||||
summary: `${botId} 동기화 완료: ${addedCount}개 추가`,
|
||||
details: { addedCount },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
await updateStatus(botId, {
|
||||
status: 'error',
|
||||
|
|
@ -110,6 +220,13 @@ async function schedulerPlugin(fastify, opts) {
|
|||
errorMessage: err.message,
|
||||
});
|
||||
fastify.log.error(`[${botId}] 동기화 오류: ${err.message}`);
|
||||
logActivity(fastify.db, {
|
||||
actor: botId,
|
||||
action: 'error',
|
||||
category: 'sync',
|
||||
summary: `${botId} 동기화 오류: ${err.message}`,
|
||||
details: { error: err.message },
|
||||
});
|
||||
}
|
||||
}, { timezone: TIMEZONE });
|
||||
|
||||
|
|
@ -123,8 +240,24 @@ async function schedulerPlugin(fastify, opts) {
|
|||
const result = await syncFn(bot);
|
||||
const addedCount = await handleSyncResult(botId, result);
|
||||
fastify.log.info(`[${botId}] 초기 동기화 완료: ${addedCount}개 추가`);
|
||||
if (addedCount > 0) {
|
||||
logActivity(fastify.db, {
|
||||
actor: botId,
|
||||
action: 'sync_complete',
|
||||
category: 'sync',
|
||||
summary: `${botId} 초기 동기화 완료: ${addedCount}개 추가`,
|
||||
details: { addedCount },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
fastify.log.error(`[${botId}] 초기 동기화 오류: ${err.message}`);
|
||||
logActivity(fastify.db, {
|
||||
actor: botId,
|
||||
action: 'error',
|
||||
category: 'sync',
|
||||
summary: `${botId} 초기 동기화 오류: ${err.message}`,
|
||||
details: { error: err.message },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -137,6 +270,8 @@ async function schedulerPlugin(fastify, opts) {
|
|||
tasks.get(botId).stop();
|
||||
tasks.delete(botId);
|
||||
}
|
||||
// DB enabled 비활성화
|
||||
await setEnabled(botId, false);
|
||||
await updateStatus(botId, { status: 'stopped' });
|
||||
fastify.log.info(`[${botId}] 스케줄 정지`);
|
||||
}
|
||||
|
|
@ -145,7 +280,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);
|
||||
|
|
@ -154,6 +290,7 @@ async function schedulerPlugin(fastify, opts) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -167,6 +304,13 @@ async function schedulerPlugin(fastify, opts) {
|
|||
tasks.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 캐시 갱신 (봇 추가/수정/삭제 시 호출)
|
||||
*/
|
||||
function invalidateCache() {
|
||||
cachedBots = null;
|
||||
}
|
||||
|
||||
// 데코레이터 등록
|
||||
fastify.decorate('scheduler', {
|
||||
startBot,
|
||||
|
|
@ -174,7 +318,8 @@ async function schedulerPlugin(fastify, opts) {
|
|||
startAll,
|
||||
stopAll,
|
||||
getStatus,
|
||||
getBots: () => bots,
|
||||
getBots: (forceRefresh = false) => getAllBots(forceRefresh),
|
||||
invalidateCache,
|
||||
});
|
||||
|
||||
// 앱 종료 시 모든 봇 정지
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
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';
|
||||
import { nowKST } from '../../utils/date.js';
|
||||
import { logActivity } from '../../utils/log.js';
|
||||
|
||||
// 봇 관련 스키마
|
||||
const botResponse = {
|
||||
|
|
@ -19,6 +19,22 @@ const botResponse = {
|
|||
check_interval: { type: 'integer' },
|
||||
error_message: { type: 'string' },
|
||||
enabled: { type: 'boolean' },
|
||||
// YouTube 봇 전용 필드
|
||||
db_id: { type: 'integer' },
|
||||
channel_id: { type: 'string' },
|
||||
channel_handle: { type: 'string' },
|
||||
channel_name: { type: 'string' },
|
||||
banner_url: { type: 'string' },
|
||||
cron_interval: { type: 'integer' },
|
||||
title_filters: { type: 'array', items: { type: 'string' } },
|
||||
default_member_ids: { type: 'array', items: { type: 'integer' } },
|
||||
extract_members_from_desc: { type: 'boolean' },
|
||||
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||
// X 봇 전용 필드
|
||||
username: { type: 'string' },
|
||||
display_name: { type: 'string' },
|
||||
avatar_url: { type: 'string' },
|
||||
text_filters: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -35,7 +51,7 @@ const botIdParam = {
|
|||
* 인증 필요
|
||||
*/
|
||||
export default async function botsRoutes(fastify) {
|
||||
const { scheduler, redis } = fastify;
|
||||
const { scheduler, redis, db } = fastify;
|
||||
const QUOTA_WARNING_KEY = 'youtube:quota_warning';
|
||||
|
||||
/**
|
||||
|
|
@ -57,9 +73,11 @@ export default async function botsRoutes(fastify) {
|
|||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
// API 호출 시에는 항상 fresh한 데이터 반환
|
||||
const allBots = await scheduler.getBots(true);
|
||||
const result = [];
|
||||
|
||||
for (const bot of bots) {
|
||||
for (const bot of allBots) {
|
||||
const status = await scheduler.getStatus(bot.id);
|
||||
|
||||
// cron 표현식에서 간격 추출 (분 단위, 일일 스케줄은 1440분)
|
||||
|
|
@ -72,7 +90,7 @@ export default async function botsRoutes(fastify) {
|
|||
checkInterval = 1440; // 24시간 = 1440분
|
||||
}
|
||||
|
||||
result.push({
|
||||
const botData = {
|
||||
id: bot.id,
|
||||
name: bot.name || bot.channelName || bot.username || bot.id,
|
||||
type: bot.type,
|
||||
|
|
@ -84,7 +102,33 @@ export default async function botsRoutes(fastify) {
|
|||
check_interval: checkInterval,
|
||||
error_message: status.errorMessage,
|
||||
enabled: bot.enabled,
|
||||
});
|
||||
};
|
||||
|
||||
// YouTube 봇인 경우 상세 정보 추가
|
||||
if (bot.type === 'youtube') {
|
||||
botData.db_id = bot.dbId;
|
||||
botData.channel_id = bot.channelId;
|
||||
botData.channel_handle = bot.channelHandle;
|
||||
botData.channel_name = bot.channelName;
|
||||
botData.banner_url = bot.bannerUrl;
|
||||
botData.cron_interval = checkInterval;
|
||||
botData.title_filters = bot.titleFilters || [];
|
||||
botData.default_member_ids = bot.defaultMemberIds || [];
|
||||
botData.extract_members_from_desc = bot.extractMembersFromDesc || false;
|
||||
botData.auto_schedule_config = bot.autoScheduleNext || null;
|
||||
}
|
||||
|
||||
// X 봇인 경우 상세 정보 추가
|
||||
if (bot.type === 'x') {
|
||||
botData.db_id = bot.dbId;
|
||||
botData.username = bot.username;
|
||||
botData.display_name = bot.displayName;
|
||||
botData.avatar_url = bot.avatarUrl;
|
||||
botData.text_filters = bot.textFilters || [];
|
||||
botData.cron_interval = checkInterval;
|
||||
}
|
||||
|
||||
result.push(botData);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -118,6 +162,7 @@ export default async function botsRoutes(fastify) {
|
|||
|
||||
try {
|
||||
await scheduler.startBot(id);
|
||||
logActivity(db, { actor: 'admin', action: 'start', category: 'bot', targetType: null, targetId: null, summary: `봇 시작: ${id}` });
|
||||
return { success: true, message: '봇이 시작되었습니다.' };
|
||||
} catch (err) {
|
||||
return badRequest(reply, err.message);
|
||||
|
|
@ -152,6 +197,7 @@ export default async function botsRoutes(fastify) {
|
|||
|
||||
try {
|
||||
await scheduler.stopBot(id);
|
||||
logActivity(db, { actor: 'admin', action: 'stop', category: 'bot', targetType: null, targetId: null, summary: `봇 정지: ${id}` });
|
||||
return { success: true, message: '봇이 정지되었습니다.' };
|
||||
} catch (err) {
|
||||
return badRequest(reply, err.message);
|
||||
|
|
@ -187,7 +233,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, '봇을 찾을 수 없습니다.');
|
||||
}
|
||||
|
|
@ -219,6 +266,7 @@ export default async function botsRoutes(fastify) {
|
|||
updatedAt: nowKST(),
|
||||
}));
|
||||
|
||||
logActivity(db, { actor: 'admin', action: 'sync_complete', category: 'sync', targetType: null, targetId: null, summary: `전체 동기화: ${id} (${result.addedCount}개 추가)` });
|
||||
return {
|
||||
success: true,
|
||||
addedCount: result.addedCount,
|
||||
|
|
|
|||
212
backend/src/routes/admin/concert.js
Normal file
212
backend/src/routes/admin/concert.js
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { addOrUpdateSchedule } from '../../services/meilisearch/index.js';
|
||||
import { uploadConcertPoster, uploadConcertMerchandise } from '../../services/image.js';
|
||||
import { CATEGORY_IDS } from '../../config/index.js';
|
||||
import { withTransaction } from '../../utils/transaction.js';
|
||||
import { badRequest, serverError } from '../../utils/error.js';
|
||||
import { logActivity } from '../../utils/log.js';
|
||||
|
||||
const CONCERT_CATEGORY_ID = CATEGORY_IDS.CONCERT;
|
||||
|
||||
/**
|
||||
* 콘서트 관련 관리자 라우트
|
||||
*/
|
||||
export default async function concertRoutes(fastify) {
|
||||
const { db, meilisearch } = fastify;
|
||||
|
||||
/**
|
||||
* POST /api/admin/concert/schedule
|
||||
* 콘서트 일정 저장 (multipart/form-data)
|
||||
*/
|
||||
fastify.post('/schedule', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const parts = request.parts();
|
||||
|
||||
// multipart 파싱
|
||||
let title = '';
|
||||
let memberIds = [];
|
||||
let rounds = [];
|
||||
let setlist = [];
|
||||
let posterBuffer = null;
|
||||
const merchandiseBuffers = [];
|
||||
|
||||
for await (const part of parts) {
|
||||
if (part.type === 'file') {
|
||||
const buffer = await part.toBuffer();
|
||||
if (part.fieldname === 'poster') {
|
||||
posterBuffer = buffer;
|
||||
} else if (part.fieldname === 'merchandise') {
|
||||
merchandiseBuffers.push(buffer);
|
||||
}
|
||||
} else {
|
||||
// field
|
||||
if (part.fieldname === 'title') title = part.value;
|
||||
else if (part.fieldname === 'memberIds') memberIds = JSON.parse(part.value);
|
||||
else if (part.fieldname === 'rounds') rounds = JSON.parse(part.value);
|
||||
else if (part.fieldname === 'setlist') setlist = JSON.parse(part.value);
|
||||
}
|
||||
}
|
||||
|
||||
// 검증
|
||||
if (!title || !title.trim()) {
|
||||
return badRequest(reply, '공연명은 필수입니다.');
|
||||
}
|
||||
if (!rounds || rounds.length === 0) {
|
||||
return badRequest(reply, '최소 1개 이상의 공연 일정이 필요합니다.');
|
||||
}
|
||||
for (const round of rounds) {
|
||||
if (!round.date) {
|
||||
return badRequest(reply, '모든 회차에 날짜는 필수입니다.');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 트랜잭션으로 DB 작업 수행
|
||||
const result = await withTransaction(db, async (conn) => {
|
||||
// 1. concert_series 생성
|
||||
const [seriesResult] = await conn.query(
|
||||
'INSERT INTO concert_series (title) VALUES (?)',
|
||||
[title.trim()]
|
||||
);
|
||||
const seriesId = seriesResult.insertId;
|
||||
|
||||
// 2. 포스터 업로드 → images → concert_series.poster_id
|
||||
if (posterBuffer) {
|
||||
const { originalUrl, mediumUrl, thumbUrl } = await uploadConcertPoster(seriesId, posterBuffer);
|
||||
const [imageResult] = await conn.query(
|
||||
'INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)',
|
||||
[originalUrl, mediumUrl, thumbUrl]
|
||||
);
|
||||
await conn.query(
|
||||
'UPDATE concert_series SET poster_id = ? WHERE id = ?',
|
||||
[imageResult.insertId, seriesId]
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 각 회차 처리
|
||||
const scheduleIds = [];
|
||||
const concertIds = [];
|
||||
|
||||
for (const round of rounds) {
|
||||
// venue 처리
|
||||
let venueId = null;
|
||||
if (round.venueId) {
|
||||
venueId = round.venueId;
|
||||
} else if (round.venueName) {
|
||||
const [venueResult] = await conn.query(
|
||||
'INSERT INTO concert_venues (name, country, address, lat, lng) VALUES (?, ?, ?, ?, ?)',
|
||||
[round.venueName, round.venueCountry || null, round.venueAddress || null, round.venueLat || null, round.venueLng || null]
|
||||
);
|
||||
venueId = venueResult.insertId;
|
||||
}
|
||||
|
||||
// schedules 테이블
|
||||
const [scheduleResult] = await conn.query(
|
||||
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
||||
[CONCERT_CATEGORY_ID, title.trim(), round.date, round.time || null]
|
||||
);
|
||||
const scheduleId = scheduleResult.insertId;
|
||||
scheduleIds.push(scheduleId);
|
||||
|
||||
// schedule_concert 테이블
|
||||
const [concertResult] = await conn.query(
|
||||
'INSERT INTO schedule_concert (schedule_id, series_id, venue_id) VALUES (?, ?, ?)',
|
||||
[scheduleId, seriesId, venueId]
|
||||
);
|
||||
concertIds.push(concertResult.insertId);
|
||||
|
||||
// schedule_members 테이블
|
||||
if (memberIds.length > 0) {
|
||||
const values = memberIds.map(memberId => [scheduleId, memberId]);
|
||||
await conn.query(
|
||||
'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?',
|
||||
[values]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 세트리스트 (첫 번째 concert_id 기준으로 저장)
|
||||
const primaryConcertId = concertIds[0];
|
||||
|
||||
for (let i = 0; i < setlist.length; i++) {
|
||||
const song = setlist[i];
|
||||
if (!song.songName || !song.songName.trim()) continue;
|
||||
|
||||
const [setlistResult] = await conn.query(
|
||||
'INSERT INTO concert_setlists (concert_id, order_num, song_name, album_name) VALUES (?, ?, ?, ?)',
|
||||
[primaryConcertId, i + 1, song.songName.trim(), song.albumName?.trim() || null]
|
||||
);
|
||||
const setlistId = setlistResult.insertId;
|
||||
|
||||
// 곡별 멤버
|
||||
if (song.memberIds && song.memberIds.length > 0) {
|
||||
const memberValues = song.memberIds.map(memberId => [setlistId, memberId]);
|
||||
await conn.query(
|
||||
'INSERT INTO concert_setlist_members (setlist_id, member_id) VALUES ?',
|
||||
[memberValues]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 굿즈(MD) 이미지
|
||||
for (let i = 0; i < merchandiseBuffers.length; i++) {
|
||||
const filename = `${String(i + 1).padStart(2, '0')}.webp`;
|
||||
const { originalUrl, mediumUrl, thumbUrl } = await uploadConcertMerchandise(seriesId, filename, merchandiseBuffers[i]);
|
||||
|
||||
const [imageResult] = await conn.query(
|
||||
'INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)',
|
||||
[originalUrl, mediumUrl, thumbUrl]
|
||||
);
|
||||
await conn.query(
|
||||
'INSERT INTO concert_series_md (series_id, image_id, sort_order) VALUES (?, ?, ?)',
|
||||
[seriesId, imageResult.insertId, i + 1]
|
||||
);
|
||||
}
|
||||
|
||||
return { seriesId, scheduleIds };
|
||||
});
|
||||
|
||||
// 6. Meilisearch 동기화 (트랜잭션 외부)
|
||||
const [categoryRows] = await db.query(
|
||||
'SELECT name, color FROM schedule_categories WHERE id = ?',
|
||||
[CONCERT_CATEGORY_ID]
|
||||
);
|
||||
const category = categoryRows[0] || {};
|
||||
|
||||
let memberNames = '';
|
||||
if (memberIds.length > 0) {
|
||||
const [members] = await db.query(
|
||||
'SELECT name FROM members WHERE id IN (?) ORDER BY id',
|
||||
[memberIds]
|
||||
);
|
||||
memberNames = members.map(m => m.name).join(',');
|
||||
}
|
||||
|
||||
for (const scheduleId of result.scheduleIds) {
|
||||
const [scheduleRows] = await db.query(
|
||||
'SELECT title, date, time FROM schedules WHERE id = ?',
|
||||
[scheduleId]
|
||||
);
|
||||
const s = scheduleRows[0];
|
||||
if (s) {
|
||||
await addOrUpdateSchedule(meilisearch, {
|
||||
id: scheduleId,
|
||||
title: s.title,
|
||||
date: s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date,
|
||||
time: s.time || '',
|
||||
category_id: CONCERT_CATEGORY_ID,
|
||||
category_name: category.name || '',
|
||||
category_color: category.color || '',
|
||||
member_names: memberNames,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logActivity(db, { actor: 'admin', action: 'create', category: 'concert', targetType: 'concert', targetId: result.seriesId, summary: `콘서트 일정 생성: ${title}` });
|
||||
return { success: true, seriesId: result.seriesId };
|
||||
} catch (err) {
|
||||
fastify.log.error(`콘서트 일정 저장 오류: ${err.message}`);
|
||||
return serverError(reply, err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
162
backend/src/routes/admin/logs.js
Normal file
162
backend/src/routes/admin/logs.js
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { errorResponse } from '../../schemas/index.js';
|
||||
import { serverError } from '../../utils/error.js';
|
||||
|
||||
/**
|
||||
* 활동 로그 관리자 라우트
|
||||
*/
|
||||
export default async function logsRoutes(fastify) {
|
||||
const { db } = fastify;
|
||||
|
||||
/**
|
||||
* GET /api/admin/logs/categories
|
||||
* 로그에 존재하는 카테고리 목록 조회
|
||||
*/
|
||||
fastify.get('/categories', {
|
||||
schema: {
|
||||
tags: ['admin/logs'],
|
||||
summary: '로그 카테고리 목록 조회',
|
||||
security: [{ bearerAuth: [] }],
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
categories: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
500: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const [rows] = await db.query('SELECT DISTINCT category FROM logs ORDER BY category');
|
||||
return { categories: rows.map(r => r.category) };
|
||||
} catch (err) {
|
||||
fastify.log.error(`로그 카테고리 조회 오류: ${err.message}`);
|
||||
return serverError(reply, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/logs
|
||||
* 활동 로그 목록 조회
|
||||
*/
|
||||
fastify.get('/', {
|
||||
schema: {
|
||||
tags: ['admin/logs'],
|
||||
summary: '활동 로그 목록 조회',
|
||||
security: [{ bearerAuth: [] }],
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
page: { type: 'integer', minimum: 1, default: 1 },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
|
||||
category: { type: 'string', description: '카테고리 필터 (콤마 구분)' },
|
||||
actor: { type: 'string', description: '행위자 필터 (admin 또는 bot)' },
|
||||
search: { type: 'string', description: 'summary 검색' },
|
||||
from: { type: 'string', description: '시작 날짜 (YYYY-MM-DD)' },
|
||||
to: { type: 'string', description: '종료 날짜 (YYYY-MM-DD)' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
logs: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
actor: { type: 'string' },
|
||||
action: { type: 'string' },
|
||||
category: { type: 'string' },
|
||||
target_type: { type: 'string', nullable: true },
|
||||
target_id: { type: 'integer', nullable: true },
|
||||
summary: { type: 'string' },
|
||||
details: { type: 'object', nullable: true },
|
||||
created_at: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
total: { type: 'integer' },
|
||||
page: { type: 'integer' },
|
||||
limit: { type: 'integer' },
|
||||
totalPages: { type: 'integer' },
|
||||
},
|
||||
},
|
||||
500: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { page = 1, limit = 50, category, actor, search, from, to } = request.query;
|
||||
|
||||
try {
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
|
||||
// 카테고리 필터
|
||||
if (category) {
|
||||
const categories = category.split(',').map(c => c.trim()).filter(Boolean);
|
||||
if (categories.length > 0) {
|
||||
conditions.push(`category IN (${categories.map(() => '?').join(',')})`);
|
||||
params.push(...categories);
|
||||
}
|
||||
}
|
||||
|
||||
// 행위자 필터
|
||||
if (actor === 'admin') {
|
||||
conditions.push("actor = 'admin'");
|
||||
} else if (actor === 'bot') {
|
||||
conditions.push("actor != 'admin'");
|
||||
}
|
||||
|
||||
// 텍스트 검색
|
||||
if (search) {
|
||||
conditions.push('summary LIKE ?');
|
||||
params.push(`%${search}%`);
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
if (from) {
|
||||
conditions.push('created_at >= ?');
|
||||
params.push(`${from} 00:00:00`);
|
||||
}
|
||||
if (to) {
|
||||
conditions.push('created_at <= ?');
|
||||
params.push(`${to} 23:59:59`);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// 총 개수 조회
|
||||
const [countResult] = await db.query(
|
||||
`SELECT COUNT(*) as total FROM logs ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const total = countResult[0].total;
|
||||
|
||||
// 로그 조회
|
||||
const [logs] = await db.query(
|
||||
`SELECT * FROM logs ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
||||
[...params, limit, offset]
|
||||
);
|
||||
|
||||
return {
|
||||
logs,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
} catch (err) {
|
||||
fastify.log.error(`활동 로그 조회 오류: ${err.message}`);
|
||||
return serverError(reply, err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
96
backend/src/routes/admin/places.js
Normal file
96
backend/src/routes/admin/places.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import config from '../../config/index.js';
|
||||
import { badRequest, serverError } from '../../utils/error.js';
|
||||
|
||||
const KAKAO_REST_KEY = process.env.KAKAO_REST_KEY;
|
||||
const GOOGLE_API_KEY = config.google.apiKey;
|
||||
|
||||
/**
|
||||
* 장소 검색 관리자 라우트
|
||||
* - 국내: 카카오맵 API
|
||||
* - 해외: 구글 Places API
|
||||
*/
|
||||
export default async function placesRoutes(fastify) {
|
||||
/**
|
||||
* GET /api/admin/kakao/places
|
||||
* 카카오맵 장소 검색 (국내)
|
||||
*/
|
||||
fastify.get('/kakao/places', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { query } = request.query;
|
||||
|
||||
if (!query || !query.trim()) {
|
||||
return badRequest(reply, '검색어를 입력해주세요.');
|
||||
}
|
||||
|
||||
if (!KAKAO_REST_KEY) {
|
||||
return serverError(reply, '카카오 API 키가 설정되지 않았습니다.');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://dapi.kakao.com/v2/local/search/keyword.json?query=${encodeURIComponent(query)}&size=15`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `KakaoAK ${KAKAO_REST_KEY}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
fastify.log.error(`카카오 API 오류: ${response.status} ${errorText}`);
|
||||
return serverError(reply, '카카오 API 호출 실패');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (err) {
|
||||
fastify.log.error(`카카오 장소 검색 오류: ${err.message}`);
|
||||
return serverError(reply, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/google/places
|
||||
* 구글 Places API 장소 검색 (해외)
|
||||
*/
|
||||
fastify.get('/google/places', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { query } = request.query;
|
||||
|
||||
if (!query || !query.trim()) {
|
||||
return badRequest(reply, '검색어를 입력해주세요.');
|
||||
}
|
||||
|
||||
if (!GOOGLE_API_KEY) {
|
||||
return serverError(reply, 'Google API 키가 설정되지 않았습니다.');
|
||||
}
|
||||
|
||||
try {
|
||||
// Places API (New) - Text Search
|
||||
const response = await fetch(
|
||||
`https://maps.googleapis.com/maps/api/place/textsearch/json?query=${encodeURIComponent(query)}&key=${GOOGLE_API_KEY}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
fastify.log.error(`Google Places API 오류: ${response.status} ${errorText}`);
|
||||
return serverError(reply, 'Google API 호출 실패');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status !== 'OK' && data.status !== 'ZERO_RESULTS') {
|
||||
fastify.log.error(`Google Places API 상태: ${data.status} - ${data.error_message || ''}`);
|
||||
return serverError(reply, `Google API 오류: ${data.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
fastify.log.error(`구글 장소 검색 오류: ${err.message}`);
|
||||
return serverError(reply, err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
397
backend/src/routes/admin/x-bots.js
Normal file
397
backend/src/routes/admin/x-bots.js
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
import { errorResponse } from '../../schemas/index.js';
|
||||
import { badRequest, notFound, serverError } from '../../utils/error.js';
|
||||
import { fetchProfile } from '../../services/x/scraper.js';
|
||||
import { logActivity } from '../../utils/log.js';
|
||||
|
||||
/**
|
||||
* X 봇 스키마
|
||||
*/
|
||||
const xBotResponse = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
username: { type: 'string' },
|
||||
display_name: { type: 'string' },
|
||||
avatar_url: { type: 'string' },
|
||||
text_filters: { type: 'array', items: { type: 'string' } },
|
||||
include_retweets: { type: 'boolean' },
|
||||
extract_youtube: { type: 'boolean' },
|
||||
cron_interval: { type: 'integer' },
|
||||
enabled: { type: 'boolean' },
|
||||
},
|
||||
};
|
||||
|
||||
const xBotIdParam = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer', description: 'X 봇 DB ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
};
|
||||
|
||||
/**
|
||||
* DB row를 API 응답 형식으로 변환
|
||||
*/
|
||||
function formatBotResponse(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
display_name: row.display_name,
|
||||
avatar_url: row.avatar_url,
|
||||
text_filters: row.text_filters
|
||||
? (typeof row.text_filters === 'string'
|
||||
? JSON.parse(row.text_filters)
|
||||
: row.text_filters)
|
||||
: [],
|
||||
include_retweets: row.include_retweets === 1,
|
||||
extract_youtube: row.extract_youtube === 1,
|
||||
cron_interval: row.cron_interval,
|
||||
enabled: row.enabled === 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* X 봇 관리 라우트
|
||||
*/
|
||||
export default async function xBotsRoutes(fastify) {
|
||||
const { db, scheduler } = fastify;
|
||||
const nitterUrl = process.env.NITTER_URL || 'http://nitter:8080';
|
||||
|
||||
/**
|
||||
* POST /api/admin/x-bots/lookup
|
||||
* username으로 프로필 정보 조회
|
||||
*/
|
||||
fastify.post('/lookup', {
|
||||
schema: {
|
||||
tags: ['admin/x-bots'],
|
||||
summary: 'X username으로 프로필 정보 조회',
|
||||
security: [{ bearerAuth: [] }],
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string', description: 'X username (@ 없이)' },
|
||||
},
|
||||
required: ['username'],
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string' },
|
||||
displayName: { type: 'string' },
|
||||
avatarUrl: { type: 'string' },
|
||||
},
|
||||
},
|
||||
400: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { username } = request.body;
|
||||
|
||||
try {
|
||||
const profile = await fetchProfile(nitterUrl, username);
|
||||
return profile;
|
||||
} catch (err) {
|
||||
return badRequest(reply, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/x-bots
|
||||
* X 봇 목록 조회
|
||||
*/
|
||||
fastify.get('/', {
|
||||
schema: {
|
||||
tags: ['admin/x-bots'],
|
||||
summary: 'X 봇 목록 조회',
|
||||
security: [{ bearerAuth: [] }],
|
||||
response: {
|
||||
200: {
|
||||
type: 'array',
|
||||
items: xBotResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const [rows] = await db.query('SELECT * FROM bot_x ORDER BY id');
|
||||
return rows.map(formatBotResponse);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/x-bots/:id
|
||||
* X 봇 상세 조회
|
||||
*/
|
||||
fastify.get('/:id', {
|
||||
schema: {
|
||||
tags: ['admin/x-bots'],
|
||||
summary: 'X 봇 상세 조회',
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: xBotIdParam,
|
||||
response: {
|
||||
200: xBotResponse,
|
||||
404: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const [rows] = await db.query('SELECT * FROM bot_x WHERE id = ?', [id]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return notFound(reply, 'X 봇을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return formatBotResponse(rows[0]);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/x-bots
|
||||
* X 봇 추가
|
||||
*/
|
||||
fastify.post('/', {
|
||||
schema: {
|
||||
tags: ['admin/x-bots'],
|
||||
summary: 'X 봇 추가',
|
||||
security: [{ bearerAuth: [] }],
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string' },
|
||||
display_name: { type: ['string', 'null'] },
|
||||
avatar_url: { type: ['string', 'null'] },
|
||||
text_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
||||
include_retweets: { type: 'boolean', default: false },
|
||||
extract_youtube: { type: 'boolean', default: false },
|
||||
cron_interval: { type: 'integer', default: 1 },
|
||||
},
|
||||
required: ['username'],
|
||||
},
|
||||
response: {
|
||||
201: xBotResponse,
|
||||
400: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const {
|
||||
username,
|
||||
display_name,
|
||||
avatar_url,
|
||||
text_filters,
|
||||
include_retweets = false,
|
||||
extract_youtube = false,
|
||||
cron_interval = 1,
|
||||
} = request.body;
|
||||
|
||||
// 중복 체크
|
||||
const [existing] = await db.query(
|
||||
'SELECT id FROM bot_x WHERE username = ?',
|
||||
[username]
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
return badRequest(reply, '이미 등록된 X 계정입니다.');
|
||||
}
|
||||
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO bot_x (username, display_name, avatar_url, text_filters, include_retweets, extract_youtube, cron_interval, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1)`,
|
||||
[
|
||||
username,
|
||||
display_name || null,
|
||||
avatar_url || null,
|
||||
text_filters && text_filters.length > 0 ? JSON.stringify(text_filters) : null,
|
||||
include_retweets ? 1 : 0,
|
||||
extract_youtube ? 1 : 0,
|
||||
cron_interval,
|
||||
]
|
||||
);
|
||||
|
||||
// 스케줄러 캐시 무효화
|
||||
scheduler.invalidateCache();
|
||||
const botId = `x-${result.insertId}`;
|
||||
|
||||
// 전체 트윗 동기화 수행 (백그라운드)
|
||||
const bot = {
|
||||
id: botId,
|
||||
dbId: result.insertId,
|
||||
type: 'x',
|
||||
username,
|
||||
nitterUrl: process.env.NITTER_URL || 'http://nitter:8080',
|
||||
textFilters: text_filters || [],
|
||||
includeRetweets: include_retweets,
|
||||
extractYoutube: extract_youtube,
|
||||
};
|
||||
|
||||
// 전체 동기화 (async, 응답 대기하지 않음)
|
||||
fastify.xBot.syncAllTweets(bot)
|
||||
.then((syncResult) => {
|
||||
fastify.log.info(`[${botId}] 초기 전체 동기화 완료: ${syncResult.addedCount}개 추가`);
|
||||
})
|
||||
.catch((err) => {
|
||||
fastify.log.error(`[${botId}] 초기 전체 동기화 실패:`, err);
|
||||
});
|
||||
|
||||
// 봇 시작 (스케줄러 등록)
|
||||
try {
|
||||
await scheduler.startBot(botId);
|
||||
} catch (err) {
|
||||
fastify.log.error(`[${botId}] 봇 시작 실패:`, err);
|
||||
}
|
||||
|
||||
const [newBot] = await db.query('SELECT * FROM bot_x WHERE id = ?', [result.insertId]);
|
||||
reply.code(201);
|
||||
logActivity(db, { actor: 'admin', action: 'create', category: 'bot', targetType: 'x_bot', targetId: result.insertId, summary: `X 봇 생성: ${username}` });
|
||||
return formatBotResponse(newBot[0]);
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/admin/x-bots/:id
|
||||
* X 봇 수정
|
||||
*/
|
||||
fastify.put('/:id', {
|
||||
schema: {
|
||||
tags: ['admin/x-bots'],
|
||||
summary: 'X 봇 수정',
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: xBotIdParam,
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
display_name: { type: ['string', 'null'] },
|
||||
avatar_url: { type: ['string', 'null'] },
|
||||
text_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
||||
include_retweets: { type: 'boolean' },
|
||||
extract_youtube: { type: 'boolean' },
|
||||
cron_interval: { type: 'integer' },
|
||||
enabled: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: xBotResponse,
|
||||
404: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const updates = request.body;
|
||||
|
||||
// 존재 확인
|
||||
const [existing] = await db.query('SELECT * FROM bot_x WHERE id = ?', [id]);
|
||||
if (existing.length === 0) {
|
||||
return notFound(reply, 'X 봇을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 동적 업데이트 쿼리 생성
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
if (updates.display_name !== undefined) {
|
||||
fields.push('display_name = ?');
|
||||
values.push(updates.display_name);
|
||||
}
|
||||
if (updates.avatar_url !== undefined) {
|
||||
fields.push('avatar_url = ?');
|
||||
values.push(updates.avatar_url);
|
||||
}
|
||||
if (updates.text_filters !== undefined) {
|
||||
fields.push('text_filters = ?');
|
||||
values.push(updates.text_filters && updates.text_filters.length > 0
|
||||
? JSON.stringify(updates.text_filters)
|
||||
: null);
|
||||
}
|
||||
if (updates.include_retweets !== undefined) {
|
||||
fields.push('include_retweets = ?');
|
||||
values.push(updates.include_retweets ? 1 : 0);
|
||||
}
|
||||
if (updates.extract_youtube !== undefined) {
|
||||
fields.push('extract_youtube = ?');
|
||||
values.push(updates.extract_youtube ? 1 : 0);
|
||||
}
|
||||
if (updates.cron_interval !== undefined) {
|
||||
fields.push('cron_interval = ?');
|
||||
values.push(updates.cron_interval);
|
||||
}
|
||||
if (updates.enabled !== undefined) {
|
||||
fields.push('enabled = ?');
|
||||
values.push(updates.enabled ? 1 : 0);
|
||||
}
|
||||
|
||||
if (fields.length > 0) {
|
||||
values.push(id);
|
||||
await db.query(
|
||||
`UPDATE bot_x SET ${fields.join(', ')} WHERE id = ?`,
|
||||
values
|
||||
);
|
||||
|
||||
// 스케줄러 캐시 무효화 및 봇 재시작
|
||||
scheduler.invalidateCache();
|
||||
const botId = `x-${id}`;
|
||||
const shouldBeEnabled = updates.enabled !== undefined
|
||||
? updates.enabled
|
||||
: existing[0].enabled === 1;
|
||||
try {
|
||||
await scheduler.stopBot(botId);
|
||||
if (shouldBeEnabled) {
|
||||
await scheduler.startBot(botId);
|
||||
}
|
||||
} catch (err) {
|
||||
fastify.log.error(`[${botId}] 봇 재시작 실패:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
const [updatedBot] = await db.query('SELECT * FROM bot_x WHERE id = ?', [id]);
|
||||
logActivity(db, { actor: 'admin', action: 'update', category: 'bot', targetType: 'x_bot', targetId: parseInt(id), summary: `X 봇 수정: ${existing[0].username}` });
|
||||
return formatBotResponse(updatedBot[0]);
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/x-bots/:id
|
||||
* X 봇 삭제
|
||||
*/
|
||||
fastify.delete('/:id', {
|
||||
schema: {
|
||||
tags: ['admin/x-bots'],
|
||||
summary: 'X 봇 삭제',
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: xBotIdParam,
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
404: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
|
||||
// 존재 확인
|
||||
const [existing] = await db.query('SELECT * FROM bot_x WHERE id = ?', [id]);
|
||||
if (existing.length === 0) {
|
||||
return notFound(reply, 'X 봇을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 봇 정지
|
||||
const botId = `x-${id}`;
|
||||
try {
|
||||
await scheduler.stopBot(botId);
|
||||
} catch (err) {
|
||||
// 이미 정지된 경우 무시
|
||||
}
|
||||
|
||||
// DB에서 삭제
|
||||
await db.query('DELETE FROM bot_x WHERE id = ?', [id]);
|
||||
|
||||
// 스케줄러 캐시 무효화
|
||||
scheduler.invalidateCache();
|
||||
|
||||
logActivity(db, { actor: 'admin', action: 'delete', category: 'bot', targetType: 'x_bot', targetId: parseInt(id), summary: `X 봇 삭제: ${existing[0].username}` });
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
xScheduleCreate,
|
||||
} from '../../schemas/index.js';
|
||||
import { badRequest, conflict, serverError } from '../../utils/error.js';
|
||||
import { logActivity } from '../../utils/log.js';
|
||||
|
||||
const X_CATEGORY_ID = CATEGORY_IDS.X;
|
||||
const NITTER_URL = config.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080';
|
||||
|
|
@ -153,6 +154,7 @@ export default async function xRoutes(fastify) {
|
|||
source_name: '',
|
||||
});
|
||||
|
||||
logActivity(db, { actor: 'admin', action: 'create', category: 'schedule', targetType: 'x_schedule', targetId: scheduleId, summary: `X 일정 생성: ${title}` });
|
||||
return { success: true, scheduleId };
|
||||
} catch (err) {
|
||||
fastify.log.error(`X 일정 저장 오류: ${err.message}`);
|
||||
|
|
|
|||
404
backend/src/routes/admin/youtube-bots.js
Normal file
404
backend/src/routes/admin/youtube-bots.js
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
import { errorResponse } from '../../schemas/index.js';
|
||||
import { badRequest, notFound, serverError } from '../../utils/error.js';
|
||||
import { getChannelByHandle } from '../../services/youtube/api.js';
|
||||
import { logActivity } from '../../utils/log.js';
|
||||
|
||||
/**
|
||||
* YouTube 봇 스키마
|
||||
*/
|
||||
const youtubeBotResponse = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
channel_id: { type: 'string' },
|
||||
channel_handle: { type: 'string' },
|
||||
channel_name: { type: 'string' },
|
||||
banner_url: { type: 'string' },
|
||||
cron_interval: { type: 'integer' },
|
||||
enabled: { type: 'boolean' },
|
||||
title_filters: { type: 'array', items: { type: 'string' } },
|
||||
default_member_ids: { type: 'array', items: { type: 'integer' } },
|
||||
extract_members_from_desc: { type: 'boolean' },
|
||||
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||
},
|
||||
};
|
||||
|
||||
const youtubeBotIdParam = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer', description: 'YouTube 봇 DB ID' },
|
||||
},
|
||||
required: ['id'],
|
||||
};
|
||||
|
||||
/**
|
||||
* DB row를 API 응답 형식으로 변환
|
||||
*/
|
||||
function formatBotResponse(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
channel_id: row.channel_id,
|
||||
channel_handle: row.channel_handle,
|
||||
channel_name: row.channel_name,
|
||||
banner_url: row.banner_url,
|
||||
cron_interval: row.cron_interval,
|
||||
enabled: row.enabled === 1,
|
||||
title_filters: row.title_filters
|
||||
? (typeof row.title_filters === 'string'
|
||||
? JSON.parse(row.title_filters)
|
||||
: row.title_filters)
|
||||
: [],
|
||||
default_member_ids: row.default_member_ids
|
||||
? (typeof row.default_member_ids === 'string'
|
||||
? JSON.parse(row.default_member_ids)
|
||||
: row.default_member_ids)
|
||||
: [],
|
||||
extract_members_from_desc: row.extract_members_from_desc === 1,
|
||||
auto_schedule_config: row.auto_schedule_config
|
||||
? (typeof row.auto_schedule_config === 'string'
|
||||
? JSON.parse(row.auto_schedule_config)
|
||||
: row.auto_schedule_config)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube 봇 관리 라우트
|
||||
*/
|
||||
export default async function youtubeBotsRoutes(fastify) {
|
||||
const { db, scheduler } = fastify;
|
||||
|
||||
/**
|
||||
* POST /api/admin/youtube-bots/lookup
|
||||
* 채널 핸들로 채널 정보 조회
|
||||
*/
|
||||
fastify.post('/lookup', {
|
||||
schema: {
|
||||
tags: ['admin/youtube-bots'],
|
||||
summary: '채널 핸들로 채널 정보 조회',
|
||||
security: [{ bearerAuth: [] }],
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
handle: { type: 'string', description: '@username 형식' },
|
||||
},
|
||||
required: ['handle'],
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
channelId: { type: 'string' },
|
||||
handle: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
thumbnailUrl: { type: 'string' },
|
||||
bannerUrl: { type: 'string' },
|
||||
},
|
||||
},
|
||||
400: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { handle } = request.body;
|
||||
|
||||
try {
|
||||
const channelInfo = await getChannelByHandle(handle);
|
||||
return channelInfo;
|
||||
} catch (err) {
|
||||
return badRequest(reply, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/youtube-bots
|
||||
* YouTube 봇 목록 조회
|
||||
*/
|
||||
fastify.get('/', {
|
||||
schema: {
|
||||
tags: ['admin/youtube-bots'],
|
||||
summary: 'YouTube 봇 목록 조회',
|
||||
security: [{ bearerAuth: [] }],
|
||||
response: {
|
||||
200: {
|
||||
type: 'array',
|
||||
items: youtubeBotResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const [rows] = await db.query('SELECT * FROM bot_youtube ORDER BY id');
|
||||
return rows.map(formatBotResponse);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/youtube-bots/:id
|
||||
* YouTube 봇 상세 조회
|
||||
*/
|
||||
fastify.get('/:id', {
|
||||
schema: {
|
||||
tags: ['admin/youtube-bots'],
|
||||
summary: 'YouTube 봇 상세 조회',
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: youtubeBotIdParam,
|
||||
response: {
|
||||
200: youtubeBotResponse,
|
||||
404: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const [rows] = await db.query('SELECT * FROM bot_youtube WHERE id = ?', [id]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return notFound(reply, 'YouTube 봇을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return formatBotResponse(rows[0]);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/youtube-bots
|
||||
* YouTube 봇 추가
|
||||
*/
|
||||
fastify.post('/', {
|
||||
schema: {
|
||||
tags: ['admin/youtube-bots'],
|
||||
summary: 'YouTube 봇 추가',
|
||||
security: [{ bearerAuth: [] }],
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
channel_id: { type: 'string' },
|
||||
channel_handle: { type: ['string', 'null'] },
|
||||
channel_name: { type: 'string' },
|
||||
banner_url: { type: ['string', 'null'] },
|
||||
cron_interval: { type: 'integer', default: 2 },
|
||||
title_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
||||
default_member_ids: { type: ['array', 'null'], items: { type: 'integer' } },
|
||||
extract_members_from_desc: { type: 'boolean', default: false },
|
||||
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||
},
|
||||
required: ['channel_id', 'channel_name'],
|
||||
},
|
||||
response: {
|
||||
201: youtubeBotResponse,
|
||||
400: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const {
|
||||
channel_id,
|
||||
channel_handle,
|
||||
channel_name,
|
||||
banner_url,
|
||||
cron_interval = 2,
|
||||
title_filters,
|
||||
default_member_ids,
|
||||
extract_members_from_desc = false,
|
||||
auto_schedule_config,
|
||||
} = request.body;
|
||||
|
||||
// 중복 체크
|
||||
const [existing] = await db.query(
|
||||
'SELECT id FROM bot_youtube WHERE channel_id = ?',
|
||||
[channel_id]
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
return badRequest(reply, '이미 등록된 채널입니다.');
|
||||
}
|
||||
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO bot_youtube
|
||||
(channel_id, channel_handle, channel_name, banner_url, cron_interval,
|
||||
title_filters, default_member_ids, extract_members_from_desc, auto_schedule_config, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
|
||||
[
|
||||
channel_id,
|
||||
channel_handle || null,
|
||||
channel_name,
|
||||
banner_url || null,
|
||||
cron_interval,
|
||||
title_filters ? JSON.stringify(title_filters) : null,
|
||||
default_member_ids ? JSON.stringify(default_member_ids) : null,
|
||||
extract_members_from_desc ? 1 : 0,
|
||||
auto_schedule_config ? JSON.stringify(auto_schedule_config) : null,
|
||||
]
|
||||
);
|
||||
|
||||
// 스케줄러 캐시 무효화 및 봇 시작
|
||||
scheduler.invalidateCache();
|
||||
const botId = `youtube-${result.insertId}`;
|
||||
try {
|
||||
await scheduler.startBot(botId);
|
||||
} catch (err) {
|
||||
fastify.log.error(`[${botId}] 봇 시작 실패:`, err);
|
||||
}
|
||||
|
||||
const [newBot] = await db.query('SELECT * FROM bot_youtube WHERE id = ?', [result.insertId]);
|
||||
reply.code(201);
|
||||
logActivity(db, { actor: 'admin', action: 'create', category: 'bot', targetType: 'youtube_bot', targetId: result.insertId, summary: `YouTube 봇 생성: ${channel_name}` });
|
||||
return formatBotResponse(newBot[0]);
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/admin/youtube-bots/:id
|
||||
* YouTube 봇 수정
|
||||
*/
|
||||
fastify.put('/:id', {
|
||||
schema: {
|
||||
tags: ['admin/youtube-bots'],
|
||||
summary: 'YouTube 봇 수정',
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: youtubeBotIdParam,
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
channel_handle: { type: ['string', 'null'] },
|
||||
channel_name: { type: 'string' },
|
||||
banner_url: { type: ['string', 'null'] },
|
||||
cron_interval: { type: 'integer' },
|
||||
title_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
||||
default_member_ids: { type: ['array', 'null'], items: { type: 'integer' } },
|
||||
extract_members_from_desc: { type: 'boolean' },
|
||||
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||
enabled: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: youtubeBotResponse,
|
||||
404: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const updates = request.body;
|
||||
|
||||
// 존재 확인
|
||||
const [existing] = await db.query('SELECT * FROM bot_youtube WHERE id = ?', [id]);
|
||||
if (existing.length === 0) {
|
||||
return notFound(reply, 'YouTube 봇을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 동적 업데이트 쿼리 생성
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
if (updates.channel_handle !== undefined) {
|
||||
fields.push('channel_handle = ?');
|
||||
values.push(updates.channel_handle);
|
||||
}
|
||||
if (updates.channel_name !== undefined) {
|
||||
fields.push('channel_name = ?');
|
||||
values.push(updates.channel_name);
|
||||
}
|
||||
if (updates.banner_url !== undefined) {
|
||||
fields.push('banner_url = ?');
|
||||
values.push(updates.banner_url);
|
||||
}
|
||||
if (updates.cron_interval !== undefined) {
|
||||
fields.push('cron_interval = ?');
|
||||
values.push(updates.cron_interval);
|
||||
}
|
||||
if (updates.title_filters !== undefined) {
|
||||
fields.push('title_filters = ?');
|
||||
values.push(JSON.stringify(updates.title_filters));
|
||||
}
|
||||
if (updates.default_member_ids !== undefined) {
|
||||
fields.push('default_member_ids = ?');
|
||||
values.push(JSON.stringify(updates.default_member_ids));
|
||||
}
|
||||
if (updates.extract_members_from_desc !== undefined) {
|
||||
fields.push('extract_members_from_desc = ?');
|
||||
values.push(updates.extract_members_from_desc ? 1 : 0);
|
||||
}
|
||||
if (updates.auto_schedule_config !== undefined) {
|
||||
fields.push('auto_schedule_config = ?');
|
||||
values.push(updates.auto_schedule_config ? JSON.stringify(updates.auto_schedule_config) : null);
|
||||
}
|
||||
if (updates.enabled !== undefined) {
|
||||
fields.push('enabled = ?');
|
||||
values.push(updates.enabled ? 1 : 0);
|
||||
}
|
||||
|
||||
if (fields.length > 0) {
|
||||
values.push(id);
|
||||
await db.query(
|
||||
`UPDATE bot_youtube SET ${fields.join(', ')} WHERE id = ?`,
|
||||
values
|
||||
);
|
||||
|
||||
// 스케줄러 캐시 무효화 및 봇 재시작
|
||||
scheduler.invalidateCache();
|
||||
const botId = `youtube-${id}`;
|
||||
const shouldBeEnabled = updates.enabled !== undefined
|
||||
? updates.enabled
|
||||
: existing[0].enabled === 1;
|
||||
try {
|
||||
await scheduler.stopBot(botId);
|
||||
if (shouldBeEnabled) {
|
||||
await scheduler.startBot(botId);
|
||||
}
|
||||
} catch (err) {
|
||||
fastify.log.error(`[${botId}] 봇 재시작 실패:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
const [updatedBot] = await db.query('SELECT * FROM bot_youtube WHERE id = ?', [id]);
|
||||
logActivity(db, { actor: 'admin', action: 'update', category: 'bot', targetType: 'youtube_bot', targetId: parseInt(id), summary: `YouTube 봇 수정: ${existing[0].channel_name}` });
|
||||
return formatBotResponse(updatedBot[0]);
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/youtube-bots/:id
|
||||
* YouTube 봇 삭제
|
||||
*/
|
||||
fastify.delete('/:id', {
|
||||
schema: {
|
||||
tags: ['admin/youtube-bots'],
|
||||
summary: 'YouTube 봇 삭제',
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: youtubeBotIdParam,
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
404: errorResponse,
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
|
||||
// 존재 확인
|
||||
const [existing] = await db.query('SELECT * FROM bot_youtube WHERE id = ?', [id]);
|
||||
if (existing.length === 0) {
|
||||
return notFound(reply, 'YouTube 봇을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 봇 정지
|
||||
const botId = `youtube-${id}`;
|
||||
try {
|
||||
await scheduler.stopBot(botId);
|
||||
} catch (err) {
|
||||
// 이미 정지된 경우 무시
|
||||
}
|
||||
|
||||
// DB에서 삭제
|
||||
await db.query('DELETE FROM bot_youtube WHERE id = ?', [id]);
|
||||
|
||||
// 스케줄러 캐시 무효화
|
||||
scheduler.invalidateCache();
|
||||
|
||||
logActivity(db, { actor: 'admin', action: 'delete', category: 'bot', targetType: 'youtube_bot', targetId: parseInt(id), summary: `YouTube 봇 삭제: ${existing[0].channel_name}` });
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
idParam,
|
||||
} from '../../schemas/index.js';
|
||||
import { badRequest, notFound, conflict, serverError } from '../../utils/error.js';
|
||||
import { logActivity } from '../../utils/log.js';
|
||||
|
||||
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE;
|
||||
|
||||
|
|
@ -149,6 +150,7 @@ export default async function youtubeRoutes(fastify) {
|
|||
source_name: channelName || '',
|
||||
});
|
||||
|
||||
logActivity(db, { actor: 'admin', action: 'create', category: 'schedule', targetType: 'youtube_schedule', targetId: scheduleId, summary: `YouTube 일정 생성: ${title}` });
|
||||
return { success: true, scheduleId };
|
||||
} catch (err) {
|
||||
fastify.log.error(`YouTube 일정 저장 오류: ${err.message}`);
|
||||
|
|
@ -252,6 +254,7 @@ export default async function youtubeRoutes(fastify) {
|
|||
source_name: channelName,
|
||||
});
|
||||
|
||||
logActivity(db, { actor: 'admin', action: 'update', category: 'schedule', targetType: 'youtube_schedule', targetId: parseInt(id), summary: `YouTube 일정 수정: ${schedules[0].title}` });
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
fastify.log.error(`YouTube 일정 수정 오류: ${err.message}`);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import photosRoutes from './photos.js';
|
|||
import teasersRoutes from './teasers.js';
|
||||
import { errorResponse, successResponse, idParam } from '../../schemas/index.js';
|
||||
import { notFound, badRequest } from '../../utils/error.js';
|
||||
import { logActivity } from '../../utils/log.js';
|
||||
|
||||
/**
|
||||
* 앨범 라우트
|
||||
|
|
@ -203,6 +204,7 @@ export default async function albumsRoutes(fastify) {
|
|||
|
||||
const result = await createAlbum(db, data, coverBuffer);
|
||||
await invalidateAlbumCache(redis);
|
||||
logActivity(db, { actor: 'admin', action: 'create', category: 'album', targetType: 'album', targetId: result.albumId, summary: `앨범 생성: ${title}` });
|
||||
return result;
|
||||
});
|
||||
|
||||
|
|
@ -251,6 +253,7 @@ export default async function albumsRoutes(fastify) {
|
|||
return notFound(reply, '앨범을 찾을 수 없습니다.');
|
||||
}
|
||||
await invalidateAlbumCache(redis, id);
|
||||
logActivity(db, { actor: 'admin', action: 'update', category: 'album', targetType: 'album', targetId: parseInt(id), summary: `앨범 수정: ${data.title || id}` });
|
||||
return result;
|
||||
});
|
||||
|
||||
|
|
@ -277,6 +280,7 @@ export default async function albumsRoutes(fastify) {
|
|||
return notFound(reply, '앨범을 찾을 수 없습니다.');
|
||||
}
|
||||
await invalidateAlbumCache(redis, id);
|
||||
logActivity(db, { actor: 'admin', action: 'delete', category: 'album', targetType: 'album', targetId: parseInt(id), summary: `앨범 삭제: ${id}` });
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
} from '../../services/image.js';
|
||||
import { withTransaction } from '../../utils/transaction.js';
|
||||
import { notFound } from '../../utils/error.js';
|
||||
import { logActivity } from '../../utils/log.js';
|
||||
|
||||
/**
|
||||
* 앨범 사진 라우트
|
||||
|
|
@ -195,6 +196,8 @@ export default async function photosRoutes(fastify) {
|
|||
|
||||
await connection.commit();
|
||||
|
||||
logActivity(db, { actor: 'admin', action: 'upload', category: 'album', targetType: 'photo', targetId: parseInt(albumId), summary: `사진 업로드: ${uploadedPhotos.length}장 (앨범 ${albumId})` });
|
||||
|
||||
reply.raw.write(`data: ${JSON.stringify({
|
||||
done: true,
|
||||
message: `${uploadedPhotos.length}개의 사진이 업로드되었습니다.`,
|
||||
|
|
@ -245,6 +248,7 @@ export default async function photosRoutes(fastify) {
|
|||
await connection.query('DELETE FROM album_photo_members WHERE photo_id = ?', [photoId]);
|
||||
await connection.query('DELETE FROM album_photos WHERE id = ?', [photoId]);
|
||||
|
||||
logActivity(db, { actor: 'admin', action: 'delete', category: 'album', targetType: 'photo', targetId: parseInt(photoId), summary: `사진 삭제: 앨범 ${albumId}` });
|
||||
return { message: '사진이 삭제되었습니다.' };
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
} from '../../services/image.js';
|
||||
import { withTransaction } from '../../utils/transaction.js';
|
||||
import { notFound } from '../../utils/error.js';
|
||||
import { logActivity } from '../../utils/log.js';
|
||||
|
||||
/**
|
||||
* 앨범 티저 라우트
|
||||
|
|
@ -78,6 +79,7 @@ export default async function teasersRoutes(fastify) {
|
|||
|
||||
await connection.query('DELETE FROM album_teasers WHERE id = ?', [teaserId]);
|
||||
|
||||
logActivity(db, { actor: 'admin', action: 'delete', category: 'album', targetType: 'teaser', targetId: parseInt(teaserId), summary: `티저 삭제: 앨범 ${albumId}` });
|
||||
return { message: '티저가 삭제되었습니다.' };
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,8 +4,13 @@ import albumsRoutes from './albums/index.js';
|
|||
import schedulesRoutes from './schedules/index.js';
|
||||
import statsRoutes from './stats/index.js';
|
||||
import botsRoutes from './admin/bots.js';
|
||||
import youtubeBotsRoutes from './admin/youtube-bots.js';
|
||||
import xBotsRoutes from './admin/x-bots.js';
|
||||
import youtubeAdminRoutes from './admin/youtube.js';
|
||||
import xAdminRoutes from './admin/x.js';
|
||||
import concertAdminRoutes from './admin/concert.js';
|
||||
import placesAdminRoutes from './admin/places.js';
|
||||
import logsAdminRoutes from './admin/logs.js';
|
||||
|
||||
/**
|
||||
* 라우트 통합
|
||||
|
|
@ -30,9 +35,24 @@ export default async function routes(fastify) {
|
|||
// 관리자 - 봇 라우트
|
||||
fastify.register(botsRoutes, { prefix: '/admin/bots' });
|
||||
|
||||
// 관리자 - YouTube 봇 라우트
|
||||
fastify.register(youtubeBotsRoutes, { prefix: '/admin/youtube-bots' });
|
||||
|
||||
// 관리자 - X 봇 라우트
|
||||
fastify.register(xBotsRoutes, { prefix: '/admin/x-bots' });
|
||||
|
||||
// 관리자 - YouTube 라우트
|
||||
fastify.register(youtubeAdminRoutes, { prefix: '/admin/youtube' });
|
||||
|
||||
// 관리자 - X 라우트
|
||||
fastify.register(xAdminRoutes, { prefix: '/admin/x' });
|
||||
|
||||
// 관리자 - 콘서트 라우트
|
||||
fastify.register(concertAdminRoutes, { prefix: '/admin/concert' });
|
||||
|
||||
// 관리자 - 장소 검색 라우트
|
||||
fastify.register(placesAdminRoutes, { prefix: '/admin' });
|
||||
|
||||
// 관리자 - 활동 로그 라우트
|
||||
fastify.register(logsAdminRoutes, { prefix: '/admin/logs' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { uploadMemberImage } from '../../services/image.js';
|
||||
import { getAllMembers, getMemberByName, getMemberBasicByName, invalidateMemberCache } from '../../services/member.js';
|
||||
import { notFound, serverError } from '../../utils/error.js';
|
||||
import { logActivity } from '../../utils/log.js';
|
||||
|
||||
/**
|
||||
* 멤버 라우트
|
||||
|
|
@ -159,6 +160,7 @@ export default async function membersRoutes(fastify, opts) {
|
|||
// 멤버 캐시 무효화
|
||||
await invalidateMemberCache(redis);
|
||||
|
||||
logActivity(db, { actor: 'admin', action: 'update', category: 'member', targetType: 'member', targetId: memberId, summary: `멤버 수정: ${fields.name || decodedName}` });
|
||||
return { message: '멤버 정보가 수정되었습니다', id: memberId };
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from '../../schemas/index.js';
|
||||
import { badRequest, notFound, serverError } from '../../utils/error.js';
|
||||
import { withTransaction } from '../../utils/transaction.js';
|
||||
import { logActivity } from '../../utils/log.js';
|
||||
|
||||
export default async function schedulesRoutes(fastify) {
|
||||
const { db, meilisearch, redis } = fastify;
|
||||
|
|
@ -151,6 +152,20 @@ export default async function schedulesRoutes(fastify) {
|
|||
return notFound(reply, '일정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 유튜브 카테고리인 경우 채널 배너 이미지 추가
|
||||
if (result.category?.id === CATEGORY_IDS.YOUTUBE) {
|
||||
const [youtubeData] = await db.query(
|
||||
`SELECT yb.banner_url
|
||||
FROM schedule_youtube sy
|
||||
LEFT JOIN bot_youtube yb ON sy.channel_id = yb.channel_id
|
||||
WHERE sy.schedule_id = ?`,
|
||||
[request.params.id]
|
||||
);
|
||||
if (youtubeData.length > 0 && youtubeData[0].banner_url) {
|
||||
result.bannerUrl = youtubeData[0].banner_url;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
|
|
@ -205,6 +220,7 @@ export default async function schedulesRoutes(fastify) {
|
|||
// Meilisearch에서도 삭제 (트랜잭션 외부, 실패해도 무시)
|
||||
await deleteSchedule(meilisearch, id);
|
||||
|
||||
logActivity(db, { actor: 'admin', action: 'delete', category: 'schedule', targetType: null, targetId: parseInt(id), summary: `일정 삭제: ${id}` });
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { readFile, writeFile } from 'fs/promises';
|
|||
import { SuggestionService } from '../../services/suggestions/index.js';
|
||||
import { reloadMorpheme, getUserDictPath } from '../../services/suggestions/morpheme.js';
|
||||
import { badRequest, serverError } from '../../utils/error.js';
|
||||
import { logActivity } from '../../utils/log.js';
|
||||
|
||||
let suggestionService = null;
|
||||
|
||||
|
|
@ -185,6 +186,7 @@ export default async function suggestionsRoutes(fastify) {
|
|||
// 형태소 분석기 리로드
|
||||
await reloadMorpheme();
|
||||
|
||||
logActivity(db, { actor: 'admin', action: 'update', category: 'dict', targetType: 'dict', targetId: null, summary: '사전 저장' });
|
||||
return { message: '사전이 저장되었습니다.' };
|
||||
} catch (error) {
|
||||
fastify.log.error(`[Suggestions] 사전 저장 오류: ${error.message}`);
|
||||
|
|
|
|||
|
|
@ -215,3 +215,44 @@ export async function uploadAlbumVideo(folderName, filename, buffer) {
|
|||
export async function deleteAlbumVideo(folderName, filename) {
|
||||
await deleteFromS3(`album/${folderName}/teaser/video/${filename}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 콘서트 포스터 업로드
|
||||
* @param {number} seriesId - 콘서트 시리즈 ID
|
||||
* @param {Buffer} buffer - 이미지 버퍼
|
||||
* @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string}>}
|
||||
*/
|
||||
export async function uploadConcertPoster(seriesId, buffer) {
|
||||
const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer);
|
||||
|
||||
const basePath = `concert/${seriesId}/poster`;
|
||||
|
||||
const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([
|
||||
uploadToS3(`${basePath}/original/poster.webp`, originalBuffer),
|
||||
uploadToS3(`${basePath}/medium_800/poster.webp`, mediumBuffer),
|
||||
uploadToS3(`${basePath}/thumb_400/poster.webp`, thumbBuffer),
|
||||
]);
|
||||
|
||||
return { originalUrl, mediumUrl, thumbUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* 콘서트 MD(굿즈) 이미지 업로드
|
||||
* @param {number} seriesId - 콘서트 시리즈 ID
|
||||
* @param {string} filename - 파일명 (예: '01.webp')
|
||||
* @param {Buffer} buffer - 이미지 버퍼
|
||||
* @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string}>}
|
||||
*/
|
||||
export async function uploadConcertMerchandise(seriesId, filename, buffer) {
|
||||
const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer);
|
||||
|
||||
const basePath = `concert/${seriesId}/md`;
|
||||
|
||||
const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([
|
||||
uploadToS3(`${basePath}/original/${filename}`, originalBuffer),
|
||||
uploadToS3(`${basePath}/medium_800/${filename}`, mediumBuffer),
|
||||
uploadToS3(`${basePath}/thumb_400/${filename}`, thumbBuffer),
|
||||
]);
|
||||
|
||||
return { originalUrl, mediumUrl, thumbUrl };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ function formatScheduleResponse(hit) {
|
|||
if (hit.category_id === CATEGORY_IDS.YOUTUBE && hit.source_name) {
|
||||
source = { name: hit.source_name, url: null };
|
||||
} else if (hit.category_id === CATEGORY_IDS.X) {
|
||||
source = { name: '', url: null };
|
||||
source = { name: hit.source_name || '', url: null };
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -217,11 +217,12 @@ export async function syncScheduleById(meilisearch, db, scheduleId) {
|
|||
s.category_id,
|
||||
c.name as category_name,
|
||||
c.color as category_color,
|
||||
sy.channel_name as source_name,
|
||||
COALESCE(sy.channel_name, sx.username) as source_name,
|
||||
GROUP_CONCAT(DISTINCT m.name ORDER BY m.id SEPARATOR ',') as member_names
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
|
||||
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
|
||||
LEFT JOIN members m ON sm.member_id = m.id AND m.is_former = 0
|
||||
WHERE s.id = ?
|
||||
|
|
@ -270,7 +271,7 @@ export async function deleteSchedule(meilisearch, scheduleId) {
|
|||
}
|
||||
|
||||
/**
|
||||
* 전체 일정 동기화
|
||||
* 전체 일정 동기화 (DB에 없는 문서는 삭제)
|
||||
*/
|
||||
export async function syncAllSchedules(meilisearch, db) {
|
||||
try {
|
||||
|
|
@ -290,17 +291,38 @@ export async function syncAllSchedules(meilisearch, db) {
|
|||
s.category_id,
|
||||
c.name as category_name,
|
||||
c.color as category_color,
|
||||
sy.channel_name as source_name,
|
||||
COALESCE(sy.channel_name, sx.username) as source_name,
|
||||
GROUP_CONCAT(DISTINCT m.name ORDER BY m.id SEPARATOR ',') as member_names
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
|
||||
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
|
||||
LEFT JOIN members m ON sm.member_id = m.id AND m.is_former = 0
|
||||
GROUP BY s.id
|
||||
`);
|
||||
|
||||
const index = meilisearch.index(INDEX_NAME);
|
||||
const dbIds = new Set(schedules.map(s => s.id));
|
||||
|
||||
// Meilisearch에서 모든 문서 ID 조회
|
||||
let meiliIds = [];
|
||||
let offset = 0;
|
||||
const limit = 1000;
|
||||
while (true) {
|
||||
const docs = await index.getDocuments({ offset, limit, fields: ['id'] });
|
||||
if (docs.results.length === 0) break;
|
||||
meiliIds.push(...docs.results.map(d => d.id));
|
||||
if (docs.results.length < limit) break;
|
||||
offset += limit;
|
||||
}
|
||||
|
||||
// DB에 없는 문서 삭제
|
||||
const idsToDelete = meiliIds.filter(id => !dbIds.has(id));
|
||||
if (idsToDelete.length > 0) {
|
||||
await index.deleteDocuments(idsToDelete);
|
||||
logger.info(`${idsToDelete.length}개 문서 삭제`);
|
||||
}
|
||||
|
||||
// 문서 변환 (addDocuments는 같은 ID면 자동 업데이트)
|
||||
const documents = schedules.map(s => ({
|
||||
|
|
|
|||
|
|
@ -37,22 +37,31 @@ export function buildDatetime(date, time) {
|
|||
* @returns {object|null} { name, url } 또는 null
|
||||
*/
|
||||
export function buildSource(schedule) {
|
||||
const { category_id, youtube_video_id, youtube_video_type, youtube_channel, x_post_id } = schedule;
|
||||
const { category_id, youtube_video_id, youtube_video_type, youtube_channel, x_post_id, x_username } = schedule;
|
||||
|
||||
if (category_id === CATEGORY_IDS.YOUTUBE && youtube_video_id) {
|
||||
const url = youtube_video_type === 'shorts'
|
||||
? `https://www.youtube.com/shorts/${youtube_video_id}`
|
||||
: `https://www.youtube.com/watch?v=${youtube_video_id}`;
|
||||
return {
|
||||
name: youtube_channel || 'YouTube',
|
||||
url,
|
||||
};
|
||||
if (category_id === CATEGORY_IDS.YOUTUBE) {
|
||||
if (youtube_video_id) {
|
||||
const url = youtube_video_type === 'shorts'
|
||||
? `https://www.youtube.com/shorts/${youtube_video_id}`
|
||||
: `https://www.youtube.com/watch?v=${youtube_video_id}`;
|
||||
return {
|
||||
name: youtube_channel || 'YouTube',
|
||||
url,
|
||||
};
|
||||
} else if (youtube_channel) {
|
||||
// 예정 일정: video_id 없이 채널 이름만
|
||||
return {
|
||||
name: youtube_channel,
|
||||
url: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (category_id === CATEGORY_IDS.X && x_post_id) {
|
||||
const username = x_username || config.x.defaultUsername;
|
||||
return {
|
||||
name: '',
|
||||
url: `https://x.com/${config.x.defaultUsername}/status/${x_post_id}`,
|
||||
name: username,
|
||||
url: `https://x.com/${username}/status/${x_post_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -185,6 +194,7 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
|
|||
sy.video_id as youtube_video_id,
|
||||
sy.video_type as youtube_video_type,
|
||||
sx.post_id as x_post_id,
|
||||
sx.username as x_username,
|
||||
sx.content as x_content,
|
||||
sx.image_urls as x_image_urls
|
||||
FROM schedules s
|
||||
|
|
@ -234,16 +244,23 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
|
|||
};
|
||||
|
||||
// 카테고리별 추가 필드
|
||||
if (s.category_id === CATEGORY_IDS.YOUTUBE && s.youtube_video_id) {
|
||||
result.videoId = s.youtube_video_id;
|
||||
result.videoType = s.youtube_video_type;
|
||||
result.channelName = s.youtube_channel;
|
||||
result.videoUrl = s.youtube_video_type === 'shorts'
|
||||
? `https://www.youtube.com/shorts/${s.youtube_video_id}`
|
||||
: `https://www.youtube.com/watch?v=${s.youtube_video_id}`;
|
||||
if (s.category_id === CATEGORY_IDS.YOUTUBE) {
|
||||
// 채널 이름은 항상 반환 (예정 일정 포함)
|
||||
if (s.youtube_channel) {
|
||||
result.channelName = s.youtube_channel;
|
||||
}
|
||||
// video_id가 있는 경우에만 영상 관련 필드 추가
|
||||
if (s.youtube_video_id) {
|
||||
result.videoId = s.youtube_video_id;
|
||||
result.videoType = s.youtube_video_type;
|
||||
result.videoUrl = s.youtube_video_type === 'shorts'
|
||||
? `https://www.youtube.com/shorts/${s.youtube_video_id}`
|
||||
: `https://www.youtube.com/watch?v=${s.youtube_video_id}`;
|
||||
}
|
||||
} else if (s.category_id === CATEGORY_IDS.X && s.x_post_id) {
|
||||
const username = config.x.defaultUsername;
|
||||
const username = s.x_username || config.x.defaultUsername;
|
||||
result.postId = s.x_post_id;
|
||||
result.username = username;
|
||||
result.content = s.x_content || null;
|
||||
result.imageUrls = s.x_image_urls ? JSON.parse(s.x_image_urls) : [];
|
||||
result.postUrl = `https://x.com/${username}/status/${s.x_post_id}`;
|
||||
|
|
@ -278,7 +295,8 @@ const SCHEDULE_LIST_SQL = `
|
|||
sy.channel_name as youtube_channel,
|
||||
sy.video_id as youtube_video_id,
|
||||
sy.video_type as youtube_video_type,
|
||||
sx.post_id as x_post_id
|
||||
sx.post_id as x_post_id,
|
||||
sx.username as x_username
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ 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';
|
||||
import { logActivity } from '../../utils/log.js';
|
||||
|
||||
const X_CATEGORY_ID = 3;
|
||||
const YOUTUBE_CATEGORY_ID = 2;
|
||||
|
|
@ -13,29 +13,26 @@ 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 bot_youtube WHERE enabled = 1'
|
||||
);
|
||||
return rows.map(r => r.channel_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* X 프로필 저장 (DB + Redis 캐시)
|
||||
* X 프로필 저장 (bot_x 테이블 + Redis 캐시)
|
||||
*/
|
||||
async function saveProfile(username, profile) {
|
||||
if (!profile.displayName && !profile.avatarUrl) return;
|
||||
|
||||
// DB에 저장 (upsert)
|
||||
// bot_x 테이블 업데이트
|
||||
await fastify.db.query(`
|
||||
INSERT INTO x_profiles (username, display_name, avatar_url)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
display_name = VALUES(display_name),
|
||||
avatar_url = VALUES(avatar_url),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, [username, profile.displayName, profile.avatarUrl]);
|
||||
UPDATE bot_x SET display_name = ?, avatar_url = ?
|
||||
WHERE username = ?
|
||||
`, [profile.displayName, profile.avatarUrl, username]);
|
||||
|
||||
// Redis 캐시에도 저장
|
||||
const data = {
|
||||
|
|
@ -54,7 +51,7 @@ async function xBotPlugin(fastify, opts) {
|
|||
/**
|
||||
* 트윗을 DB에 저장
|
||||
*/
|
||||
async function saveTweet(tweet) {
|
||||
async function saveTweet(tweet, username) {
|
||||
// 중복 체크 (post_id로) - 트랜잭션 전에 수행
|
||||
const [existing] = await fastify.db.query(
|
||||
'SELECT id FROM schedule_x WHERE post_id = ?',
|
||||
|
|
@ -79,10 +76,11 @@ async function xBotPlugin(fastify, opts) {
|
|||
|
||||
// schedule_x 테이블에 저장
|
||||
await connection.query(
|
||||
'INSERT INTO schedule_x (schedule_id, post_id, content, image_urls) VALUES (?, ?, ?, ?)',
|
||||
'INSERT INTO schedule_x (schedule_id, post_id, username, content, image_urls) VALUES (?, ?, ?, ?, ?)',
|
||||
[
|
||||
scheduleId,
|
||||
tweet.id,
|
||||
username,
|
||||
tweet.text,
|
||||
tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null,
|
||||
]
|
||||
|
|
@ -106,22 +104,28 @@ async function xBotPlugin(fastify, opts) {
|
|||
}
|
||||
|
||||
// 트랜잭션으로 INSERT 작업 수행
|
||||
return withTransaction(fastify.db, async (connection) => {
|
||||
// schedules 테이블에 저장
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
||||
[YOUTUBE_CATEGORY_ID, video.title, video.date, video.time]
|
||||
);
|
||||
const scheduleId = result.insertId;
|
||||
try {
|
||||
return await withTransaction(fastify.db, async (connection) => {
|
||||
// schedules 테이블에 저장
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
||||
[YOUTUBE_CATEGORY_ID, video.title, video.date, video.time]
|
||||
);
|
||||
const scheduleId = result.insertId;
|
||||
|
||||
// schedule_youtube 테이블에 저장
|
||||
await connection.query(
|
||||
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
|
||||
[scheduleId, video.videoId, video.videoType, video.channelId, video.channelTitle]
|
||||
);
|
||||
// schedule_youtube 테이블에 저장
|
||||
await connection.query(
|
||||
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
|
||||
[scheduleId, video.videoId, video.videoType, video.channelId, video.channelTitle]
|
||||
);
|
||||
|
||||
return scheduleId;
|
||||
});
|
||||
return scheduleId;
|
||||
});
|
||||
} catch (err) {
|
||||
// UNIQUE 제약 위반 (동시성 중복) → 무시
|
||||
if (err.code === 'ER_DUP_ENTRY') return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -131,7 +135,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) {
|
||||
|
|
@ -156,11 +160,21 @@ async function xBotPlugin(fastify, opts) {
|
|||
return addedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 필터 적용 (키워드 중 하나라도 포함되면 true)
|
||||
*/
|
||||
function matchesFilter(text, filters) {
|
||||
if (!filters || filters.length === 0) return true;
|
||||
const lowerText = text.toLowerCase();
|
||||
return filters.some(filter => lowerText.includes(filter.toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 트윗 동기화 (정기 실행)
|
||||
*/
|
||||
async function syncNewTweets(bot) {
|
||||
const { tweets, profile } = await fetchTweets(bot.nitterUrl, bot.username);
|
||||
const options = { includeRetweets: bot.includeRetweets || false };
|
||||
const { tweets, profile } = await fetchTweets(bot.nitterUrl, bot.username, options);
|
||||
|
||||
// 프로필 저장 (DB + 캐시)
|
||||
await saveProfile(bot.username, profile);
|
||||
|
|
@ -169,43 +183,68 @@ async function xBotPlugin(fastify, opts) {
|
|||
let ytAddedCount = 0;
|
||||
|
||||
for (const tweet of tweets) {
|
||||
const scheduleId = await saveTweet(tweet);
|
||||
// 텍스트 필터 적용
|
||||
if (!matchesFilter(tweet.text, bot.textFilters)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scheduleId = await saveTweet(tweet, bot.username);
|
||||
if (scheduleId) {
|
||||
// Meilisearch 동기화
|
||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
||||
const title = extractTitle(tweet.text);
|
||||
logActivity(fastify.db, {
|
||||
actor: bot.id,
|
||||
action: 'create',
|
||||
category: 'schedule',
|
||||
targetType: 'x_schedule',
|
||||
targetId: scheduleId,
|
||||
summary: `X 트윗 추가: ${title}`,
|
||||
});
|
||||
addedCount++;
|
||||
// YouTube 링크 처리
|
||||
ytAddedCount += await processYoutubeLinks(tweet);
|
||||
// YouTube 링크 처리 (옵션이 켜져 있을 때만)
|
||||
if (bot.extractYoutube === true) {
|
||||
ytAddedCount += await processYoutubeLinks(tweet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { addedCount: addedCount + ytAddedCount, tweetCount: addedCount, ytCount: ytAddedCount };
|
||||
return { addedCount: addedCount + ytAddedCount, total: tweets.length, tweetCount: addedCount, ytCount: ytAddedCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 트윗 동기화 (초기화)
|
||||
*/
|
||||
async function syncAllTweets(bot) {
|
||||
const tweets = await fetchAllTweets(bot.nitterUrl, bot.username, fastify.log);
|
||||
const options = { includeRetweets: bot.includeRetweets || false };
|
||||
const tweets = await fetchAllTweets(bot.nitterUrl, bot.username, fastify.log, options);
|
||||
|
||||
let addedCount = 0;
|
||||
let ytAddedCount = 0;
|
||||
|
||||
for (const tweet of tweets) {
|
||||
const scheduleId = await saveTweet(tweet);
|
||||
// 텍스트 필터 적용
|
||||
if (!matchesFilter(tweet.text, bot.textFilters)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scheduleId = await saveTweet(tweet, bot.username);
|
||||
if (scheduleId) {
|
||||
// Meilisearch 동기화
|
||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
||||
addedCount++;
|
||||
ytAddedCount += await processYoutubeLinks(tweet);
|
||||
// YouTube 링크 처리 (옵션이 켜져 있을 때만)
|
||||
if (bot.extractYoutube === true) {
|
||||
ytAddedCount += await processYoutubeLinks(tweet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { addedCount: addedCount + ytAddedCount, tweetCount: addedCount, ytCount: ytAddedCount };
|
||||
return { addedCount: addedCount + ytAddedCount, total: tweets.length, tweetCount: addedCount, ytCount: ytAddedCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* X 프로필 조회 (Redis 캐시 → DB)
|
||||
* X 프로필 조회 (Redis 캐시 → bot_x 테이블)
|
||||
*/
|
||||
async function getProfile(username) {
|
||||
// Redis 캐시 확인
|
||||
|
|
@ -214,9 +253,9 @@ async function xBotPlugin(fastify, opts) {
|
|||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
// DB에서 조회
|
||||
// bot_x 테이블에서 조회
|
||||
const [rows] = await fastify.db.query(
|
||||
'SELECT username, display_name, avatar_url, updated_at FROM x_profiles WHERE username = ?',
|
||||
'SELECT username, display_name, avatar_url FROM bot_x WHERE username = ?',
|
||||
[username]
|
||||
);
|
||||
|
||||
|
|
@ -226,7 +265,6 @@ async function xBotPlugin(fastify, opts) {
|
|||
username: row.username,
|
||||
displayName: row.display_name,
|
||||
avatarUrl: row.avatar_url,
|
||||
updatedAt: row.updated_at?.toISOString(),
|
||||
};
|
||||
// Redis 캐시에 저장
|
||||
await fastify.redis.setex(
|
||||
|
|
|
|||
|
|
@ -125,18 +125,26 @@ function extractTextFromHtml(html) {
|
|||
|
||||
/**
|
||||
* HTML에서 트윗 목록 파싱
|
||||
* @param {string} html - HTML 문자열
|
||||
* @param {string} username - 사용자명
|
||||
* @param {object} options - 옵션
|
||||
* @param {boolean} options.includeRetweets - 리트윗 포함 여부 (기본: false)
|
||||
*/
|
||||
export function parseTweets(html, username) {
|
||||
export function parseTweets(html, username, options = {}) {
|
||||
const { includeRetweets = false } = options;
|
||||
const tweets = [];
|
||||
const containers = html.split('class="timeline-item ');
|
||||
|
||||
for (let i = 1; i < containers.length; i++) {
|
||||
const container = containers[i];
|
||||
|
||||
// 고정/리트윗 제외
|
||||
// 고정 트윗 제외
|
||||
const isPinned = container.includes('class="pinned"');
|
||||
if (isPinned) continue;
|
||||
|
||||
// 리트윗 필터링 (옵션에 따라)
|
||||
const isRetweet = container.includes('class="retweet-header"');
|
||||
if (isPinned || isRetweet) continue;
|
||||
if (isRetweet && !includeRetweets) continue;
|
||||
|
||||
// 트윗 ID
|
||||
const idMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/);
|
||||
|
|
@ -219,9 +227,39 @@ export async function fetchSingleTweet(nitterUrl, username, postId) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Nitter에서 트윗 수집 (첫 페이지만)
|
||||
* Nitter에서 프로필 정보만 조회
|
||||
*/
|
||||
export async function fetchTweets(nitterUrl, username) {
|
||||
export async function fetchProfile(nitterUrl, username) {
|
||||
const url = `${nitterUrl}/${username}`;
|
||||
const res = await fetchWithTimeout(url);
|
||||
const html = await res.text();
|
||||
|
||||
// 프로필이 존재하는지 확인
|
||||
if (html.includes('Error: User') || html.includes('User not found')) {
|
||||
throw new Error('사용자를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
const profile = extractProfile(html);
|
||||
|
||||
if (!profile.displayName) {
|
||||
throw new Error('프로필 정보를 가져올 수 없습니다');
|
||||
}
|
||||
|
||||
return {
|
||||
username,
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Nitter에서 트윗 수집 (첫 페이지만)
|
||||
* @param {string} nitterUrl - Nitter URL
|
||||
* @param {string} username - 사용자명
|
||||
* @param {object} options - 옵션
|
||||
* @param {boolean} options.includeRetweets - 리트윗 포함 여부
|
||||
*/
|
||||
export async function fetchTweets(nitterUrl, username, options = {}) {
|
||||
const url = `${nitterUrl}/${username}`;
|
||||
const res = await fetchWithTimeout(url);
|
||||
const html = await res.text();
|
||||
|
|
@ -230,15 +268,20 @@ export async function fetchTweets(nitterUrl, username) {
|
|||
const profile = extractProfile(html);
|
||||
|
||||
// 트윗 파싱
|
||||
const tweets = parseTweets(html, username);
|
||||
const tweets = parseTweets(html, username, options);
|
||||
|
||||
return { tweets, profile };
|
||||
}
|
||||
|
||||
/**
|
||||
* Nitter에서 전체 트윗 수집 (페이지네이션)
|
||||
* @param {string} nitterUrl - Nitter URL
|
||||
* @param {string} username - 사용자명
|
||||
* @param {object} log - 로거
|
||||
* @param {object} options - 옵션
|
||||
* @param {boolean} options.includeRetweets - 리트윗 포함 여부
|
||||
*/
|
||||
export async function fetchAllTweets(nitterUrl, username, log) {
|
||||
export async function fetchAllTweets(nitterUrl, username, log, options = {}) {
|
||||
const allTweets = [];
|
||||
let cursor = null;
|
||||
let pageNum = 1;
|
||||
|
|
@ -254,7 +297,7 @@ export async function fetchAllTweets(nitterUrl, username, log) {
|
|||
try {
|
||||
const res = await fetchWithTimeout(url);
|
||||
const html = await res.text();
|
||||
const tweets = parseTweets(html, username);
|
||||
const tweets = parseTweets(html, username, options);
|
||||
|
||||
if (tweets.length === 0) {
|
||||
emptyCount++;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import config from '../../config/index.js';
|
||||
import { formatDate, formatTime } from '../../utils/date.js';
|
||||
|
||||
const API_KEY = config.youtube.apiKey;
|
||||
const API_KEY = config.google.apiKey;
|
||||
const API_BASE = 'https://www.googleapis.com/youtube/v3';
|
||||
|
||||
/**
|
||||
|
|
@ -44,6 +44,72 @@ export async function getUploadsPlaylistId(channelId) {
|
|||
return data.items[0].contentDetails.relatedPlaylists.uploads;
|
||||
}
|
||||
|
||||
/**
|
||||
* 핸들로 채널 조회
|
||||
* @param {string} handle - @username 형식 (@ 제외)
|
||||
*/
|
||||
export async function getChannelByHandle(handle) {
|
||||
// @ 제거
|
||||
const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle;
|
||||
const url = `${API_BASE}/channels?part=snippet,brandingSettings&forHandle=${cleanHandle}&key=${API_KEY}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message);
|
||||
}
|
||||
if (!data.items?.length) {
|
||||
throw new Error('채널을 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
const channel = data.items[0];
|
||||
const { snippet, brandingSettings } = channel;
|
||||
|
||||
// 배너 URL에 고해상도 파라미터 추가
|
||||
const bannerBase = brandingSettings?.image?.bannerExternalUrl;
|
||||
const bannerUrl = bannerBase ? `${bannerBase}=w2560` : null;
|
||||
|
||||
return {
|
||||
channelId: channel.id,
|
||||
handle: cleanHandle,
|
||||
title: snippet.title,
|
||||
description: snippet.description,
|
||||
thumbnailUrl: snippet.thumbnails?.high?.url || snippet.thumbnails?.default?.url,
|
||||
bannerUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 채널 정보 조회 (배너 이미지 포함)
|
||||
*/
|
||||
export async function getChannelInfo(channelId) {
|
||||
const url = `${API_BASE}/channels?part=snippet,brandingSettings&id=${channelId}&key=${API_KEY}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message);
|
||||
}
|
||||
if (!data.items?.length) {
|
||||
throw new Error('채널을 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
const channel = data.items[0];
|
||||
const { snippet, brandingSettings } = channel;
|
||||
|
||||
// 배너 URL에 고해상도 파라미터 추가
|
||||
const bannerBase = brandingSettings?.image?.bannerExternalUrl;
|
||||
const bannerUrl = bannerBase ? `${bannerBase}=w2560` : null;
|
||||
|
||||
return {
|
||||
channelId,
|
||||
title: snippet.title,
|
||||
description: snippet.description,
|
||||
thumbnailUrl: snippet.thumbnails?.high?.url || snippet.thumbnails?.default?.url,
|
||||
bannerUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 영상 ID 목록으로 duration 조회 (Shorts 판별용)
|
||||
*/
|
||||
|
|
@ -63,15 +129,13 @@ async function getVideoDurations(videoIds) {
|
|||
}
|
||||
|
||||
/**
|
||||
* 최근 N개 영상 조회
|
||||
* 최근 영상 ID 목록만 조회 (Activities API - 1 unit)
|
||||
* @param {string} channelId - 채널 ID
|
||||
* @param {number} maxResults - 최대 결과 수
|
||||
* @param {string} uploadsPlaylistId - 캐싱된 uploads playlist ID (선택)
|
||||
*/
|
||||
export async function fetchRecentVideos(channelId, maxResults = 10, uploadsPlaylistId = null) {
|
||||
const uploadsId = uploadsPlaylistId || await getUploadsPlaylistId(channelId);
|
||||
|
||||
const url = `${API_BASE}/playlistItems?part=snippet&playlistId=${uploadsId}&maxResults=${maxResults}&key=${API_KEY}`;
|
||||
export async function fetchRecentVideoIds(channelId, maxResults = 10) {
|
||||
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 res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
|
|
@ -79,28 +143,10 @@ export async function fetchRecentVideos(channelId, maxResults = 10, uploadsPlayl
|
|||
throw new Error(data.error.message);
|
||||
}
|
||||
|
||||
const videoIds = data.items.map(item => item.snippet.resourceId.videoId);
|
||||
const shortsMap = await getVideoDurations(videoIds);
|
||||
|
||||
return data.items.map(item => {
|
||||
const { snippet } = item;
|
||||
const videoId = snippet.resourceId.videoId;
|
||||
const isShorts = shortsMap[videoId] || false;
|
||||
const publishedAt = new Date(snippet.publishedAt);
|
||||
|
||||
return {
|
||||
videoId,
|
||||
title: snippet.title,
|
||||
description: snippet.description || '',
|
||||
channelId: snippet.channelId,
|
||||
channelTitle: snippet.channelTitle,
|
||||
publishedAt,
|
||||
date: formatDate(publishedAt),
|
||||
time: formatTime(publishedAt),
|
||||
videoType: isShorts ? 'shorts' : 'video',
|
||||
videoUrl: getVideoUrl(videoId, isShorts),
|
||||
};
|
||||
});
|
||||
return (data.items || [])
|
||||
.filter(item => item.snippet.type === 'upload')
|
||||
.slice(0, maxResults)
|
||||
.map(item => item.contentDetails.upload.videoId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,31 +1,208 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.js';
|
||||
import bots from '../../config/bots.js';
|
||||
import { fetchRecentVideoIds, fetchVideoInfo, fetchAllVideos } from './api.js';
|
||||
import { CATEGORY_IDS } from '../../config/index.js';
|
||||
import { withTransaction } from '../../utils/transaction.js';
|
||||
import { syncScheduleById } from '../meilisearch/index.js';
|
||||
import { syncScheduleById, deleteSchedule } from '../meilisearch/index.js';
|
||||
import { logActivity } from '../../utils/log.js';
|
||||
|
||||
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE;
|
||||
const PLAYLIST_CACHE_PREFIX = 'yt_uploads:';
|
||||
|
||||
async function youtubeBotPlugin(fastify, opts) {
|
||||
async function youtubeBotPlugin(fastify) {
|
||||
/**
|
||||
* uploads playlist ID 조회 (Redis 캐싱)
|
||||
* 다음 특정 요일 날짜 계산 (KST 기준)
|
||||
* @param {number} targetDay - 목표 요일 (0=일, 4=목)
|
||||
* @param {Date} fromDate - 기준 날짜 (기본: 오늘)
|
||||
* @returns {string} YYYY-MM-DD 형식
|
||||
*/
|
||||
async function getCachedUploadsPlaylistId(channelId) {
|
||||
const cacheKey = `${PLAYLIST_CACHE_PREFIX}${channelId}`;
|
||||
function getNextWeekday(targetDay, fromDate = new Date()) {
|
||||
const kst = new Date(fromDate.toLocaleString('en-US', { timeZone: 'Asia/Seoul' }));
|
||||
const currentDay = kst.getDay();
|
||||
// 다음 주 같은 요일까지 일수 계산
|
||||
let daysUntil = targetDay - currentDay + 7;
|
||||
if (daysUntil <= 0) daysUntil += 7;
|
||||
|
||||
// Redis 캐시 확인
|
||||
const cached = await fastify.redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
const nextDate = new Date(kst);
|
||||
nextDate.setDate(kst.getDate() + daysUntil);
|
||||
|
||||
const year = nextDate.getFullYear();
|
||||
const month = String(nextDate.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(nextDate.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 날짜의 예정 일정 조회 (is_temp = 1인 것)
|
||||
*/
|
||||
async function findScheduledEntry(bot, date) {
|
||||
const [rows] = await fastify.db.query(
|
||||
`SELECT sy.schedule_id, s.title, s.date, s.time
|
||||
FROM schedule_youtube sy
|
||||
JOIN schedules s ON s.id = sy.schedule_id
|
||||
WHERE s.is_temp = 1 AND sy.channel_id = ? AND s.date = ?`,
|
||||
[bot.channelId, date]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 채널의 일반 영상 개수 조회 (쇼츠 제외)
|
||||
*/
|
||||
async function getVideoCount(channelId) {
|
||||
const [rows] = await fastify.db.query(
|
||||
`SELECT COUNT(*) as cnt FROM schedule_youtube
|
||||
WHERE channel_id = ? AND video_type = 'video' AND video_id IS NOT NULL`,
|
||||
[channelId]
|
||||
);
|
||||
return rows[0].cnt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 예정 일정 제목 생성
|
||||
*/
|
||||
async function generateScheduledTitle(bot) {
|
||||
const { autoScheduleNext } = bot;
|
||||
|
||||
if (autoScheduleNext.titleTemplate) {
|
||||
const videoCount = await getVideoCount(bot.channelId);
|
||||
const nextEpisode = videoCount + 1;
|
||||
|
||||
return autoScheduleNext.titleTemplate
|
||||
.replace('{channelName}', bot.channelName)
|
||||
.replace('{episode}', nextEpisode);
|
||||
}
|
||||
|
||||
// API 호출 후 캐싱 (영구 저장 - 값이 변하지 않음)
|
||||
const playlistId = await getUploadsPlaylistId(channelId);
|
||||
await fastify.redis.set(cacheKey, playlistId);
|
||||
return autoScheduleNext.title || `${bot.channelName} (예정)`;
|
||||
}
|
||||
|
||||
return playlistId;
|
||||
/**
|
||||
* 다음 주 예정 일정 생성
|
||||
*/
|
||||
async function createScheduledEntry(bot) {
|
||||
const { autoScheduleNext } = bot;
|
||||
if (!autoScheduleNext) return null;
|
||||
|
||||
const nextDate = getNextWeekday(autoScheduleNext.dayOfWeek);
|
||||
|
||||
// 이미 존재하는지 확인 (같은 채널, 같은 날짜, is_temp = 1)
|
||||
const [existing] = await fastify.db.query(
|
||||
`SELECT sy.schedule_id FROM schedule_youtube sy
|
||||
JOIN schedules s ON s.id = sy.schedule_id
|
||||
WHERE s.is_temp = 1 AND sy.channel_id = ? AND s.date = ?`,
|
||||
[bot.channelId, nextDate]
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
return null; // 이미 존재
|
||||
}
|
||||
|
||||
// 제목 생성
|
||||
const title = await generateScheduledTitle(bot);
|
||||
|
||||
// 트랜잭션으로 생성
|
||||
const scheduleId = await withTransaction(fastify.db, async (conn) => {
|
||||
const [result] = await conn.query(
|
||||
'INSERT INTO schedules (category_id, title, date, time, is_temp) VALUES (?, ?, ?, ?, 1)',
|
||||
[YOUTUBE_CATEGORY_ID, title, nextDate, autoScheduleNext.time]
|
||||
);
|
||||
const newScheduleId = result.insertId;
|
||||
|
||||
await conn.query(
|
||||
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
|
||||
[newScheduleId, null, 'video', bot.channelId, bot.channelName]
|
||||
);
|
||||
|
||||
return newScheduleId;
|
||||
});
|
||||
|
||||
// Meilisearch 동기화
|
||||
if (scheduleId) {
|
||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
||||
fastify.log.info(`[${bot.id}] 다음 주 예정 일정 생성: ${nextDate} - ${title}`);
|
||||
}
|
||||
|
||||
return scheduleId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 예정 일정을 실제 영상으로 덮어씌움
|
||||
*/
|
||||
async function updateScheduledEntry(scheduledEntry, video, bot) {
|
||||
await withTransaction(fastify.db, async (conn) => {
|
||||
// schedules 테이블 업데이트 (is_temp = 0으로 변경)
|
||||
await conn.query(
|
||||
'UPDATE schedules SET title = ?, date = ?, time = ?, is_temp = 0 WHERE id = ?',
|
||||
[video.title, video.date, video.time, scheduledEntry.schedule_id]
|
||||
);
|
||||
|
||||
// schedule_youtube 테이블 업데이트
|
||||
await conn.query(
|
||||
'UPDATE schedule_youtube SET video_id = ?, video_type = ? WHERE schedule_id = ?',
|
||||
[video.videoId, video.videoType, scheduledEntry.schedule_id]
|
||||
);
|
||||
});
|
||||
|
||||
// Meilisearch 동기화
|
||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduledEntry.schedule_id);
|
||||
fastify.log.info(`[${bot.id}] 예정 일정 업데이트: ${video.title}`);
|
||||
|
||||
return scheduledEntry.schedule_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 예정 일정 삭제 + 다음 주 예정 일정 생성
|
||||
*/
|
||||
async function deleteScheduledAndCreateNext(bot, scheduleId) {
|
||||
// 삭제
|
||||
await withTransaction(fastify.db, async (conn) => {
|
||||
await conn.query('DELETE FROM schedule_members WHERE schedule_id = ?', [scheduleId]);
|
||||
await conn.query('DELETE FROM schedule_youtube WHERE schedule_id = ?', [scheduleId]);
|
||||
await conn.query('DELETE FROM schedules WHERE id = ?', [scheduleId]);
|
||||
});
|
||||
|
||||
// Meilisearch에서도 삭제
|
||||
await deleteSchedule(fastify.meilisearch, scheduleId);
|
||||
fastify.log.info(`[${bot.id}] 예정 일정 삭제 (영상 미업로드)`);
|
||||
|
||||
// 다음 주 예정 일정 생성
|
||||
await createScheduledEntry(bot);
|
||||
}
|
||||
|
||||
/**
|
||||
* 예정 일정 deadline 체크 (금요일 00시)
|
||||
*/
|
||||
async function checkScheduledDeadline(bot) {
|
||||
const { autoScheduleNext } = bot;
|
||||
if (!autoScheduleNext || !autoScheduleNext.deadlineDayOfWeek) return;
|
||||
|
||||
const now = new Date();
|
||||
const kst = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Seoul' }));
|
||||
const currentDay = kst.getDay();
|
||||
|
||||
// deadline 요일인지 확인 (금요일 = 5)
|
||||
if (currentDay !== autoScheduleNext.deadlineDayOfWeek) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 어제(목요일) 날짜 계산 - deadline 당일이면 전날이 목표 요일
|
||||
const targetDate = new Date(kst);
|
||||
targetDate.setDate(kst.getDate() - 1); // 어제
|
||||
|
||||
const year = targetDate.getFullYear();
|
||||
const month = String(targetDate.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(targetDate.getDate()).padStart(2, '0');
|
||||
const targetDateStr = `${year}-${month}-${day}`;
|
||||
|
||||
// 예정 일정이 아직 존재하는지 확인 (is_temp = 1인 것)
|
||||
const [rows] = await fastify.db.query(
|
||||
`SELECT sy.schedule_id FROM schedule_youtube sy
|
||||
JOIN schedules s ON s.id = sy.schedule_id
|
||||
WHERE s.is_temp = 1 AND sy.channel_id = ? AND s.date = ?`,
|
||||
[bot.channelId, targetDateStr]
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
// 아직 예정 상태 → 삭제 + 다음 주 생성
|
||||
await deleteScheduledAndCreateNext(bot, rows[0].schedule_id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -68,8 +245,29 @@ 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;
|
||||
const isVideoType = video.videoType === 'video'; // 쇼츠가 아닌 일반 영상
|
||||
|
||||
// 예정 일정 처리 (쇼츠 제외 옵션이 있으면 쇼츠는 무시)
|
||||
if (autoScheduleNext && isVideoType) {
|
||||
// 해당 날짜의 예정 일정이 있는지 확인
|
||||
const scheduledEntry = await findScheduledEntry(bot, video.date);
|
||||
|
||||
if (scheduledEntry) {
|
||||
// 예정 일정을 실제 영상으로 덮어씌움
|
||||
await updateScheduledEntry(scheduledEntry, video, bot);
|
||||
// 다음 주 예정 일정 생성
|
||||
await createScheduledEntry(bot);
|
||||
return scheduledEntry.schedule_id;
|
||||
}
|
||||
}
|
||||
|
||||
// 멤버 이름 맵 미리 조회 (트랜잭션 전에)
|
||||
|
|
@ -79,69 +277,114 @@ async function youtubeBotPlugin(fastify, opts) {
|
|||
}
|
||||
|
||||
// 트랜잭션으로 INSERT 작업 수행
|
||||
return withTransaction(fastify.db, async (connection) => {
|
||||
// schedules 테이블에 저장
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
||||
[YOUTUBE_CATEGORY_ID, video.title, video.date, video.time]
|
||||
);
|
||||
const scheduleId = result.insertId;
|
||||
let scheduleId;
|
||||
try {
|
||||
scheduleId = await withTransaction(fastify.db, async (connection) => {
|
||||
// schedules 테이블에 저장
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
||||
[YOUTUBE_CATEGORY_ID, video.title, video.date, video.time]
|
||||
);
|
||||
const newScheduleId = result.insertId;
|
||||
|
||||
// schedule_youtube 테이블에 저장
|
||||
await connection.query(
|
||||
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
|
||||
[scheduleId, video.videoId, video.videoType, video.channelId, bot.channelName]
|
||||
);
|
||||
// schedule_youtube 테이블에 저장
|
||||
await connection.query(
|
||||
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
|
||||
[newScheduleId, video.videoId, video.videoType, video.channelId, bot.channelName]
|
||||
);
|
||||
|
||||
// 멤버 연결 (커스텀 설정)
|
||||
if (bot.defaultMemberId || bot.extractMembersFromDesc) {
|
||||
const memberIds = [];
|
||||
if (bot.defaultMemberId) {
|
||||
memberIds.push(bot.defaultMemberId);
|
||||
// 멤버 연결 (커스텀 설정)
|
||||
const hasDefaultMembers = bot.defaultMemberIds && bot.defaultMemberIds.length > 0;
|
||||
if (hasDefaultMembers || bot.extractMembersFromDesc) {
|
||||
const memberIds = [];
|
||||
if (hasDefaultMembers) {
|
||||
memberIds.push(...bot.defaultMemberIds);
|
||||
}
|
||||
if (nameMap) {
|
||||
memberIds.push(...extractMemberIds(video.description, nameMap));
|
||||
}
|
||||
if (memberIds.length > 0) {
|
||||
const uniqueIds = [...new Set(memberIds)];
|
||||
const values = uniqueIds.map(id => [newScheduleId, id]);
|
||||
await connection.query(
|
||||
'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?',
|
||||
[values]
|
||||
);
|
||||
}
|
||||
}
|
||||
if (nameMap) {
|
||||
memberIds.push(...extractMemberIds(video.description, nameMap));
|
||||
}
|
||||
if (memberIds.length > 0) {
|
||||
const uniqueIds = [...new Set(memberIds)];
|
||||
const values = uniqueIds.map(id => [scheduleId, id]);
|
||||
await connection.query(
|
||||
'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?',
|
||||
[values]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return scheduleId;
|
||||
});
|
||||
return newScheduleId;
|
||||
});
|
||||
} catch (err) {
|
||||
// UNIQUE 제약 위반 (동시성 중복) → 무시
|
||||
if (err.code === 'ER_DUP_ENTRY') return null;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 새 영상 추가 후 다음 주 예정 일정 생성 (쇼츠 제외)
|
||||
if (autoScheduleNext && isVideoType && scheduleId) {
|
||||
await createScheduledEntry(bot);
|
||||
}
|
||||
|
||||
return scheduleId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 영상 동기화 (정기 실행)
|
||||
*/
|
||||
async function syncNewVideos(bot) {
|
||||
const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId);
|
||||
const videos = await fetchRecentVideos(bot.channelId, 10, uploadsPlaylistId);
|
||||
let addedCount = 0;
|
||||
// 예정 일정 deadline 체크 (금요일 00시)
|
||||
if (bot.autoScheduleNext) {
|
||||
await checkScheduledDeadline(bot);
|
||||
}
|
||||
|
||||
// 1. 최근 영상 ID 목록만 조회 (activities.list - 1 unit)
|
||||
const videoIds = await fetchRecentVideoIds(bot.channelId, 10);
|
||||
if (videoIds.length === 0) {
|
||||
return { addedCount: 0, total: 0 };
|
||||
}
|
||||
|
||||
// 2. DB에서 이미 존재하는 영상 필터링
|
||||
const [existing] = await fastify.db.query(
|
||||
'SELECT video_id FROM schedule_youtube WHERE video_id IN (?)',
|
||||
[videoIds]
|
||||
);
|
||||
const existingIds = new Set(existing.map(r => r.video_id));
|
||||
const newVideoIds = videoIds.filter(id => !existingIds.has(id));
|
||||
|
||||
if (newVideoIds.length === 0) {
|
||||
return { addedCount: 0, total: videoIds.length };
|
||||
}
|
||||
|
||||
// 3. 새 영상만 상세 정보 조회 (videos.list - 새 영상당 1 unit)
|
||||
let addedCount = 0;
|
||||
for (const videoId of newVideoIds) {
|
||||
const video = await fetchVideoInfo(videoId);
|
||||
if (!video) continue;
|
||||
|
||||
for (const video of videos) {
|
||||
const scheduleId = await saveVideo(video, bot);
|
||||
if (scheduleId) {
|
||||
// Meilisearch 동기화
|
||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
||||
logActivity(fastify.db, {
|
||||
actor: bot.id,
|
||||
action: 'create',
|
||||
category: 'schedule',
|
||||
targetType: 'youtube_schedule',
|
||||
targetId: scheduleId,
|
||||
summary: `YouTube 영상 추가: ${video.title}`,
|
||||
});
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return { addedCount, total: videos.length };
|
||||
return { addedCount, total: videoIds.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 영상 동기화 (초기화)
|
||||
*/
|
||||
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) {
|
||||
|
|
@ -157,22 +400,24 @@ 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 bot_youtube WHERE enabled = 1'
|
||||
);
|
||||
return rows.map(r => r.channel_id);
|
||||
}
|
||||
|
||||
fastify.decorate('youtubeBot', {
|
||||
syncNewVideos,
|
||||
syncAllVideos,
|
||||
getManagedChannelIds,
|
||||
saveVideo,
|
||||
});
|
||||
}
|
||||
|
||||
export default fp(youtubeBotPlugin, {
|
||||
name: 'youtubeBot',
|
||||
dependencies: ['db', 'redis'],
|
||||
dependencies: ['db'],
|
||||
});
|
||||
|
|
|
|||
26
backend/src/utils/log.js
Normal file
26
backend/src/utils/log.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* 활동 로그 유틸리티
|
||||
* fire-and-forget: 로그 실패가 비즈니스 로직에 영향 주지 않도록 처리
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {object} db - DB 커넥션
|
||||
* @param {object} params
|
||||
* @param {string} params.actor - 행위자 ("admin", "youtube-3", "x-1" 등)
|
||||
* @param {string} params.action - 행동 (create, update, delete, upload, start, stop, sync_complete, error)
|
||||
* @param {string} params.category - 대분류 (album, schedule, member, bot, category, dict, concert, sync)
|
||||
* @param {string} [params.targetType] - 대상 타입 (youtube_schedule, x_schedule, album, photo, member 등)
|
||||
* @param {number} [params.targetId] - 대상 DB ID
|
||||
* @param {string} params.summary - 한 줄 요약
|
||||
* @param {object} [params.details] - 추가 상세 정보 (JSON)
|
||||
*/
|
||||
export async function logActivity(db, { actor, action, category, targetType, targetId, summary, details }) {
|
||||
try {
|
||||
await db.query(
|
||||
'INSERT INTO logs (actor, action, category, target_type, target_id, summary, details) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[actor, action, category, targetType || null, targetId || null, summary, details ? JSON.stringify(details) : null]
|
||||
);
|
||||
} catch (err) {
|
||||
// 로그 실패는 무시 — 비즈니스 로직에 영향 주지 않음
|
||||
}
|
||||
}
|
||||
186
docs/api.md
186
docs/api.md
|
|
@ -290,6 +290,142 @@ YouTube API 할당량 경고 조회
|
|||
|
||||
---
|
||||
|
||||
## 관리자 - YouTube 봇 (인증 필요)
|
||||
|
||||
### POST /admin/youtube-bots/lookup
|
||||
채널 핸들로 채널 정보 조회
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"handle": "@studiofromis_9"
|
||||
}
|
||||
```
|
||||
|
||||
**응답:**
|
||||
```json
|
||||
{
|
||||
"channelId": "UCxxx",
|
||||
"title": "채널명",
|
||||
"thumbnailUrl": "https://...",
|
||||
"bannerUrl": "https://..."
|
||||
}
|
||||
```
|
||||
|
||||
### GET /admin/youtube-bots
|
||||
YouTube 봇 목록 조회
|
||||
|
||||
### GET /admin/youtube-bots/:id
|
||||
YouTube 봇 상세 조회
|
||||
|
||||
### POST /admin/youtube-bots
|
||||
YouTube 봇 추가
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"channel_id": "UCxxx",
|
||||
"channel_handle": "@studiofromis_9",
|
||||
"channel_name": "채널명",
|
||||
"cron_interval": 2,
|
||||
"title_filters": ["fromis_9", "프로미스나인"],
|
||||
"default_member_ids": [1, 2],
|
||||
"extract_members_from_desc": true,
|
||||
"auto_schedule_config": {
|
||||
"dayOfWeek": 4,
|
||||
"time": "18:00:00",
|
||||
"titleTemplate": "{channelName} {episode}화",
|
||||
"deadlineDayOfWeek": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PUT /admin/youtube-bots/:id
|
||||
YouTube 봇 수정
|
||||
|
||||
### DELETE /admin/youtube-bots/:id
|
||||
YouTube 봇 삭제
|
||||
|
||||
---
|
||||
|
||||
## 관리자 - X 봇 (인증 필요)
|
||||
|
||||
### POST /admin/x-bots/lookup
|
||||
X username으로 프로필 정보 조회 (Nitter 사용)
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"username": "realfromis_9"
|
||||
}
|
||||
```
|
||||
|
||||
**응답:**
|
||||
```json
|
||||
{
|
||||
"username": "realfromis_9",
|
||||
"displayName": "프로미스나인 (fromis_9)",
|
||||
"avatarUrl": "https://..."
|
||||
}
|
||||
```
|
||||
|
||||
### GET /admin/x-bots
|
||||
X 봇 목록 조회
|
||||
|
||||
**응답:** `XBot[]`
|
||||
|
||||
### GET /admin/x-bots/:id
|
||||
X 봇 상세 조회
|
||||
|
||||
**응답:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"username": "realfromis_9",
|
||||
"display_name": "프로미스나인 (fromis_9)",
|
||||
"avatar_url": "https://...",
|
||||
"text_filters": ["fromis", "프로미스"],
|
||||
"include_retweets": false,
|
||||
"extract_youtube": true,
|
||||
"cron_interval": 1,
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
### POST /admin/x-bots
|
||||
X 봇 추가
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"username": "realfromis_9",
|
||||
"display_name": "프로미스나인 (fromis_9)",
|
||||
"avatar_url": "https://...",
|
||||
"text_filters": ["fromis"],
|
||||
"include_retweets": false,
|
||||
"extract_youtube": false,
|
||||
"cron_interval": 1
|
||||
}
|
||||
```
|
||||
|
||||
| 필드 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `username` | string | (필수) | X username (@ 없이) |
|
||||
| `display_name` | string\|null | null | 표시 이름 |
|
||||
| `avatar_url` | string\|null | null | 프로필 이미지 URL |
|
||||
| `text_filters` | string[]\|null | null | 텍스트 필터 (하나라도 포함 시 추가, 비어있으면 모든 트윗) |
|
||||
| `include_retweets` | boolean | false | 리트윗 포함 여부 |
|
||||
| `extract_youtube` | boolean | false | 트윗 내 YouTube 링크 자동 추출하여 유튜브 일정 추가 |
|
||||
| `cron_interval` | integer | 1 | 동기화 간격 (분) |
|
||||
|
||||
### PUT /admin/x-bots/:id
|
||||
X 봇 수정 (부분 업데이트 가능)
|
||||
|
||||
### DELETE /admin/x-bots/:id
|
||||
X 봇 삭제
|
||||
|
||||
---
|
||||
|
||||
## 관리자 - YouTube (인증 필요)
|
||||
|
||||
### GET /admin/youtube/video-info
|
||||
|
|
@ -386,6 +522,56 @@ X 일정 저장
|
|||
|
||||
---
|
||||
|
||||
## 관리자 - 활동 로그 (인증 필요)
|
||||
|
||||
### GET /admin/logs
|
||||
활동 로그 목록 조회
|
||||
|
||||
**Query Parameters:**
|
||||
- `page` - 페이지 번호 (기본 1)
|
||||
- `limit` - 페이지당 개수 (기본 50, 최대 100)
|
||||
- `category` - 카테고리 필터 (콤마 구분: album, schedule, member, bot, category, dict, concert, sync)
|
||||
- `actor` - 행위자 필터 (admin 또는 bot)
|
||||
- `search` - summary 텍스트 검색
|
||||
- `from` - 시작 날짜 (YYYY-MM-DD)
|
||||
- `to` - 종료 날짜 (YYYY-MM-DD)
|
||||
|
||||
**응답:**
|
||||
```json
|
||||
{
|
||||
"logs": [
|
||||
{
|
||||
"id": 1,
|
||||
"actor": "admin",
|
||||
"action": "create",
|
||||
"category": "album",
|
||||
"target_type": "album",
|
||||
"target_id": 12,
|
||||
"summary": "앨범 생성: Unlock My World",
|
||||
"details": null,
|
||||
"created_at": "2026-03-02 14:30:00"
|
||||
}
|
||||
],
|
||||
"total": 150,
|
||||
"page": 1,
|
||||
"limit": 50,
|
||||
"totalPages": 3
|
||||
}
|
||||
```
|
||||
|
||||
**actor 값:**
|
||||
- `"admin"` - 관리자 수동 작업
|
||||
- `"youtube-{id}"` - YouTube 봇 (예: youtube-3)
|
||||
- `"x-{id}"` - X 봇 (예: x-1)
|
||||
|
||||
**action 값:**
|
||||
- `create`, `update`, `delete`, `upload` - CRUD 작업
|
||||
- `start`, `stop` - 봇 시작/정지
|
||||
- `sync_complete` - 봇 동기화 완료
|
||||
- `error` - 봇 동기화 에러
|
||||
|
||||
---
|
||||
|
||||
## 헬스 체크
|
||||
|
||||
### GET /health
|
||||
|
|
|
|||
|
|
@ -18,8 +18,11 @@ fromis_9/
|
|||
│ │ ├── routes/ # API 라우트
|
||||
│ │ │ ├── admin/ # 관리자 API
|
||||
│ │ │ │ ├── bots.js # 봇 관리
|
||||
│ │ │ │ ├── youtube-bots.js # YouTube 봇 CRUD
|
||||
│ │ │ │ ├── x-bots.js # X 봇 CRUD
|
||||
│ │ │ │ ├── youtube.js # YouTube 일정 관리
|
||||
│ │ │ │ └── x.js # X 일정 관리
|
||||
│ │ │ │ ├── x.js # X 일정 관리
|
||||
│ │ │ │ └── logs.js # 활동 로그 조회
|
||||
│ │ │ ├── albums/
|
||||
│ │ │ │ ├── index.js # 앨범 CRUD
|
||||
│ │ │ │ ├── photos.js # 앨범 사진 관리
|
||||
|
|
@ -35,6 +38,8 @@ fromis_9/
|
|||
│ │ │ └── index.js # 라우트 등록
|
||||
│ │ ├── services/ # 비즈니스 로직
|
||||
│ │ │ ├── youtube/ # YouTube 봇
|
||||
│ │ │ │ ├── api.js # YouTube Data API 호출
|
||||
│ │ │ │ └── index.js # 봇 로직 (동기화, 저장)
|
||||
│ │ │ ├── x/ # X(Twitter) 봇
|
||||
│ │ │ ├── meilisearch/ # 검색 서비스
|
||||
│ │ │ └── suggestions/ # 추천 검색어
|
||||
|
|
@ -42,6 +47,7 @@ fromis_9/
|
|||
│ │ │ ├── cache.js # Redis 캐시 헬퍼 (SCAN 사용)
|
||||
│ │ │ ├── date.js # 날짜 유틸 (KST 변환)
|
||||
│ │ │ ├── error.js # 에러 응답 헬퍼
|
||||
│ │ │ ├── log.js # 활동 로그 유틸 (fire-and-forget)
|
||||
│ │ │ ├── logger.js # 로깅 유틸
|
||||
│ │ │ └── transaction.js # DB 트랜잭션 래퍼
|
||||
│ │ ├── app.js # Fastify 앱 설정
|
||||
|
|
@ -65,6 +71,7 @@ fromis_9/
|
|||
│ │ │ ├── categories.js
|
||||
│ │ │ ├── stats.js
|
||||
│ │ │ ├── bots.js
|
||||
│ │ │ ├── logs.js
|
||||
│ │ │ ├── auth.js
|
||||
│ │ │ └── suggestions.js
|
||||
│ │ │
|
||||
|
|
@ -149,8 +156,13 @@ fromis_9/
|
|||
│ │ │ │ │ ├── PhotoPreviewModal.jsx
|
||||
│ │ │ │ │ ├── PendingFileItem.jsx
|
||||
│ │ │ │ │ └── BulkEditPanel.jsx
|
||||
│ │ │ │ └── bot/
|
||||
│ │ │ │ └── BotCard.jsx
|
||||
│ │ │ │ ├── bot/
|
||||
│ │ │ │ │ ├── BotCard.jsx
|
||||
│ │ │ │ │ ├── YouTubeBotDialog.jsx
|
||||
│ │ │ │ │ └── XBotDialog.jsx
|
||||
│ │ │ │ └── log/
|
||||
│ │ │ │ ├── constants.js
|
||||
│ │ │ │ └── LogDetailDialog.jsx
|
||||
│ │ │ │
|
||||
│ │ │ └── mobile/ # 모바일 컴포넌트
|
||||
│ │ │ ├── layout/
|
||||
|
|
@ -198,6 +210,8 @@ fromis_9/
|
|||
│ │ │ │ │ ├── Albums.jsx
|
||||
│ │ │ │ │ ├── AlbumForm.jsx
|
||||
│ │ │ │ │ └── AlbumPhotos.jsx
|
||||
│ │ │ │ ├── logs/
|
||||
│ │ │ │ │ └── Logs.jsx
|
||||
│ │ │ │ └── schedules/
|
||||
│ │ │ │ ├── Schedules.jsx
|
||||
│ │ │ │ ├── ScheduleForm.jsx
|
||||
|
|
@ -280,7 +294,7 @@ fromis_9/
|
|||
|
||||
## 데이터베이스
|
||||
|
||||
### 테이블 목록 (25개)
|
||||
### 테이블 목록 (28개)
|
||||
|
||||
#### 사용자/인증
|
||||
- `admin_users` - 관리자 계정
|
||||
|
|
@ -312,8 +326,12 @@ fromis_9/
|
|||
- `concert_setlists` - 콘서트 셋리스트
|
||||
- `concert_setlist_members` - 셋리스트-멤버 연결
|
||||
|
||||
#### X(Twitter) 프로필
|
||||
- `x_profiles` - X 프로필 캐시 (프로필 이미지, 이름 등)
|
||||
#### 봇
|
||||
- `bot_youtube` - YouTube 봇 설정 (채널 정보, 동기화 간격, 필터 등, video_id UNIQUE)
|
||||
- `bot_x` - X 봇 설정 (username, 프로필, 동기화 간격, 텍스트 필터, 리트윗 포함, YouTube 추출)
|
||||
|
||||
#### 활동 로그
|
||||
- `logs` - 관리자/봇 활동 로그 (actor, action, category, summary 등)
|
||||
|
||||
#### 이미지
|
||||
- `images` - 이미지 메타데이터 (3개 해상도 URL)
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ src/api/
|
|||
└── admin/ # 관리자 API (인증 필요)
|
||||
├── auth.js # login, verifyToken
|
||||
├── albums.js # createAlbum, updateAlbum, deleteAlbum, ...
|
||||
├── bots.js # getBots, startBot, stopBot, syncBot
|
||||
├── bots.js # getBots, startBot, stopBot, syncBot, getXBot, createXBot, updateXBot, deleteXBot, lookupXProfile
|
||||
├── categories.js # getCategories, createCategory, updateCategory, ...
|
||||
├── members.js # updateMember
|
||||
├── schedules.js # getYoutubeInfo, saveYoutube, getXInfo, saveX, ...
|
||||
|
|
@ -258,6 +258,62 @@ queryClient.invalidateQueries();
|
|||
|
||||
---
|
||||
|
||||
## YouTube 봇 동기화
|
||||
|
||||
### 동기화 흐름 (syncNewVideos)
|
||||
1. `fetchRecentVideoIds()` — Activities API로 최근 영상 ID 목록만 조회 (1 unit)
|
||||
2. DB에서 이미 존재하는 video_id 필터링
|
||||
3. 새 영상만 `fetchVideoInfo()` — Videos API로 상세 정보 조회 (새 영상당 1 unit)
|
||||
4. `saveVideo()` — DB 저장 + Meilisearch 동기화
|
||||
|
||||
### API 할당량
|
||||
- 일일 할당량: 10,000 units
|
||||
- 새 영상 없을 때: activities.list 1 unit만 소비
|
||||
- 새 영상 있을 때: 1 + 새 영상 수 units
|
||||
- 1분 간격, 3채널 기준: ~4,320 units/일 (43%)
|
||||
|
||||
### 주요 API 함수 (services/youtube/api.js)
|
||||
| 함수 | YouTube API | 용도 |
|
||||
|------|-----------|------|
|
||||
| `fetchRecentVideoIds()` | activities.list (1 unit) | 최근 영상 ID 목록 조회 |
|
||||
| `fetchVideoInfo()` | videos.list (1 unit) | 단일 영상 상세 정보 |
|
||||
| `fetchAllVideos()` | playlistItems.list + videos.list | 전체 영상 초기 동기화 |
|
||||
| `getChannelByHandle()` | channels.list (1 unit) | 핸들로 채널 조회 |
|
||||
| `getChannelInfo()` | channels.list (1 unit) | 채널 정보 (배너 등) |
|
||||
|
||||
---
|
||||
|
||||
## 활동 로그 시스템
|
||||
|
||||
관리자/봇의 모든 활동을 `logs` 테이블에 기록하고 관리자 페이지에서 조회.
|
||||
|
||||
### 로그 기록 방법
|
||||
|
||||
```js
|
||||
import { logActivity } from '../utils/log.js';
|
||||
|
||||
// fire-and-forget: 로그 실패가 비즈니스 로직에 영향 주지 않음
|
||||
logActivity(db, {
|
||||
actor: 'admin', // "admin" 또는 봇 ID ("youtube-3", "x-1")
|
||||
action: 'create', // create, update, delete, upload, start, stop, sync_complete, error
|
||||
category: 'album', // album, schedule, member, bot, category, dict, concert, sync
|
||||
targetType: 'album', // 대상 타입 (optional)
|
||||
targetId: 12, // 대상 DB ID (optional)
|
||||
summary: '앨범 생성: 제목', // 한 줄 요약
|
||||
details: { key: 'value' }, // 추가 정보 JSON (optional)
|
||||
});
|
||||
```
|
||||
|
||||
### 새 기능 추가 시
|
||||
로그는 자동 수집이 아니므로, 새로운 라우트나 기능을 추가할 때 `logActivity` 호출을 직접 넣어야 합니다.
|
||||
|
||||
### 로그 대상
|
||||
- **관리자 라우트**: 앨범/일정/멤버/봇/카테고리/사전/콘서트 CRUD
|
||||
- **봇 스케줄러**: 동기화 완료(addedCount > 0), 동기화 에러
|
||||
- **봇 서비스**: YouTube 영상 추가, X 트윗 추가
|
||||
|
||||
---
|
||||
|
||||
## 유용한 명령어
|
||||
|
||||
```bash
|
||||
|
|
|
|||
130
docs/logs.md
Normal file
130
docs/logs.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# 활동 로그 시스템
|
||||
|
||||
## 개요
|
||||
|
||||
관리자 페이지에서 모든 행동(관리자 수동 작업 + 봇 자동 작업)에 대한 로그를 조회할 수 있는 시스템.
|
||||
앨범 CRUD, 멤버 수정, 일정 추가/수정/삭제, 봇 동기화 등 모든 활동을 DB에 기록하고 관리자 페이지에서 필터링/페이지네이션으로 조회.
|
||||
|
||||
---
|
||||
|
||||
## DB 테이블
|
||||
|
||||
### `logs`
|
||||
|
||||
```sql
|
||||
CREATE TABLE logs (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
actor VARCHAR(50) NOT NULL, -- "admin" 또는 봇 ID ("youtube-3", "x-1" 등)
|
||||
action VARCHAR(50) NOT NULL, -- create, update, delete, start, stop, sync_complete, error 등
|
||||
category VARCHAR(30) NOT NULL, -- album, schedule, member, bot, category, dict, concert, sync
|
||||
target_type VARCHAR(50) DEFAULT NULL, -- youtube_schedule, x_schedule, album, photo, member 등
|
||||
target_id INT UNSIGNED DEFAULT NULL,
|
||||
summary VARCHAR(500) NOT NULL, -- 사람이 읽을 수 있는 한 줄 요약
|
||||
details JSON DEFAULT NULL, -- 추가 상세 정보
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_category (category),
|
||||
INDEX idx_actor (actor)
|
||||
);
|
||||
```
|
||||
|
||||
### 컬럼 설명
|
||||
|
||||
| 컬럼 | 설명 | 예시 |
|
||||
|------|------|------|
|
||||
| `actor` | 행위자 | `"admin"`, `"youtube-3"`, `"x-1"`, `"meilisearch"` |
|
||||
| `action` | 행동 유형 | `create`, `update`, `delete`, `upload`, `start`, `stop`, `sync_complete`, `error` |
|
||||
| `category` | 대분류 | `album`, `schedule`, `member`, `bot`, `category`, `dict`, `concert`, `sync` |
|
||||
| `target_type` | 대상 타입 | `youtube_schedule`, `x_schedule`, `album`, `photo`, `teaser`, `member`, `youtube_bot`, `x_bot`, `category`, `concert` |
|
||||
| `target_id` | 대상 DB ID | 해당 레코드의 PK |
|
||||
| `summary` | 한 줄 요약 | `"YouTube 일정 생성: fromis_9 영상 제목"` |
|
||||
| `details` | 추가 정보 (JSON) | `{ "videoId": "abc123", "channelName": "채널명" }` |
|
||||
|
||||
---
|
||||
|
||||
## 백엔드 구현
|
||||
|
||||
### 로그 유틸리티
|
||||
|
||||
**파일:** `backend/src/utils/log.js`
|
||||
|
||||
```javascript
|
||||
import { logActivity } from '../utils/log.js';
|
||||
|
||||
logActivity(db, { actor, action, category, targetType, targetId, summary, details });
|
||||
```
|
||||
|
||||
- fire-and-forget: 로그 실패가 비즈니스 로직에 영향 주지 않도록 try/catch 감싸기
|
||||
- 트랜잭션 외부에서 호출 (로그 실패가 롤백 유발하지 않도록)
|
||||
|
||||
### API 엔드포인트
|
||||
|
||||
**GET /api/admin/logs** — 로그 목록 조회 (인증 필수)
|
||||
|
||||
| 파라미터 | 타입 | 기본값 | 설명 |
|
||||
|----------|------|--------|------|
|
||||
| `page` | integer | 1 | 페이지 번호 |
|
||||
| `limit` | integer | 50 | 페이지당 개수 (최대 100) |
|
||||
| `category` | string | - | 카테고리 필터 (콤마 구분) |
|
||||
| `actor` | string | - | 행위자 필터 (`"admin"` 또는 `"bot"`) |
|
||||
| `search` | string | - | summary 검색 |
|
||||
| `from` | string | - | 시작 날짜 (YYYY-MM-DD) |
|
||||
| `to` | string | - | 종료 날짜 (YYYY-MM-DD) |
|
||||
|
||||
### 로그 삽입 대상
|
||||
|
||||
#### 관리자 수동 작업
|
||||
|
||||
| 파일 | 로그 대상 |
|
||||
|------|----------|
|
||||
| `routes/admin/youtube.js` | YouTube 일정 생성/수정 |
|
||||
| `routes/admin/x.js` | X 일정 생성 |
|
||||
| `routes/admin/concert.js` | 콘서트 일정 생성 |
|
||||
| `routes/admin/youtube-bots.js` | YouTube 봇 생성/수정/삭제 |
|
||||
| `routes/admin/x-bots.js` | X 봇 생성/수정/삭제 |
|
||||
| `routes/admin/bots.js` | 봇 시작/정지/전체동기화 |
|
||||
| `routes/albums/index.js` | 앨범 생성/수정/삭제 |
|
||||
| `routes/albums/photos.js` | 사진 업로드/삭제 |
|
||||
| `routes/albums/teasers.js` | 티저 삭제 |
|
||||
| `routes/members/index.js` | 멤버 수정 |
|
||||
| `routes/schedules/index.js` | 일정 삭제, 카테고리 CRUD |
|
||||
| `routes/schedules/suggestions.js` | 사전 저장 |
|
||||
|
||||
#### 봇 자동 작업
|
||||
|
||||
| 파일 | 로그 대상 |
|
||||
|------|----------|
|
||||
| `plugins/scheduler.js` | 동기화 완료 (addedCount > 0일 때만), 에러 |
|
||||
| `services/youtube/index.js` | 영상 추가 성공 |
|
||||
| `services/x/index.js` | 트윗 추가 성공 |
|
||||
|
||||
> **봇 로그 전략:** 변화 없는 동기화는 로그 안 남김. `addedCount > 0`이거나 에러인 경우만 기록.
|
||||
|
||||
---
|
||||
|
||||
## 프론트엔드 구현
|
||||
|
||||
### 파일 구조
|
||||
|
||||
| 파일 | 내용 |
|
||||
|------|------|
|
||||
| `frontend/src/api/admin/logs.js` | API 클라이언트 |
|
||||
| `frontend/src/pages/pc/admin/logs/Logs.jsx` | 로그 페이지 컴포넌트 |
|
||||
|
||||
### 로그 페이지
|
||||
|
||||
**경로:** `/admin/logs`
|
||||
|
||||
**UI 구성:**
|
||||
- 필터 바: 카테고리 칩, 행위자 드롭다운(애니메이션), 기간 선택(커스텀 DatePicker), 텍스트 검색(300ms 디바운스)
|
||||
- 로그 테이블: 시간, 행위자(아이콘), 액션 뱃지(색상별), 카테고리, summary
|
||||
- 서버 사이드 페이지네이션 (keepPreviousData로 깜빡임 방지)
|
||||
|
||||
**액션 뱃지 색상:**
|
||||
| 액션 | 색상 |
|
||||
|------|------|
|
||||
| create / upload | 초록 |
|
||||
| update | 파랑 |
|
||||
| delete / error | 빨강 |
|
||||
| sync_complete | 보라 |
|
||||
| start / stop | 노랑 |
|
||||
|
|
@ -11,6 +11,116 @@ export async function getBots() {
|
|||
return fetchAuthApi('/admin/bots');
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube 봇 상세 조회
|
||||
* @param {number} id - YouTube 봇 DB ID
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function getYouTubeBot(id) {
|
||||
return fetchAuthApi(`/admin/youtube-bots/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 채널 핸들로 채널 정보 조회
|
||||
* @param {string} handle - @username 형식
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function lookupChannel(handle) {
|
||||
return fetchAuthApi('/admin/youtube-bots/lookup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ handle }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube 봇 추가
|
||||
* @param {object} data - 봇 데이터
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function createYouTubeBot(data) {
|
||||
return fetchAuthApi('/admin/youtube-bots', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube 봇 수정
|
||||
* @param {number} id - YouTube 봇 DB ID
|
||||
* @param {object} data - 업데이트할 데이터
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function updateYouTubeBot(id, data) {
|
||||
return fetchAuthApi(`/admin/youtube-bots/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube 봇 삭제
|
||||
* @param {number} id - YouTube 봇 DB ID
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function deleteYouTubeBot(id) {
|
||||
return fetchAuthApi(`/admin/youtube-bots/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* X 봇 상세 조회
|
||||
* @param {number} id - X 봇 DB ID
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function getXBot(id) {
|
||||
return fetchAuthApi(`/admin/x-bots/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* X username으로 프로필 정보 조회
|
||||
* @param {string} username - X username (@ 없이)
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function lookupXProfile(username) {
|
||||
return fetchAuthApi('/admin/x-bots/lookup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* X 봇 추가
|
||||
* @param {object} data - 봇 데이터
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function createXBot(data) {
|
||||
return fetchAuthApi('/admin/x-bots', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* X 봇 수정
|
||||
* @param {number} id - X 봇 DB ID
|
||||
* @param {object} data - 업데이트할 데이터
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function updateXBot(id, data) {
|
||||
return fetchAuthApi(`/admin/x-bots/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* X 봇 삭제
|
||||
* @param {number} id - X 봇 DB ID
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function deleteXBot(id) {
|
||||
return fetchAuthApi(`/admin/x-bots/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 시작
|
||||
* @param {string} id - 봇 ID
|
||||
|
|
|
|||
13
frontend/src/api/admin/concert.js
Normal file
13
frontend/src/api/admin/concert.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* 콘서트 관리자 API
|
||||
*/
|
||||
import { fetchFormData } from '@/api/client';
|
||||
|
||||
/**
|
||||
* 콘서트 일정 생성
|
||||
* @param {FormData} formData - 콘서트 데이터
|
||||
* @returns {Promise<{success: boolean, seriesId: number}>}
|
||||
*/
|
||||
export async function createConcertSchedule(formData) {
|
||||
return fetchFormData('/admin/concert/schedule', formData, 'POST');
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ export * as adminCategoryApi from './categories';
|
|||
export * as adminBotApi from './bots';
|
||||
export * as adminStatsApi from './stats';
|
||||
export * as adminSuggestionApi from './suggestions';
|
||||
export * as adminLogApi from './logs';
|
||||
export * as adminAuthApi from './auth';
|
||||
|
||||
// 개별 함수 export
|
||||
|
|
|
|||
35
frontend/src/api/admin/logs.js
Normal file
35
frontend/src/api/admin/logs.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* 관리자 활동 로그 API
|
||||
*/
|
||||
import { fetchAuthApi } from '@/api/client';
|
||||
|
||||
/**
|
||||
* 활동 로그 목록 조회
|
||||
* @param {object} params - 쿼리 파라미터
|
||||
* @param {number} [params.page] - 페이지 번호
|
||||
* @param {number} [params.limit] - 페이지당 개수
|
||||
* @param {string} [params.category] - 카테고리 필터 (콤마 구분)
|
||||
* @param {string} [params.actor] - 행위자 필터 (admin 또는 bot)
|
||||
* @param {string} [params.search] - summary 검색
|
||||
* @param {string} [params.from] - 시작 날짜 (YYYY-MM-DD)
|
||||
* @param {string} [params.to] - 종료 날짜 (YYYY-MM-DD)
|
||||
* @returns {Promise<{logs: Array, total: number, page: number, limit: number, totalPages: number}>}
|
||||
*/
|
||||
/**
|
||||
* 로그 카테고리 목록 조회
|
||||
* @returns {Promise<{categories: string[]}>}
|
||||
*/
|
||||
export async function getLogCategories() {
|
||||
return fetchAuthApi('/admin/logs/categories');
|
||||
}
|
||||
|
||||
export async function getLogs(params = {}) {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
query.set(key, value);
|
||||
}
|
||||
}
|
||||
const qs = query.toString();
|
||||
return fetchAuthApi(`/admin/logs${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
113
frontend/src/components/common/BirthdayCelebrationDialog.jsx
Normal file
113
frontend/src/components/common/BirthdayCelebrationDialog.jsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { memo, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* 생일 축하 다이얼로그
|
||||
* @param {boolean} isOpen - 다이얼로그 표시 여부
|
||||
* @param {function} onClose - 닫기 핸들러
|
||||
* @param {string} title - 제목 (예: HAPPY Jiwon DAY)
|
||||
* @param {string} memberImage - 멤버 이미지 URL
|
||||
* @param {string} date - 생일 날짜 (YYYY-MM-DD)
|
||||
*/
|
||||
const BirthdayCelebrationDialog = memo(function BirthdayCelebrationDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
title = '',
|
||||
memberImage = '',
|
||||
date = '',
|
||||
}) {
|
||||
// ESC 키로 닫기
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const dateObj = date ? new Date(date) : new Date();
|
||||
const month = dateObj.getMonth() + 1;
|
||||
const day = dateObj.getDate();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* 배경 오버레이 */}
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
||||
|
||||
{/* 다이얼로그 */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.8, opacity: 0, y: 20 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="relative w-full max-w-md overflow-hidden rounded-3xl bg-gradient-to-br from-pink-400 via-purple-400 to-indigo-400 shadow-2xl"
|
||||
>
|
||||
{/* 닫기 버튼 */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 z-10 p-2 rounded-full bg-white/20 hover:bg-white/30 transition-colors"
|
||||
>
|
||||
<X size={20} className="text-white" />
|
||||
</button>
|
||||
|
||||
{/* 배경 장식 */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-6 left-10 text-2xl animate-pulse">🎉</div>
|
||||
<div className="absolute top-10 right-16 text-xl animate-pulse delay-100">🎂</div>
|
||||
<div className="absolute bottom-20 left-8 text-2xl animate-pulse delay-200">🎁</div>
|
||||
<div className="absolute bottom-10 right-10 text-xl animate-pulse delay-150">🎉</div>
|
||||
<div className="absolute top-1/3 left-4 text-white/30 text-lg animate-pulse delay-300">✦</div>
|
||||
<div className="absolute top-1/2 right-4 text-white/20 text-sm animate-pulse delay-250">✦</div>
|
||||
<div className="absolute -top-16 -left-16 w-48 h-48 bg-white/10 rounded-full" />
|
||||
<div className="absolute -bottom-20 -right-20 w-56 h-56 bg-white/10 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div className="relative flex flex-col items-center py-12 px-8 text-center">
|
||||
{/* 멤버 사진 */}
|
||||
<div className="w-28 h-28 rounded-full bg-white/30 backdrop-blur-sm flex items-center justify-center shadow-lg border-4 border-white/30 mb-6 overflow-hidden">
|
||||
{memberImage ? (
|
||||
<img
|
||||
src={memberImage}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-5xl">🎂</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 텍스트 */}
|
||||
<h2
|
||||
className="text-white font-bold text-2xl mb-2"
|
||||
style={{ textShadow: '0 2px 4px rgba(0,0,0,0.2)' }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p
|
||||
className="text-white/80 text-base"
|
||||
style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}
|
||||
>
|
||||
🎂 {month}월 {day}일
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
|
||||
export default BirthdayCelebrationDialog;
|
||||
|
|
@ -9,3 +9,4 @@ export { default as LightboxIndicator } from './LightboxIndicator';
|
|||
export { default as AnimatedNumber } from './AnimatedNumber';
|
||||
export { default as Fromis9Logo } from './Fromis9Logo';
|
||||
export { default as DebutCelebrationDialog } from './DebutCelebrationDialog';
|
||||
export { default as BirthdayCelebrationDialog } from './BirthdayCelebrationDialog';
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
*/
|
||||
import { memo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Youtube, Play, Square, RefreshCw, Download } from 'lucide-react';
|
||||
import { Play, Square, RefreshCw, RotateCcw, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Tooltip } from '@/components/common';
|
||||
|
||||
// X 아이콘 컴포넌트
|
||||
export const XIcon = ({ size = 20, fill = 'currentColor' }) => (
|
||||
|
|
@ -16,72 +17,101 @@ export const XIcon = ({ size = 20, fill = 'currentColor' }) => (
|
|||
export const MeilisearchIcon = ({ size = 20 }) => (
|
||||
<svg width={size} height={size} viewBox="0 108.4 512 295.2">
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="meili-a"
|
||||
x1="488.157"
|
||||
x2="-21.055"
|
||||
y1="469.917"
|
||||
y2="179.001"
|
||||
gradientTransform="matrix(1 0 0 -1 0 514)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<linearGradient id="meili-a" x1="488.157" x2="-21.055" y1="469.917" y2="179.001" gradientTransform="matrix(1 0 0 -1 0 514)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stopColor="#ff5caa" />
|
||||
<stop offset="1" stopColor="#ff4e62" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="meili-b"
|
||||
x1="522.305"
|
||||
x2="13.094"
|
||||
y1="410.144"
|
||||
y2="119.228"
|
||||
gradientTransform="matrix(1 0 0 -1 0 514)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<linearGradient id="meili-b" x1="522.305" x2="13.094" y1="410.144" y2="119.228" gradientTransform="matrix(1 0 0 -1 0 514)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stopColor="#ff5caa" />
|
||||
<stop offset="1" stopColor="#ff4e62" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="meili-c"
|
||||
x1="556.456"
|
||||
x2="47.244"
|
||||
y1="350.368"
|
||||
y2="59.452"
|
||||
gradientTransform="matrix(1 0 0 -1 0 514)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<linearGradient id="meili-c" x1="556.456" x2="47.244" y1="350.368" y2="59.452" gradientTransform="matrix(1 0 0 -1 0 514)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stopColor="#ff5caa" />
|
||||
<stop offset="1" stopColor="#ff4e62" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="m0 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z"
|
||||
fill="url(#meili-a)"
|
||||
/>
|
||||
<path
|
||||
d="m138.8 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z"
|
||||
fill="url(#meili-b)"
|
||||
/>
|
||||
<path
|
||||
d="m277.6 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z"
|
||||
fill="url(#meili-c)"
|
||||
/>
|
||||
<path d="m0 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z" fill="url(#meili-a)" />
|
||||
<path d="m138.8 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z" fill="url(#meili-b)" />
|
||||
<path d="m277.6 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z" fill="url(#meili-c)" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {Object} props.bot - 봇 데이터
|
||||
* @param {number} props.index - 인덱스 (애니메이션용)
|
||||
* @param {boolean} props.isInitialLoad - 첫 로드 여부
|
||||
* @param {string|null} props.syncing - 동기화 중인 봇 ID
|
||||
* @param {Object} props.statusInfo - 상태 정보 (text, color, bg, dot)
|
||||
* @param {Function} props.onSync - 동기화 핸들러
|
||||
* @param {Function} props.onToggle - 토글 핸들러
|
||||
* @param {Function} props.onAnimationComplete - 애니메이션 완료 핸들러
|
||||
* @param {Function} props.formatTime - 시간 포맷 함수
|
||||
* @param {Function} props.formatInterval - 간격 포맷 함수
|
||||
* 리스트형 봇 (Meilisearch용) - 한 줄에 모든 정보
|
||||
*/
|
||||
const BotCard = memo(function BotCard({
|
||||
export const BotListItem = memo(function BotListItem({
|
||||
bot,
|
||||
index,
|
||||
isInitialLoad,
|
||||
syncing,
|
||||
statusInfo,
|
||||
onSync,
|
||||
onToggle,
|
||||
onAnimationComplete,
|
||||
formatTime,
|
||||
formatInterval,
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={isInitialLoad ? { opacity: 0, x: -10 } : false}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={isInitialLoad ? { delay: index * 0.05, duration: 0.2 } : { duration: 0.15 }}
|
||||
onAnimationComplete={onAnimationComplete}
|
||||
className="flex items-center gap-4 p-4 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{/* 상태 표시 */}
|
||||
<div className={`w-2 h-2 rounded-full ${statusInfo.dot} ${bot.status === 'running' ? 'animate-pulse' : ''}`} />
|
||||
|
||||
{/* 이름 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-gray-900 truncate">{bot.name}</h3>
|
||||
</div>
|
||||
|
||||
{/* 통계 */}
|
||||
<div className="hidden sm:flex items-center gap-6 text-sm text-gray-500">
|
||||
<div className="text-center">
|
||||
<span className="font-semibold text-gray-900">{bot.schedules_added || 0}</span>
|
||||
<span className="ml-1">추가</span>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<span className="text-xs">{bot.last_check_at ? formatTime(bot.last_check_at) : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onSync(bot.id)}
|
||||
disabled={syncing === bot.id}
|
||||
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors disabled:opacity-50"
|
||||
title="전체 동기화"
|
||||
>
|
||||
{syncing === bot.id ? (
|
||||
<RefreshCw size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={18} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onToggle(bot.id, bot.status, bot.name)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
bot.status === 'running'
|
||||
? 'text-gray-500 hover:text-red-600 hover:bg-red-50'
|
||||
: 'text-gray-500 hover:text-green-600 hover:bg-green-50'
|
||||
}`}
|
||||
title={bot.status === 'running' ? '정지' : '시작'}
|
||||
>
|
||||
{bot.status === 'running' ? <Square size={18} /> : <Play size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 미니 카드형 봇 (YouTube용) - 컴팩트한 카드
|
||||
*/
|
||||
export const BotMiniCard = memo(function BotMiniCard({
|
||||
bot,
|
||||
index,
|
||||
isInitialLoad,
|
||||
|
|
@ -97,120 +127,200 @@ const BotCard = memo(function BotCard({
|
|||
<motion.div
|
||||
initial={isInitialLoad ? { opacity: 0, scale: 0.95 } : false}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={isInitialLoad ? { delay: index * 0.05 } : { duration: 0.15 }}
|
||||
transition={isInitialLoad ? { delay: index * 0.05, duration: 0.2 } : { duration: 0.15 }}
|
||||
onAnimationComplete={onAnimationComplete}
|
||||
className="relative bg-gradient-to-br from-gray-50 to-white rounded-xl border border-gray-200 overflow-hidden hover:shadow-md transition-all"
|
||||
className="group relative bg-white border border-gray-200 rounded-xl overflow-hidden hover:shadow-md transition-all"
|
||||
>
|
||||
{/* 상단 헤더 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
bot.type === 'x'
|
||||
? 'bg-black'
|
||||
: bot.type === 'meilisearch'
|
||||
? 'bg-[#ddf1fd]'
|
||||
: 'bg-red-50'
|
||||
}`}
|
||||
>
|
||||
{bot.type === 'x' ? (
|
||||
<XIcon size={20} fill="white" />
|
||||
) : bot.type === 'meilisearch' ? (
|
||||
<MeilisearchIcon size={20} />
|
||||
) : (
|
||||
<Youtube size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900">{bot.name}</h3>
|
||||
<p className="text-xs text-gray-400">
|
||||
{bot.last_check_at
|
||||
? `${formatTime(bot.last_check_at)}에 업데이트됨`
|
||||
: '아직 업데이트 없음'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full ${statusInfo.bg} ${statusInfo.color}`}
|
||||
>
|
||||
{/* 메인 영역 */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-900 truncate flex-1">{bot.name}</h3>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot} ${bot.status === 'running' ? 'animate-pulse' : ''}`}
|
||||
></span>
|
||||
{statusInfo.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 통계 정보 */}
|
||||
<div className="grid grid-cols-3 divide-x divide-gray-100 bg-gray-50/50">
|
||||
<div className="p-3 text-center">
|
||||
<div className="text-lg font-bold text-gray-900">{bot.schedules_added || 0}</div>
|
||||
<div className="text-xs text-gray-400">총 추가</div>
|
||||
</div>
|
||||
<div className="p-3 text-center">
|
||||
<div
|
||||
className={`text-lg font-bold ${bot.last_added_count > 0 ? 'text-green-500' : 'text-gray-400'}`}
|
||||
className={`ml-2 flex items-center gap-1.5 px-2 py-0.5 text-xs font-medium rounded-full ${statusInfo.bg} ${statusInfo.color}`}
|
||||
>
|
||||
+{bot.last_added_count || 0}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">마지막</div>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot} ${bot.status === 'running' ? 'animate-pulse' : ''}`} />
|
||||
{statusInfo.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3 text-center">
|
||||
<div className="text-lg font-bold text-gray-900">{formatInterval(bot.check_interval)}</div>
|
||||
<div className="text-xs text-gray-400">업데이트 간격</div>
|
||||
|
||||
{/* 간단 통계 */}
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-gray-500">
|
||||
<span>총 <strong className="text-gray-900">{bot.schedules_added || 0}</strong></span>
|
||||
<span>•</span>
|
||||
<span>최근 <strong className={bot.last_added_count > 0 ? 'text-green-600' : 'text-gray-400'}>+{bot.last_added_count || 0}</strong></span>
|
||||
<span>•</span>
|
||||
<span>{formatInterval(bot.check_interval)}</span>
|
||||
</div>
|
||||
|
||||
{/* 마지막 업데이트 */}
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
{bot.last_check_at ? formatTime(bot.last_check_at) : '대기 중'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 오류 메시지 */}
|
||||
{bot.status === 'error' && bot.error_message && (
|
||||
<div className="px-4 py-2 bg-red-50 text-red-600 text-xs border-t border-red-100">
|
||||
<div className="px-4 py-2 bg-red-50 text-red-600 text-xs">
|
||||
{bot.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="p-4 border-t border-gray-100">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onSync(bot.id)}
|
||||
disabled={syncing === bot.id}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium transition-colors hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{syncing === bot.id ? (
|
||||
<>
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
<span>동기화 중...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={16} />
|
||||
<span>전체 동기화</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onToggle(bot.id, bot.status, bot.name)}
|
||||
className={`flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${
|
||||
bot.status === 'running'
|
||||
? 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
: 'bg-green-500 text-white hover:bg-green-600'
|
||||
}`}
|
||||
>
|
||||
{bot.status === 'running' ? (
|
||||
<>
|
||||
<Square size={16} />
|
||||
<span>정지</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={16} />
|
||||
<span>시작</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* 호버시 나타나는 액션 버튼 */}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => onSync(bot.id)}
|
||||
disabled={syncing === bot.id}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-white text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{syncing === bot.id ? (
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={14} />
|
||||
)}
|
||||
동기화
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onToggle(bot.id, bot.status, bot.name)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
bot.status === 'running'
|
||||
? 'bg-red-500 text-white hover:bg-red-600'
|
||||
: 'bg-green-500 text-white hover:bg-green-600'
|
||||
}`}
|
||||
>
|
||||
{bot.status === 'running' ? <Square size={14} /> : <Play size={14} />}
|
||||
{bot.status === 'running' ? '정지' : '시작'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 테이블 행 봇
|
||||
*/
|
||||
export const BotTableRow = memo(function BotTableRow({
|
||||
bot,
|
||||
index,
|
||||
isInitialLoad,
|
||||
syncing,
|
||||
statusInfo,
|
||||
onSync,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAnimationComplete,
|
||||
formatTime,
|
||||
formatInterval,
|
||||
}) {
|
||||
return (
|
||||
<motion.tr
|
||||
initial={isInitialLoad ? { opacity: 0 } : false}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={isInitialLoad ? { delay: index * 0.05, duration: 0.2 } : { duration: 0.15 }}
|
||||
onAnimationComplete={onAnimationComplete}
|
||||
className="border-b border-gray-100 last:border-0 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${statusInfo.dot} ${bot.status === 'running' ? 'animate-pulse' : ''}`} />
|
||||
<span className="font-medium text-gray-900 truncate">{bot.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusInfo.bg} ${statusInfo.color}`}>
|
||||
{statusInfo.text}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 font-medium">{bot.schedules_added || 0}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span className={bot.last_added_count > 0 ? 'text-green-600 font-medium' : 'text-gray-400'}>
|
||||
+{bot.last_added_count || 0}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">{formatInterval(bot.check_interval)}</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-400">
|
||||
{bot.last_check_at ? formatTime(bot.last_check_at) : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 전체 동기화 */}
|
||||
<Tooltip text="전체 동기화">
|
||||
<button
|
||||
onClick={() => onSync(bot.id)}
|
||||
disabled={syncing === bot.id}
|
||||
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{syncing === bot.id ? (
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
) : (
|
||||
<RotateCcw size={16} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/* 시작/정지 */}
|
||||
<Tooltip text={bot.status === 'running' ? '정지' : '시작'}>
|
||||
<button
|
||||
onClick={() => onToggle(bot.id, bot.status, bot.name)}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
bot.status === 'running'
|
||||
? 'text-gray-400 hover:text-orange-600 hover:bg-orange-50'
|
||||
: 'text-gray-400 hover:text-green-600 hover:bg-green-50'
|
||||
}`}
|
||||
>
|
||||
{bot.status === 'running' ? <Square size={16} /> : <Play size={16} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/* 수정 (YouTube, X) */}
|
||||
{(bot.type === 'youtube' || bot.type === 'x') && onEdit && (
|
||||
<Tooltip text="수정">
|
||||
<button
|
||||
onClick={() => onEdit(bot)}
|
||||
className="p-1.5 text-gray-400 hover:text-amber-600 hover:bg-amber-50 rounded transition-colors"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* 삭제 (YouTube, X) */}
|
||||
{(bot.type === 'youtube' || bot.type === 'x') && onDelete && (
|
||||
<Tooltip text="삭제">
|
||||
<button
|
||||
onClick={() => onDelete(bot)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 테이블 래퍼
|
||||
*/
|
||||
export const BotTable = ({ children }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full table-fixed">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-4 py-3 w-[26%]">이름</th>
|
||||
<th className="px-4 py-3 w-[9%]">상태</th>
|
||||
<th className="px-4 py-3 w-[9%]">총 추가</th>
|
||||
<th className="px-4 py-3 w-[9%]">최근</th>
|
||||
<th className="px-4 py-3 w-[9%]">간격</th>
|
||||
<th className="px-4 py-3 w-[22%]">마지막 업데이트</th>
|
||||
<th className="px-4 py-3 w-[16%]">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{children}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 기본 카드 (호환성 유지)
|
||||
const BotCard = BotMiniCard;
|
||||
|
||||
export default BotCard;
|
||||
|
|
|
|||
476
frontend/src/components/pc/admin/bot/XBotDialog.jsx
Normal file
476
frontend/src/components/pc/admin/bot/XBotDialog.jsx
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
/**
|
||||
* X 봇 추가/수정 다이얼로그
|
||||
*/
|
||||
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 { Search, X, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
|
||||
import { getXBot, createXBot, updateXBot, lookupXProfile } from '@/api/admin/bots';
|
||||
import { XIcon } from './BotCard';
|
||||
|
||||
// 동기화 간격 옵션
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ value: 1, label: '1분' },
|
||||
{ value: 2, label: '2분' },
|
||||
{ value: 5, label: '5분' },
|
||||
{ value: 10, label: '10분' },
|
||||
{ value: 30, label: '30분' },
|
||||
{ value: 60, label: '1시간' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 커스텀 드롭다운 컴포넌트 (Portal 사용)
|
||||
*/
|
||||
function Dropdown({ value, 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 selectedOption = options.find((opt) => opt.value === value);
|
||||
|
||||
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={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-sky-50 text-sky-600' : ''
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = !!botId;
|
||||
|
||||
// 폼 상태
|
||||
const [username, setUsername] = useState('');
|
||||
const [profileInfo, setProfileInfo] = useState(null);
|
||||
const [lookupLoading, setLookupLoading] = useState(false);
|
||||
const [interval, setInterval] = useState(1);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// 고급 설정
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [textFilters, setTextFilters] = useState([]);
|
||||
const [filterInput, setFilterInput] = useState('');
|
||||
const [includeRetweets, setIncludeRetweets] = useState(false);
|
||||
const [extractYoutube, setExtractYoutube] = useState(false);
|
||||
|
||||
// X 봇 상세 조회 (수정 모드)
|
||||
const { data: bot, isLoading: botLoading } = useQuery({
|
||||
queryKey: ['admin', 'x-bot', botId],
|
||||
queryFn: () => getXBot(botId),
|
||||
enabled: isOpen && !!botId,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// 다이얼로그 열릴 때 데이터 설정
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (bot) {
|
||||
// 수정 모드
|
||||
setUsername(bot.username || '');
|
||||
setProfileInfo({
|
||||
username: bot.username,
|
||||
displayName: bot.display_name,
|
||||
avatarUrl: bot.avatar_url,
|
||||
});
|
||||
setInterval(bot.cron_interval || 1);
|
||||
setTextFilters(bot.text_filters || []);
|
||||
setIncludeRetweets(bot.include_retweets || false);
|
||||
setExtractYoutube(bot.extract_youtube || false);
|
||||
setShowAdvanced((bot.text_filters && bot.text_filters.length > 0) || bot.include_retweets || bot.extract_youtube || false);
|
||||
} else if (!botId) {
|
||||
// 추가 모드
|
||||
setUsername('');
|
||||
setProfileInfo(null);
|
||||
setInterval(1);
|
||||
setTextFilters([]);
|
||||
setFilterInput('');
|
||||
setIncludeRetweets(false);
|
||||
setExtractYoutube(false);
|
||||
setShowAdvanced(false);
|
||||
}
|
||||
}, [isOpen, bot, botId]);
|
||||
|
||||
// 프로필 조회
|
||||
const handleLookup = async () => {
|
||||
if (!username.trim()) return;
|
||||
setLookupLoading(true);
|
||||
try {
|
||||
const data = await lookupXProfile(username);
|
||||
setProfileInfo({
|
||||
username: data.username,
|
||||
displayName: data.displayName,
|
||||
avatarUrl: data.avatarUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('프로필 조회 실패:', error);
|
||||
alert(error.message || '프로필을 찾을 수 없습니다.');
|
||||
} finally {
|
||||
setLookupLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 제출
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!profileInfo) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const data = {
|
||||
username: profileInfo.username,
|
||||
display_name: profileInfo.displayName,
|
||||
avatar_url: profileInfo.avatarUrl,
|
||||
text_filters: textFilters.length > 0 ? textFilters : null,
|
||||
include_retweets: includeRetweets,
|
||||
extract_youtube: extractYoutube,
|
||||
cron_interval: interval,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
await updateXBot(botId, data);
|
||||
} else {
|
||||
await createXBot(data);
|
||||
}
|
||||
|
||||
// 캐시 무효화
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'bots'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'x-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-gray-100 flex items-center justify-center">
|
||||
<XIcon size={20} fill="#000" />
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-gray-900">
|
||||
{isEdit ? 'X 봇 수정' : 'X 봇 추가'}
|
||||
</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-sky-500" />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-6 space-y-5">
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Username
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">@</span>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="realfromis_9"
|
||||
disabled={isEdit}
|
||||
className="w-full pl-8 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500/20 focus:border-sky-500 disabled:bg-gray-50 disabled:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
{!isEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLookup}
|
||||
disabled={lookupLoading || !username.trim()}
|
||||
className="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{lookupLoading ? (
|
||||
<span className="w-4 h-4 border-2 border-gray-400/30 border-t-gray-400 rounded-full animate-spin" />
|
||||
) : (
|
||||
<Search size={18} />
|
||||
)}
|
||||
조회
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 프로필 정보 표시 */}
|
||||
{profileInfo && (
|
||||
<div className="mt-3 p-4 bg-gray-50 rounded-lg flex items-center gap-4">
|
||||
{profileInfo.avatarUrl ? (
|
||||
<img
|
||||
src={profileInfo.avatarUrl}
|
||||
alt={profileInfo.displayName}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 bg-gray-200 rounded-full flex items-center justify-center">
|
||||
<XIcon size={24} fill="#374151" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 truncate">
|
||||
{profileInfo.displayName}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">@{profileInfo.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* 고급 설정 */}
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
<span className="font-medium text-gray-700">고급 설정</span>
|
||||
{showAdvanced ? (
|
||||
<ChevronUp size={20} className="text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown size={20} className="text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="p-4 space-y-4 border-t border-gray-100">
|
||||
{/* 리트윗 포함 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">리트윗 포함</label>
|
||||
<p className="text-xs text-gray-400">리트윗도 일정에 추가합니다</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIncludeRetweets(!includeRetweets)}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||
includeRetweets ? 'bg-sky-500' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${
|
||||
includeRetweets ? 'translate-x-5' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* YouTube 영상 추출 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">YouTube 영상 추출</label>
|
||||
<p className="text-xs text-gray-400">트윗에 YouTube 링크가 있으면 유튜브 일정에 추가합니다</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExtractYoutube(!extractYoutube)}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||
extractYoutube ? 'bg-sky-500' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${
|
||||
extractYoutube ? 'translate-x-5' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 텍스트 필터 */}
|
||||
<div>
|
||||
<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]">
|
||||
{textFilters.map((filter, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-sky-50 text-sky-600 rounded-md text-sm"
|
||||
>
|
||||
{filter}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTextFilters(textFilters.filter((_, i) => i !== idx))}
|
||||
className="hover:text-sky-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 (!textFilters.includes(filterInput.trim())) {
|
||||
setTextFilters([...textFilters, filterInput.trim()]);
|
||||
}
|
||||
setFilterInput('');
|
||||
}
|
||||
}}
|
||||
placeholder={textFilters.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>
|
||||
)}
|
||||
</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={!profileInfo || submitting || botLoading}
|
||||
className="px-4 py-2.5 bg-sky-500 text-white rounded-lg hover:bg-sky-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 XBotDialog;
|
||||
752
frontend/src/components/pc/admin/bot/YouTubeBotDialog.jsx
Normal file
752
frontend/src/components/pc/admin/bot/YouTubeBotDialog.jsx
Normal file
|
|
@ -0,0 +1,752 @@
|
|||
/**
|
||||
* YouTube 봇 추가/수정 다이얼로그
|
||||
*/
|
||||
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 { Youtube, Search, X, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
|
||||
import { getMembers } from '@/api/public/members';
|
||||
import { getYouTubeBot, createYouTubeBot, updateYouTubeBot, lookupChannel } from '@/api/admin/bots';
|
||||
|
||||
// 동기화 간격 옵션
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ value: 1, label: '1분' },
|
||||
{ value: 2, label: '2분' },
|
||||
{ value: 5, label: '5분' },
|
||||
{ value: 10, label: '10분' },
|
||||
{ value: 30, label: '30분' },
|
||||
{ value: 60, label: '1시간' },
|
||||
];
|
||||
|
||||
// 요일 옵션
|
||||
const DAY_OPTIONS = [
|
||||
{ value: 0, label: '일요일' },
|
||||
{ value: 1, label: '월요일' },
|
||||
{ value: 2, label: '화요일' },
|
||||
{ value: 3, label: '수요일' },
|
||||
{ value: 4, label: '목요일' },
|
||||
{ value: 5, label: '금요일' },
|
||||
{ value: 6, label: '토요일' },
|
||||
];
|
||||
|
||||
// 시간 옵션 (00:00 ~ 23:00)
|
||||
const TIME_OPTIONS = Array.from({ length: 24 }, (_, i) => ({
|
||||
value: `${String(i).padStart(2, '0')}:00`,
|
||||
label: `${String(i).padStart(2, '0')}:00`,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 커스텀 드롭다운 컴포넌트 (Portal 사용)
|
||||
*/
|
||||
function Dropdown({ value, 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 selectedOption = options.find((opt) => opt.value === value);
|
||||
|
||||
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={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-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>
|
||||
);
|
||||
}
|
||||
|
||||
function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = !!botId;
|
||||
|
||||
// 폼 상태
|
||||
const [handle, setHandle] = useState('');
|
||||
const [channelInfo, setChannelInfo] = useState(null);
|
||||
const [lookupLoading, setLookupLoading] = useState(false);
|
||||
const [interval, setInterval] = useState(2);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// 예정 일정 설정
|
||||
const [autoScheduleEnabled, setAutoScheduleEnabled] = useState(false);
|
||||
const [scheduleDayOfWeek, setScheduleDayOfWeek] = useState(4);
|
||||
const [scheduleTime, setScheduleTime] = useState('18:00');
|
||||
const [titleTemplate, setTitleTemplate] = useState('{channelName} {episode}화');
|
||||
const [deadlineDayOfWeek, setDeadlineDayOfWeek] = useState(5);
|
||||
|
||||
// 고급 설정
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [titleFilters, setTitleFilters] = useState([]);
|
||||
const [filterInput, setFilterInput] = useState('');
|
||||
const [defaultMemberIds, setDefaultMemberIds] = useState([]);
|
||||
const [extractMembers, setExtractMembers] = useState(false);
|
||||
|
||||
// 멤버 목록 (탈퇴 멤버 제외)
|
||||
const [members, setMembers] = useState([]);
|
||||
|
||||
// YouTube 봇 상세 조회 (수정 모드)
|
||||
const { data: bot, isLoading: botLoading } = useQuery({
|
||||
queryKey: ['admin', 'youtube-bot', botId],
|
||||
queryFn: () => getYouTubeBot(botId),
|
||||
enabled: isOpen && !!botId,
|
||||
staleTime: 0, // 항상 fresh 데이터 가져오기
|
||||
});
|
||||
|
||||
// 멤버 목록 로드
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
getMembers()
|
||||
.then((data) => setMembers(data.filter((m) => !m.is_former)))
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// 다이얼로그 열릴 때 데이터 설정 (수정/추가 모드)
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return; // 닫혀있으면 아무것도 안 함
|
||||
}
|
||||
|
||||
if (bot) {
|
||||
// 수정 모드: 기존 데이터 로드
|
||||
setHandle(bot.channel_handle || '');
|
||||
setChannelInfo({
|
||||
channelId: bot.channel_id,
|
||||
title: bot.channel_name,
|
||||
bannerUrl: bot.banner_url,
|
||||
});
|
||||
setInterval(bot.cron_interval || 2);
|
||||
|
||||
const config = bot.auto_schedule_config
|
||||
? (typeof bot.auto_schedule_config === 'string'
|
||||
? JSON.parse(bot.auto_schedule_config)
|
||||
: bot.auto_schedule_config)
|
||||
: null;
|
||||
|
||||
// config가 존재하고 dayOfWeek가 정의되어 있으면 활성화
|
||||
if (config && config.dayOfWeek !== undefined) {
|
||||
setAutoScheduleEnabled(true);
|
||||
setScheduleDayOfWeek(config.dayOfWeek);
|
||||
setScheduleTime(config.time?.slice(0, 5) || '18:00');
|
||||
setTitleTemplate(config.titleTemplate || '{channelName} {episode}화');
|
||||
setDeadlineDayOfWeek(config.deadlineDayOfWeek ?? 5);
|
||||
} else {
|
||||
setAutoScheduleEnabled(false);
|
||||
setScheduleDayOfWeek(4);
|
||||
setScheduleTime('18:00');
|
||||
setTitleTemplate('{channelName} {episode}화');
|
||||
setDeadlineDayOfWeek(5);
|
||||
}
|
||||
|
||||
setTitleFilters(bot.title_filters || []);
|
||||
setDefaultMemberIds(bot.default_member_ids || []);
|
||||
setExtractMembers(bot.extract_members_from_desc || false);
|
||||
|
||||
// 고급 설정이 있으면 펼침
|
||||
if ((bot.title_filters && bot.title_filters.length > 0) ||
|
||||
(bot.default_member_ids && bot.default_member_ids.length > 0) ||
|
||||
bot.extract_members_from_desc) {
|
||||
setShowAdvanced(true);
|
||||
} else {
|
||||
setShowAdvanced(false);
|
||||
}
|
||||
} else if (!botId) {
|
||||
// 추가 모드: 초기값으로 리셋
|
||||
setHandle('');
|
||||
setChannelInfo(null);
|
||||
setInterval(2);
|
||||
setAutoScheduleEnabled(false);
|
||||
setScheduleDayOfWeek(4);
|
||||
setScheduleTime('18:00');
|
||||
setTitleTemplate('{channelName} {episode}화');
|
||||
setDeadlineDayOfWeek(5);
|
||||
setShowAdvanced(false);
|
||||
setTitleFilters([]);
|
||||
setFilterInput('');
|
||||
setDefaultMemberIds([]);
|
||||
setExtractMembers(false);
|
||||
}
|
||||
}, [isOpen, bot, botId]);
|
||||
|
||||
// 채널 조회
|
||||
const handleLookup = async () => {
|
||||
if (!handle.trim()) return;
|
||||
setLookupLoading(true);
|
||||
try {
|
||||
const data = await lookupChannel(handle);
|
||||
setChannelInfo({
|
||||
channelId: data.channelId,
|
||||
title: data.title,
|
||||
thumbnailUrl: data.thumbnailUrl,
|
||||
bannerUrl: data.bannerUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('채널 조회 실패:', error);
|
||||
alert(error.message || '채널을 찾을 수 없습니다.');
|
||||
} finally {
|
||||
setLookupLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 제출
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!channelInfo) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const data = {
|
||||
channel_handle: handle || null,
|
||||
channel_name: channelInfo.title,
|
||||
cron_interval: interval,
|
||||
title_filters: titleFilters.length > 0 ? titleFilters : null,
|
||||
default_member_ids: defaultMemberIds.length > 0 ? defaultMemberIds : null,
|
||||
extract_members_from_desc: extractMembers,
|
||||
auto_schedule_config: autoScheduleEnabled
|
||||
? {
|
||||
dayOfWeek: scheduleDayOfWeek,
|
||||
time: `${scheduleTime}:00`,
|
||||
titleTemplate,
|
||||
deadlineDayOfWeek,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
await updateYouTubeBot(botId, data);
|
||||
} else {
|
||||
data.channel_id = channelInfo.channelId;
|
||||
await createYouTubeBot(data);
|
||||
}
|
||||
|
||||
// 캐시 무효화
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'bots'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'youtube-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-red-50 flex items-center justify-center">
|
||||
<Youtube size={20} className="text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-gray-900">
|
||||
{isEdit ? 'YouTube 봇 수정' : 'YouTube 봇 추가'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
{botLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center p-12">
|
||||
<Loader2 size={32} className="animate-spin text-red-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>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">@</span>
|
||||
<input
|
||||
type="text"
|
||||
value={handle}
|
||||
onChange={(e) => setHandle(e.target.value)}
|
||||
placeholder="studiofromis_9"
|
||||
disabled={isEdit}
|
||||
className="w-full pl-8 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500/20 focus:border-red-500 disabled:bg-gray-50 disabled:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
{!isEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLookup}
|
||||
disabled={lookupLoading || !handle.trim()}
|
||||
className="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{lookupLoading ? (
|
||||
<span className="w-4 h-4 border-2 border-gray-400/30 border-t-gray-400 rounded-full animate-spin" />
|
||||
) : (
|
||||
<Search size={18} />
|
||||
)}
|
||||
조회
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 채널 정보 표시 */}
|
||||
{channelInfo && (
|
||||
<div className="mt-2 bg-gray-50 rounded-lg overflow-hidden">
|
||||
{channelInfo.bannerUrl && (
|
||||
<div className="h-20 overflow-hidden">
|
||||
<img
|
||||
src={channelInfo.bannerUrl}
|
||||
alt="채널 배너"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3 flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
|
||||
<Youtube size={20} className="text-gray-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 truncate">{channelInfo.title}</p>
|
||||
<p className="text-xs text-gray-500">{channelInfo.channelId}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 동기화 간격 */}
|
||||
<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>
|
||||
|
||||
{/* 예정 일정 자동 생성 */}
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setAutoScheduleEnabled(!autoScheduleEnabled)}
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">예정 일정 자동 생성</p>
|
||||
<p className="text-sm text-gray-500">매주 특정 요일에 임시 일정을 미리 생성합니다</p>
|
||||
</div>
|
||||
<div
|
||||
className={`w-11 h-6 rounded-full transition-colors ${
|
||||
autoScheduleEnabled ? 'bg-red-500' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 bg-white rounded-full shadow-sm transform transition-transform mt-0.5 ${
|
||||
autoScheduleEnabled ? 'translate-x-5.5 ml-0.5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{autoScheduleEnabled && (
|
||||
<div className="p-4 pt-0 space-y-4 border-t border-gray-100">
|
||||
{/* 요일 & 시간 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">요일</label>
|
||||
<Dropdown
|
||||
value={scheduleDayOfWeek}
|
||||
options={DAY_OPTIONS}
|
||||
onChange={setScheduleDayOfWeek}
|
||||
placeholder="요일 선택"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">시간</label>
|
||||
<Dropdown
|
||||
value={scheduleTime}
|
||||
options={TIME_OPTIONS}
|
||||
onChange={setScheduleTime}
|
||||
placeholder="시간 선택"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 제목 템플릿 */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">제목 템플릿</label>
|
||||
<input
|
||||
type="text"
|
||||
value={titleTemplate}
|
||||
onChange={(e) => setTitleTemplate(e.target.value)}
|
||||
placeholder="{channelName} {episode}화"
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-red-500/20 focus:border-red-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{'{channelName}'}: 채널명, {'{episode}'}: 회차 번호
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 마감 요일 */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">마감 요일</label>
|
||||
<Dropdown
|
||||
value={deadlineDayOfWeek}
|
||||
options={DAY_OPTIONS}
|
||||
onChange={setDeadlineDayOfWeek}
|
||||
placeholder="요일 선택"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
이 요일까지 영상이 없으면 예정 일정을 삭제합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 고급 설정 */}
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
<span className="font-medium text-gray-700">고급 설정</span>
|
||||
{showAdvanced ? (
|
||||
<ChevronUp size={20} className="text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown size={20} className="text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="p-4 pt-0 space-y-4 border-t border-gray-100">
|
||||
{/* 제목 필터 */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">제목 필터</label>
|
||||
<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>
|
||||
|
||||
{/* 멤버 추출 */}
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
onClick={() => setExtractMembers(!extractMembers)}
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">설명에서 멤버 추출</p>
|
||||
<p className="text-xs text-gray-500">영상 설명에서 멤버 이름을 찾아 자동 연결</p>
|
||||
</div>
|
||||
<div
|
||||
className={`w-10 h-5 rounded-full transition-colors ${
|
||||
extractMembers ? 'bg-red-500' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-4 h-4 bg-white rounded-full shadow-sm transform transition-transform mt-0.5 ${
|
||||
extractMembers ? 'translate-x-5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 bg-gray-50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
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={!channelInfo || submitting || botLoading}
|
||||
className="px-4 py-2.5 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{submitting && <Loader2 size={16} className="animate-spin" />}
|
||||
{isEdit ? '수정' : '추가'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export default YouTubeBotDialog;
|
||||
|
|
@ -1 +1,3 @@
|
|||
export { default as BotCard, XIcon, MeilisearchIcon } from './BotCard';
|
||||
export { default as BotCard, XIcon, MeilisearchIcon, BotListItem, BotMiniCard, BotTableRow, BotTable } from './BotCard';
|
||||
export { default as YouTubeBotDialog } from './YouTubeBotDialog';
|
||||
export { default as XBotDialog } from './XBotDialog';
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
* - loadingText: 로딩 중 텍스트 (기본: "삭제 중...")
|
||||
* - variant: 버튼 색상 (기본: "danger", "primary" 가능)
|
||||
*/
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { AlertTriangle, Trash2 } from 'lucide-react';
|
||||
|
||||
|
|
@ -46,7 +47,7 @@ function ConfirmDialog({
|
|||
primary: 'text-primary',
|
||||
};
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
|
|
@ -108,7 +109,8 @@ function ConfirmDialog({
|
|||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ function DatePicker({
|
|||
placeholder = '날짜 선택',
|
||||
showDayOfWeek = false,
|
||||
minYear = 2000,
|
||||
min,
|
||||
max,
|
||||
compact = false,
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState('days');
|
||||
|
|
@ -132,6 +135,14 @@ function DatePicker({
|
|||
return today.getFullYear() === year && today.getMonth() === month && today.getDate() === day;
|
||||
};
|
||||
|
||||
const isDisabledDate = (day) => {
|
||||
if (!day) return true;
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
if (min && dateStr < min) return true;
|
||||
if (max && dateStr > max) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const currentMonth = new Date().getMonth();
|
||||
const isCurrentYear = (y) => currentYear === y;
|
||||
|
|
@ -179,12 +190,14 @@ function DatePicker({
|
|||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleButtonClick(e, () => setIsOpen(!isOpen))}
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
className={`w-full border border-gray-200 bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent ${
|
||||
compact ? 'px-4 py-2 rounded-lg' : 'px-4 py-3 rounded-xl'
|
||||
}`}
|
||||
>
|
||||
<span className={value ? 'text-gray-900' : 'text-gray-400'}>
|
||||
<span className={`${compact ? 'text-sm' : ''} ${value ? 'text-gray-900' : 'text-gray-400'}`}>
|
||||
{value ? formatDisplayDate(value) : placeholder}
|
||||
</span>
|
||||
<Calendar size={18} className="text-gray-400" />
|
||||
<Calendar size={compact ? 16 : 18} className="text-gray-400" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
|
|
@ -303,19 +316,20 @@ function DatePicker({
|
|||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((day, i) => {
|
||||
const dayOfWeek = i % 7;
|
||||
const disabled = isDisabledDate(day);
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
disabled={!day}
|
||||
onClick={(e) => day && handleButtonClick(e, () => selectDate(day))}
|
||||
disabled={!day || disabled}
|
||||
onClick={(e) => day && !disabled && handleButtonClick(e, () => selectDate(day))}
|
||||
className={`aspect-square rounded-full text-sm font-medium flex items-center justify-center transition-all
|
||||
${!day ? '' : 'hover:bg-gray-100'}
|
||||
${!day ? '' : disabled ? 'opacity-30 cursor-not-allowed' : 'hover:bg-gray-100'}
|
||||
${isSelected(day) ? 'bg-primary text-white hover:bg-primary' : ''}
|
||||
${isToday(day) && !isSelected(day) ? 'text-primary font-bold' : ''}
|
||||
${day && !isSelected(day) && !isToday(day) && dayOfWeek === 0 ? 'text-red-500' : ''}
|
||||
${day && !isSelected(day) && !isToday(day) && dayOfWeek === 6 ? 'text-blue-500' : ''}
|
||||
${day && !isSelected(day) && !isToday(day) && dayOfWeek > 0 && dayOfWeek < 6 ? 'text-gray-700' : ''}
|
||||
${isToday(day) && !isSelected(day) && !disabled ? 'text-primary font-bold' : ''}
|
||||
${day && !isSelected(day) && !isToday(day) && !disabled && dayOfWeek === 0 ? 'text-red-500' : ''}
|
||||
${day && !isSelected(day) && !isToday(day) && !disabled && dayOfWeek === 6 ? 'text-blue-500' : ''}
|
||||
${day && !isSelected(day) && !isToday(day) && !disabled && dayOfWeek > 0 && dayOfWeek < 6 ? 'text-gray-700' : ''}
|
||||
`}
|
||||
>
|
||||
{day}
|
||||
|
|
|
|||
289
frontend/src/components/pc/admin/common/VenueSearchDialog.jsx
Normal file
289
frontend/src/components/pc/admin/common/VenueSearchDialog.jsx
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
/**
|
||||
* 장소 검색 다이얼로그 컴포넌트
|
||||
* - 국내: 카카오맵 API
|
||||
* - 해외: 구글맵 API
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, Search, MapPin, Globe } from "lucide-react";
|
||||
import useAuthStore from "@/stores/useAuthStore";
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.isOpen - 다이얼로그 열림 여부
|
||||
* @param {Function} props.onClose - 닫기 핸들러
|
||||
* @param {Function} props.onSelect - 장소 선택 핸들러 ({ name, address, country, lat, lng })
|
||||
*/
|
||||
function VenueSearchDialog({ isOpen, onClose, onSelect }) {
|
||||
const [region, setRegion] = useState("domestic"); // domestic | overseas
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [results, setResults] = useState([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// 다이얼로그 닫기 시 상태 초기화
|
||||
const handleClose = () => {
|
||||
setSearchQuery("");
|
||||
setResults([]);
|
||||
setError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 지역 변경 시 결과 초기화
|
||||
const handleRegionChange = (newRegion) => {
|
||||
setRegion(newRegion);
|
||||
setResults([]);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
// 검색 실행
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = useAuthStore.getState().token;
|
||||
|
||||
if (region === "domestic") {
|
||||
// 카카오맵 API
|
||||
const response = await fetch(
|
||||
`/api/admin/kakao/places?query=${encodeURIComponent(searchQuery)}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const places = (data.documents || []).map((place) => ({
|
||||
id: place.id,
|
||||
name: place.place_name,
|
||||
address: place.road_address_name || place.address_name,
|
||||
country: "South Korea",
|
||||
lat: parseFloat(place.y),
|
||||
lng: parseFloat(place.x),
|
||||
category: place.category_name,
|
||||
}));
|
||||
setResults(places);
|
||||
} else {
|
||||
setError("검색 중 오류가 발생했습니다.");
|
||||
}
|
||||
} else {
|
||||
// 구글맵 API
|
||||
const response = await fetch(
|
||||
`/api/admin/google/places?query=${encodeURIComponent(searchQuery)}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const places = (data.results || []).map((place) => ({
|
||||
id: place.place_id,
|
||||
name: place.name,
|
||||
address: place.formatted_address,
|
||||
country: extractCountry(place.formatted_address),
|
||||
lat: place.geometry?.location?.lat,
|
||||
lng: place.geometry?.location?.lng,
|
||||
category: place.types?.[0]?.replace(/_/g, " "),
|
||||
}));
|
||||
setResults(places);
|
||||
} else {
|
||||
setError("검색 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("장소 검색 오류:", err);
|
||||
setError("검색 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 주소에서 국가 추출 (구글맵용)
|
||||
const extractCountry = (address) => {
|
||||
if (!address) return "";
|
||||
const parts = address.split(", ");
|
||||
return parts[parts.length - 1] || "";
|
||||
};
|
||||
|
||||
// 장소 선택
|
||||
const handleSelectPlace = (place) => {
|
||||
onSelect({
|
||||
name: place.name,
|
||||
address: place.address,
|
||||
country: place.country,
|
||||
lat: place.lat,
|
||||
lng: place.lng,
|
||||
});
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="bg-white rounded-2xl p-6 max-w-lg w-full mx-4 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-900">장소 검색</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 지역 선택 탭 */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRegionChange("domestic")}
|
||||
className={`flex-1 py-2.5 rounded-lg font-medium text-sm transition-colors flex items-center justify-center gap-2 ${
|
||||
region === "domestic"
|
||||
? "bg-primary text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
<MapPin size={16} />
|
||||
국내
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRegionChange("overseas")}
|
||||
className={`flex-1 py-2.5 rounded-lg font-medium text-sm transition-colors flex items-center justify-center gap-2 ${
|
||||
region === "overseas"
|
||||
? "bg-primary text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
<Globe size={16} />
|
||||
해외
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search
|
||||
size={18}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
region === "domestic"
|
||||
? "장소명을 입력하세요 (예: 올림픽홀)"
|
||||
: "장소명을 입력하세요 (예: Tokyo Dome)"
|
||||
}
|
||||
className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSearch}
|
||||
disabled={searching}
|
||||
className="px-4 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors disabled:opacity-50"
|
||||
>
|
||||
{searching ? (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{
|
||||
duration: 1,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
<Search size={18} />
|
||||
</motion.div>
|
||||
) : (
|
||||
"검색"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 text-red-600 text-sm rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 결과 */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{results.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{results.map((place) => (
|
||||
<button
|
||||
key={place.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectPlace(place)}
|
||||
className="w-full p-3 text-left hover:bg-gray-50 rounded-xl flex items-start gap-3 border border-gray-100 transition-colors"
|
||||
>
|
||||
<MapPin
|
||||
size={18}
|
||||
className="text-gray-400 mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900">{place.name}</p>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{place.address}
|
||||
</p>
|
||||
{place.category && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{place.category}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{region === "overseas" && place.country && (
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
{place.country}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<MapPin size={32} className="mx-auto mb-2 text-gray-300" />
|
||||
<p>장소명을 입력하고 검색해주세요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export default VenueSearchDialog;
|
||||
|
|
@ -12,3 +12,6 @@ export * from './album';
|
|||
|
||||
// 봇 관련
|
||||
export * from './bot';
|
||||
|
||||
// 로그 관련
|
||||
export * from './log';
|
||||
|
|
|
|||
124
frontend/src/components/pc/admin/log/LogDetailDialog.jsx
Normal file
124
frontend/src/components/pc/admin/log/LogDetailDialog.jsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* 로그 상세 다이얼로그
|
||||
*/
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, User, Bot } from 'lucide-react';
|
||||
import { ACTION_STYLES, ACTION_LABELS, CATEGORY_LABELS, parseSummary, formatDateTime, hasDetails } from './constants';
|
||||
|
||||
// 행위자 뱃지
|
||||
function ActorBadge({ actor }) {
|
||||
if (actor === 'admin') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 text-gray-700 text-xs font-medium rounded-full">
|
||||
<User size={12} />
|
||||
관리자
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-indigo-50 text-indigo-700 text-xs font-medium rounded-full">
|
||||
<Bot size={12} />
|
||||
{actor}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// summary 렌더링
|
||||
function Summary({ summary }) {
|
||||
const { prefix, detail } = parseSummary(summary);
|
||||
return (
|
||||
<>
|
||||
<span className="text-primary font-medium">[{prefix}]</span>
|
||||
{detail && <span className="ml-1.5">{detail}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { ActorBadge, Summary };
|
||||
|
||||
export default function LogDetailDialog({ log, onClose }) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{log && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/40"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="relative bg-white rounded-2xl shadow-xl max-w-lg w-full mx-4"
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`inline-block px-2.5 py-1 text-xs font-medium rounded-full ${ACTION_STYLES[log.action] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{ACTION_LABELS[log.action] || log.action}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{CATEGORY_LABELS[log.category] || log.category}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={18} className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-5 space-y-4">
|
||||
{/* 내용 */}
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-1.5">내용</div>
|
||||
<div className="text-sm text-gray-900 leading-relaxed">
|
||||
<Summary summary={log.summary} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 행위자 + 시간 */}
|
||||
<div className="flex gap-6">
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-1.5">행위자</div>
|
||||
<ActorBadge actor={log.actor} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-1.5">시간</div>
|
||||
<span className="text-sm text-gray-700 tabular-nums">{formatDateTime(log.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 대상 */}
|
||||
{(log.target_type || log.target_id) && (
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-1.5">대상</div>
|
||||
<span className="text-sm text-gray-700">
|
||||
{log.target_type && <span>{log.target_type}</span>}
|
||||
{log.target_id && <span className="ml-1.5 text-gray-400">#{log.target_id}</span>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 상세 정보 */}
|
||||
{hasDetails(log.details) && (
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-1.5">상세 정보</div>
|
||||
<pre className="text-xs text-gray-600 bg-gray-50 rounded-lg p-3 overflow-auto max-h-40">
|
||||
{JSON.stringify(log.details, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
73
frontend/src/components/pc/admin/log/constants.js
Normal file
73
frontend/src/components/pc/admin/log/constants.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* 활동 로그 상수 및 유틸리티
|
||||
*/
|
||||
|
||||
// 카테고리 한글 라벨 매핑
|
||||
export const CATEGORY_LABELS = {
|
||||
album: '앨범',
|
||||
schedule: '일정',
|
||||
member: '멤버',
|
||||
bot: '봇',
|
||||
category: '카테고리',
|
||||
dict: '사전',
|
||||
concert: '콘서트',
|
||||
sync: '동기화',
|
||||
};
|
||||
|
||||
// 액션 뱃지 색상
|
||||
export const ACTION_STYLES = {
|
||||
create: 'bg-emerald-100 text-emerald-700',
|
||||
upload: 'bg-emerald-100 text-emerald-700',
|
||||
update: 'bg-blue-100 text-blue-700',
|
||||
delete: 'bg-red-100 text-red-700',
|
||||
sync_complete: 'bg-purple-100 text-purple-700',
|
||||
error: 'bg-red-100 text-red-700',
|
||||
start: 'bg-amber-100 text-amber-700',
|
||||
stop: 'bg-amber-100 text-amber-700',
|
||||
};
|
||||
|
||||
// 액션 한글 라벨
|
||||
export const ACTION_LABELS = {
|
||||
create: '생성',
|
||||
upload: '업로드',
|
||||
update: '수정',
|
||||
delete: '삭제',
|
||||
sync_complete: '동기화',
|
||||
error: '에러',
|
||||
start: '시작',
|
||||
stop: '정지',
|
||||
};
|
||||
|
||||
export const ITEMS_PER_PAGE = 15;
|
||||
|
||||
// HTML 엔티티 디코딩
|
||||
export function decodeHtml(str) {
|
||||
if (!str) return '';
|
||||
const el = document.createElement('textarea');
|
||||
el.innerHTML = str;
|
||||
return el.value;
|
||||
}
|
||||
|
||||
// summary를 prefix와 detail로 분리
|
||||
export function parseSummary(summary) {
|
||||
const decoded = decodeHtml(summary);
|
||||
const idx = decoded.indexOf(': ');
|
||||
if (idx === -1) return { prefix: decoded, detail: '' };
|
||||
return { prefix: decoded.substring(0, idx), detail: decoded.substring(idx + 2) };
|
||||
}
|
||||
|
||||
// 날짜/시간 포맷 (DB에 KST로 저장되어 있으므로 UTC 기준으로 읽음)
|
||||
export function formatDateTime(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
const y = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
||||
return `${y}.${month}.${day} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// details가 유효한 데이터인지 확인
|
||||
export function hasDetails(details) {
|
||||
return details && typeof details === 'object' && Object.keys(details).length > 0;
|
||||
}
|
||||
2
frontend/src/components/pc/admin/log/index.js
Normal file
2
frontend/src/components/pc/admin/log/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './constants';
|
||||
export { default as LogDetailDialog, ActorBadge, Summary } from './LogDetailDialog';
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Search, MapPin } from 'lucide-react';
|
||||
import useAuthStore from '@/stores/useAuthStore';
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
|
|
@ -33,7 +34,7 @@ function LocationSearchDialog({ isOpen, onClose, onSelect }) {
|
|||
|
||||
setSearching(true);
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const token = useAuthStore.getState().token;
|
||||
const response = await fetch(`/api/admin/kakao/places?query=${encodeURIComponent(searchQuery)}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ function WordItem({ id, word, pos, index, onUpdate, onDelete }) {
|
|||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
className="absolute top-full left-0 mt-1 w-64 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-20"
|
||||
className="absolute top-full left-0 mt-1 w-64 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-40"
|
||||
>
|
||||
{POS_TAGS.map((tag) => (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -54,15 +54,11 @@ function MobileMembers() {
|
|||
}
|
||||
}, [allMembers.length]);
|
||||
|
||||
// 현재/전 멤버 분리
|
||||
// 현재 멤버만 표시
|
||||
const currentMembers = useMemo(
|
||||
() => allMembers.filter((m) => !m.is_former),
|
||||
[allMembers]
|
||||
);
|
||||
const formerMembers = useMemo(
|
||||
() => allMembers.filter((m) => m.is_former),
|
||||
[allMembers]
|
||||
);
|
||||
|
||||
// 나이 계산
|
||||
const calculateAge = (birthDate) => {
|
||||
|
|
@ -105,28 +101,6 @@ function MobileMembers() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* 전 멤버 섹션 */}
|
||||
{formerMembers.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 my-6">
|
||||
<div className="flex-1 h-px bg-gray-300" />
|
||||
<span className="text-gray-400 text-sm font-medium">전 멤버</span>
|
||||
<div className="flex-1 h-px bg-gray-300" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{formerMembers.map((member, index) => (
|
||||
<MemberCard
|
||||
key={member.id}
|
||||
member={member}
|
||||
index={index}
|
||||
onClick={() => setSelectedMember(member)}
|
||||
shouldAnimate={shouldAnimate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 선택된 멤버 모달 */}
|
||||
<AnimatePresence>
|
||||
{selectedMember && (
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
BirthdayCard as MobileBirthdayCard,
|
||||
DebutCard as MobileDebutCard,
|
||||
} from '@/components/mobile';
|
||||
import { DebutCelebrationDialog } from '@/components/common';
|
||||
import { DebutCelebrationDialog, BirthdayCelebrationDialog } from '@/components/common';
|
||||
import { fireBirthdayConfetti, fireDebutConfetti } from '@/utils';
|
||||
|
||||
/**
|
||||
|
|
@ -55,6 +55,8 @@ function MobileSchedule() {
|
|||
const [showSuggestionsScreen, setShowSuggestionsScreen] = useState(false);
|
||||
const [showDebutDialog, setShowDebutDialog] = useState(false);
|
||||
const [debutDialogInfo, setDebutDialogInfo] = useState({ isDebut: false, anniversaryYear: 0 });
|
||||
const [showBirthdayDialog, setShowBirthdayDialog] = useState(false);
|
||||
const [birthdayInfo, setBirthdayInfo] = useState({ title: '', memberImage: '', date: '' });
|
||||
|
||||
// 검색 모드 진입/종료
|
||||
const enterSearchMode = () => {
|
||||
|
|
@ -194,8 +196,19 @@ function MobileSchedule() {
|
|||
});
|
||||
|
||||
if (hasBirthdayToday) {
|
||||
const birthdaySchedule = schedules.find((s) => {
|
||||
if (!s.is_birthday) return false;
|
||||
const scheduleDate = s.date ? s.date.split('T')[0] : '';
|
||||
return scheduleDate === today;
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
fireBirthdayConfetti();
|
||||
setBirthdayInfo({
|
||||
title: birthdaySchedule?.title || '',
|
||||
memberImage: birthdaySchedule?.member_image || '',
|
||||
date: birthdaySchedule?.date || '',
|
||||
});
|
||||
setShowBirthdayDialog(true);
|
||||
localStorage.setItem(confettiKey, 'true');
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
|
|
@ -813,6 +826,14 @@ function MobileSchedule() {
|
|||
isDebut={debutDialogInfo.isDebut}
|
||||
anniversaryYear={debutDialogInfo.anniversaryYear}
|
||||
/>
|
||||
{/* 생일 축하 다이얼로그 */}
|
||||
<BirthdayCelebrationDialog
|
||||
isOpen={showBirthdayDialog}
|
||||
onClose={() => setShowBirthdayDialog(false)}
|
||||
title={birthdayInfo.title}
|
||||
memberImage={birthdayInfo.memberImage}
|
||||
date={birthdayInfo.date}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,47 @@ import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
|||
import { useEffect, useState, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight } from 'lucide-react';
|
||||
import Linkify from 'react-linkify';
|
||||
import { getSchedule } from '@/api';
|
||||
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils';
|
||||
import Birthday from './Birthday';
|
||||
|
||||
/**
|
||||
* URL을 링크로 변환하는 함수
|
||||
*/
|
||||
function linkifyText(text) {
|
||||
if (!text) return null;
|
||||
|
||||
// URL 패턴: http(s)://로 시작하거나 일반적인 단축 URL 도메인
|
||||
const urlPattern = /(https?:\/\/[^\s]+|(?:bit\.ly|youtu\.be|t\.co|goo\.gl|tinyurl\.com)\/[^\s]+)/gi;
|
||||
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = urlPattern.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
let url = match[0];
|
||||
const href = url.startsWith('http') ? url : `https://${url}`;
|
||||
|
||||
parts.push(
|
||||
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특수 일정 ID 파싱
|
||||
* @param {string} id - 일정 ID
|
||||
|
|
@ -74,33 +110,69 @@ function useFullscreenOrientation(isShorts) {
|
|||
}, [isShorts]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile 예정 일정 Placeholder 컴포넌트
|
||||
*/
|
||||
function MobileScheduledPlaceholder({ bannerUrl }) {
|
||||
return (
|
||||
<div className="relative aspect-video bg-gradient-to-br from-gray-800 to-gray-900 rounded-xl overflow-hidden shadow-lg">
|
||||
{/* 배경: 배너 이미지 또는 패턴 */}
|
||||
{bannerUrl ? (
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${bannerUrl})` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 하단 텍스트 */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||
<div className="flex items-center gap-2 text-white/90">
|
||||
<Clock size={16} className="text-amber-400" />
|
||||
<span className="text-base font-medium">업로드 예정</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile 유튜브 섹션
|
||||
*/
|
||||
function MobileYoutubeSection({ schedule }) {
|
||||
const videoId = schedule.videoId;
|
||||
const isShorts = schedule.videoType === 'shorts';
|
||||
const isScheduled = !videoId; // videoId가 없으면 예정 일정
|
||||
|
||||
// 숏츠가 아닐 때만 가로 회전 (숏츠는 전체화면에서 세로 유지)
|
||||
useFullscreenOrientation(isShorts);
|
||||
const members = schedule.members || [];
|
||||
const isFullGroup = members.length === 5;
|
||||
|
||||
if (!videoId) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 영상 임베드 - 숏츠도 가로 비율로 표시 (전체화면에서는 유튜브가 세로로 처리) */}
|
||||
{/* 영상 임베드 또는 예정 Placeholder */}
|
||||
<motion.div initial={{ opacity: 0, scale: 0.98 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.1 }}>
|
||||
<div className="relative bg-gray-900 rounded-xl overflow-hidden shadow-lg aspect-video">
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${videoId}?rel=0`}
|
||||
title={schedule.title}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen; web-share"
|
||||
allowFullScreen
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
{isScheduled ? (
|
||||
<MobileScheduledPlaceholder bannerUrl={schedule.bannerUrl} />
|
||||
) : (
|
||||
<div className="relative bg-gray-900 rounded-xl overflow-hidden shadow-lg aspect-video">
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${videoId}?rel=0`}
|
||||
title={schedule.title}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen; web-share"
|
||||
allowFullScreen
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 영상 정보 */}
|
||||
|
|
@ -110,10 +182,17 @@ function MobileYoutubeSection({ schedule }) {
|
|||
transition={{ delay: 0.2 }}
|
||||
className="bg-gradient-to-br from-gray-100 to-gray-200/80 rounded-xl p-4"
|
||||
>
|
||||
<h1 className="font-bold text-gray-900 text-base leading-relaxed mb-3">{decodeHtmlEntities(schedule.title)}</h1>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h1 className="font-bold text-gray-900 text-base leading-relaxed">{decodeHtmlEntities(schedule.title)}</h1>
|
||||
{isScheduled && (
|
||||
<span className="flex-shrink-0 px-2 py-0.5 bg-amber-100 text-amber-700 text-xs font-semibold rounded-full">
|
||||
예정
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 메타 정보 */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 mb-3">
|
||||
<div className={`flex flex-wrap items-center gap-3 text-xs text-gray-500 ${members.length > 0 || !isScheduled ? 'mb-3' : ''}`}>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar size={12} />
|
||||
<span>{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>
|
||||
|
|
@ -143,20 +222,22 @@ function MobileYoutubeSection({ schedule }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 유튜브에서 보기 버튼 */}
|
||||
<div className="pt-4 border-t border-gray-300/50">
|
||||
<a
|
||||
href={schedule.videoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-red-500 active:bg-red-600 text-white rounded-xl font-medium transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||
</svg>
|
||||
YouTube에서 보기
|
||||
</a>
|
||||
</div>
|
||||
{/* 유튜브에서 보기 버튼 (예정 일정이 아닐 때만) */}
|
||||
{!isScheduled && (
|
||||
<div className="pt-4 border-t border-gray-300/50">
|
||||
<a
|
||||
href={schedule.videoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-red-500 active:bg-red-600 text-white rounded-xl font-medium transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||
</svg>
|
||||
YouTube에서 보기
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -228,12 +309,6 @@ function MobileXSection({ schedule }) {
|
|||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [lightboxOpen]);
|
||||
|
||||
// 링크 데코레이터
|
||||
const linkDecorator = (href, text, key) => (
|
||||
<a key={key} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500">
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -268,7 +343,7 @@ function MobileXSection({ schedule }) {
|
|||
{/* 본문 */}
|
||||
<div className="p-4">
|
||||
<p className="text-gray-900 text-[15px] leading-relaxed whitespace-pre-wrap">
|
||||
<Linkify componentDecorator={linkDecorator}>{decodeHtmlEntities(schedule.content || schedule.title)}</Linkify>
|
||||
{linkifyText(decodeHtmlEntities(schedule.content || schedule.title))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
import { useAdminAuth } from '@/hooks/pc/admin';
|
||||
import { useToast } from '@/hooks/common';
|
||||
import { adminAlbumApi, adminMemberApi } from '@/api/admin';
|
||||
import useAuthStore from '@/stores/useAuthStore';
|
||||
|
||||
function AdminAlbumPhotos() {
|
||||
const { albumId } = useParams();
|
||||
|
|
@ -337,7 +338,7 @@ function AdminAlbumPhotos() {
|
|||
setProcessingStatus('');
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const token = useAuthStore.getState().token;
|
||||
|
||||
const formData = new FormData();
|
||||
const metadata = pendingFiles.map((pf) => ({
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Disc3, Calendar, Users, Home, ChevronRight } from 'lucide-react';
|
||||
import { Disc3, Calendar, Users, Home, ChevronRight, ScrollText } from 'lucide-react';
|
||||
import { AdminLayout } from '@/components/pc/admin';
|
||||
import { useAdminAuth } from '@/hooks/pc/admin';
|
||||
import { adminStatsApi } from '@/api/admin';
|
||||
|
|
@ -88,6 +88,13 @@ function AdminDashboard() {
|
|||
path: '/admin/schedule',
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
icon: ScrollText,
|
||||
label: '활동 로그',
|
||||
description: '관리자 및 봇 활동 기록 조회',
|
||||
path: '/admin/logs',
|
||||
color: 'bg-gray-500',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
|||
402
frontend/src/pages/pc/admin/logs/Logs.jsx
Normal file
402
frontend/src/pages/pc/admin/logs/Logs.jsx
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
/**
|
||||
* 관리자 활동 로그 페이지
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Home, ChevronRight, Search, ChevronLeft, ChevronDown,
|
||||
X, Loader2, Check, ScrollText,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AdminLayout, DatePicker,
|
||||
CATEGORY_LABELS, ACTION_STYLES, ACTION_LABELS, ITEMS_PER_PAGE, formatDateTime,
|
||||
LogDetailDialog, ActorBadge, Summary,
|
||||
} from '@/components/pc/admin';
|
||||
import { useAdminAuth } from '@/hooks/pc/admin';
|
||||
import { adminLogApi } from '@/api/admin';
|
||||
|
||||
function Logs() {
|
||||
const { user } = useAdminAuth();
|
||||
|
||||
// 필터 상태
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategories, setSelectedCategories] = useState([]);
|
||||
const [actorFilter, setActorFilter] = useState('all');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [actorDropdownOpen, setActorDropdownOpen] = useState(false);
|
||||
const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false);
|
||||
const [selectedLog, setSelectedLog] = useState(null);
|
||||
|
||||
// 검색어 디바운스
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(searchQuery), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
// 카테고리 목록 조회
|
||||
const { data: categoryData } = useQuery({
|
||||
queryKey: ['admin', 'logs', 'categories'],
|
||||
queryFn: () => adminLogApi.getLogCategories(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const categories = categoryData?.categories || [];
|
||||
|
||||
// 로그 API 호출
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'logs', { page: currentPage, category: selectedCategories.join(','), actor: actorFilter === 'all' ? '' : actorFilter, search: debouncedSearch, from: dateFrom, to: dateTo }],
|
||||
queryFn: () => adminLogApi.getLogs({
|
||||
page: currentPage,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
category: selectedCategories.join(',') || undefined,
|
||||
actor: actorFilter === 'all' ? undefined : actorFilter,
|
||||
search: debouncedSearch || undefined,
|
||||
from: dateFrom || undefined,
|
||||
to: dateTo || undefined,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const logs = data?.logs || [];
|
||||
const total = data?.total || 0;
|
||||
const totalPages = data?.totalPages || 0;
|
||||
|
||||
// 카테고리 토글
|
||||
const toggleCategory = (cat) => {
|
||||
setSelectedCategories((prev) =>
|
||||
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
|
||||
);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// 카테고리 드롭다운 버튼 텍스트
|
||||
const getCategoryButtonText = () => {
|
||||
if (selectedCategories.length === 0) return '전체 카테고리';
|
||||
if (selectedCategories.length === 1) return CATEGORY_LABELS[selectedCategories[0]] || selectedCategories[0];
|
||||
return `카테고리 (${selectedCategories.length})`;
|
||||
};
|
||||
|
||||
// 필터 초기화
|
||||
const clearFilters = () => {
|
||||
setSearchQuery('');
|
||||
setSelectedCategories([]);
|
||||
setActorFilter('all');
|
||||
setDateFrom('');
|
||||
setDateTo('');
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchQuery || selectedCategories.length > 0 || actorFilter !== 'all' || dateFrom || dateTo;
|
||||
|
||||
return (
|
||||
<AdminLayout user={user}>
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
{/* 브레드크럼 */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 mb-8">
|
||||
<Link to="/admin/dashboard" className="hover:text-primary transition-colors">
|
||||
<Home size={16} />
|
||||
</Link>
|
||||
<ChevronRight size={14} />
|
||||
<span className="text-gray-700">활동 로그</span>
|
||||
</div>
|
||||
|
||||
{/* 타이틀 */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">활동 로그</h1>
|
||||
<p className="text-gray-500">모든 관리자 및 봇 활동 기록을 확인합니다</p>
|
||||
</div>
|
||||
|
||||
{/* 필터 영역 */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 검색 */}
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => { setSearchQuery(e.target.value); setCurrentPage(1); }}
|
||||
placeholder="로그 검색..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 행위자 드롭다운 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => { setActorDropdownOpen(!actorDropdownOpen); setCategoryDropdownOpen(false); }}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span className="text-gray-600">
|
||||
{actorFilter === 'all' ? '전체 행위자' : actorFilter === 'admin' ? '관리자' : '봇'}
|
||||
</span>
|
||||
<ChevronDown size={16} className="text-gray-400" />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{actorDropdownOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setActorDropdownOpen(false)} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute top-full left-0 mt-1 w-36 bg-white border border-gray-200 rounded-lg shadow-lg z-20 py-1"
|
||||
>
|
||||
{[
|
||||
{ value: 'all', label: '전체 행위자' },
|
||||
{ value: 'admin', label: '관리자' },
|
||||
{ value: 'bot', label: '봇' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => { setActorFilter(opt.value); setActorDropdownOpen(false); setCurrentPage(1); }}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-50 transition-colors ${
|
||||
actorFilter === opt.value ? 'text-primary font-medium' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 드롭다운 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => categories.length > 0 && (setCategoryDropdownOpen(!categoryDropdownOpen), setActorDropdownOpen(false))}
|
||||
disabled={categories.length === 0}
|
||||
className={`flex items-center gap-2 px-4 py-2 border rounded-lg text-sm transition-colors ${
|
||||
categories.length === 0
|
||||
? 'border-gray-200 text-gray-400 cursor-not-allowed'
|
||||
: selectedCategories.length > 0
|
||||
? 'border-primary text-primary hover:bg-gray-50'
|
||||
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<span>{getCategoryButtonText()}</span>
|
||||
<ChevronDown size={16} className={selectedCategories.length > 0 ? 'text-primary' : 'text-gray-400'} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{categoryDropdownOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setCategoryDropdownOpen(false)} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute top-full left-0 mt-1 w-44 bg-white border border-gray-200 rounded-lg shadow-lg z-20 py-1"
|
||||
>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => toggleCategory(cat)}
|
||||
className="w-full flex items-center gap-2.5 px-4 py-2 text-sm hover:bg-gray-50 transition-colors text-gray-700"
|
||||
>
|
||||
<span className={`w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
|
||||
selectedCategories.includes(cat) ? 'bg-primary border-primary' : 'border-gray-300'
|
||||
}`}>
|
||||
{selectedCategories.includes(cat) && <Check size={12} className="text-white" />}
|
||||
</span>
|
||||
{CATEGORY_LABELS[cat] || cat}
|
||||
</button>
|
||||
))}
|
||||
{selectedCategories.length > 0 && (
|
||||
<>
|
||||
<div className="border-t border-gray-100 my-1" />
|
||||
<button
|
||||
onClick={() => { setSelectedCategories([]); setCurrentPage(1); }}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-400 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
선택 해제
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* 날짜 필터 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-40">
|
||||
<DatePicker
|
||||
value={dateFrom}
|
||||
onChange={(v) => { setDateFrom(v); setCurrentPage(1); }}
|
||||
placeholder="시작일"
|
||||
max={dateTo || undefined}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<span className="text-gray-400 text-sm">~</span>
|
||||
<div className="w-40">
|
||||
<DatePicker
|
||||
value={dateTo}
|
||||
onChange={(v) => { setDateTo(v); setCurrentPage(1); }}
|
||||
placeholder="종료일"
|
||||
min={dateFrom || undefined}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 초기화 */}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-1 px-2 py-2 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
초기화
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 결과 개수 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
총 <span className="font-medium text-gray-900">{total}</span>개의 로그
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 로그 테이블 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1], delay: 0.15 }}
|
||||
className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden"
|
||||
>
|
||||
<table className="w-full table-fixed">
|
||||
<thead className="bg-gray-50 border-b border-gray-100">
|
||||
<tr>
|
||||
<th className="text-left pl-4 pr-2 py-4 text-sm font-medium text-gray-500 whitespace-nowrap w-[15%]">시간</th>
|
||||
<th className="text-left px-3 py-4 text-sm font-medium text-gray-500 whitespace-nowrap w-[15%]">행위자</th>
|
||||
<th className="text-left px-3 py-4 text-sm font-medium text-gray-500 whitespace-nowrap w-[10%]">액션</th>
|
||||
<th className="text-left px-3 py-4 text-sm font-medium text-gray-500 whitespace-nowrap w-[10%]">카테고리</th>
|
||||
<th className="text-left pl-3 pr-6 py-4 text-sm font-medium text-gray-500">내용</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{logs.map((log, index) => (
|
||||
<motion.tr
|
||||
key={log.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<td className="pl-4 pr-2 py-3.5 text-sm text-gray-500 tabular-nums whitespace-nowrap">
|
||||
{formatDateTime(log.created_at)}
|
||||
</td>
|
||||
<td className="px-3 py-3.5 whitespace-nowrap">
|
||||
<ActorBadge actor={log.actor} />
|
||||
</td>
|
||||
<td className="px-3 py-3.5 whitespace-nowrap">
|
||||
<span className={`inline-block px-2.5 py-1 text-xs font-medium rounded-full ${ACTION_STYLES[log.action] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{ACTION_LABELS[log.action] || log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-3.5 whitespace-nowrap">
|
||||
<span className="text-xs text-gray-500">
|
||||
{CATEGORY_LABELS[log.category] || log.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="pl-3 pr-6 py-3.5 text-sm text-gray-700">
|
||||
<div
|
||||
onClick={() => setSelectedLog(log)}
|
||||
className="truncate cursor-pointer hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<Summary summary={log.summary} />
|
||||
</div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{isLoading && logs.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<Loader2 size={32} className="animate-spin mb-4" />
|
||||
<p className="text-sm">로그를 불러오는 중...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && logs.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<ScrollText size={48} strokeWidth={1} className="mb-4" />
|
||||
<p className="text-sm">
|
||||
{hasActiveFilters ? '검색 결과가 없습니다.' : '활동 로그가 없습니다.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter((page) => {
|
||||
if (totalPages <= 7) return true;
|
||||
if (page === 1 || page === totalPages) return true;
|
||||
if (Math.abs(page - currentPage) <= 2) return true;
|
||||
return false;
|
||||
})
|
||||
.reduce((acc, page, i, arr) => {
|
||||
if (i > 0 && page - arr[i - 1] > 1) {
|
||||
acc.push({ type: 'ellipsis', key: `e-${page}` });
|
||||
}
|
||||
acc.push({ type: 'page', value: page, key: page });
|
||||
return acc;
|
||||
}, [])
|
||||
.map((item) =>
|
||||
item.type === 'ellipsis' ? (
|
||||
<span key={item.key} className="w-9 h-9 flex items-center justify-center text-sm text-gray-400">...</span>
|
||||
) : (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => setCurrentPage(item.value)}
|
||||
className={`w-9 h-9 rounded-lg text-sm font-medium transition-colors ${
|
||||
currentPage === item.value
|
||||
? 'bg-primary text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{item.value}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 로그 상세 다이얼로그 */}
|
||||
<LogDetailDialog log={selectedLog} onClose={() => setSelectedLog(null)} />
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default Logs;
|
||||
|
|
@ -1,14 +1,41 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
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 } from 'lucide-react';
|
||||
import { Home, ChevronRight, Bot, CheckCircle, XCircle, RefreshCw, Plus, Youtube } from 'lucide-react';
|
||||
import { Toast, Tooltip, AnimatedNumber } from '@/components/common';
|
||||
import { AdminLayout, BotCard } from '@/components/pc/admin';
|
||||
import { AdminLayout, XIcon, MeilisearchIcon, BotTableRow, BotTable, YouTubeBotDialog, XBotDialog } from '@/components/pc/admin';
|
||||
import { useAdminAuth } from '@/hooks/pc/admin';
|
||||
import { useToast } from '@/hooks/common';
|
||||
import * as botsApi from '@/api/admin/bots';
|
||||
|
||||
// 섹션 설정
|
||||
const SECTIONS = {
|
||||
meilisearch: {
|
||||
title: 'Meilisearch',
|
||||
icon: MeilisearchIcon,
|
||||
color: 'text-pink-500',
|
||||
bgColor: 'bg-pink-50',
|
||||
borderColor: 'border-pink-100',
|
||||
},
|
||||
youtube: {
|
||||
title: 'YouTube',
|
||||
icon: Youtube,
|
||||
color: 'text-red-500',
|
||||
bgColor: 'bg-red-50',
|
||||
borderColor: 'border-red-100',
|
||||
canAdd: true,
|
||||
},
|
||||
x: {
|
||||
title: 'X',
|
||||
icon: XIcon,
|
||||
color: 'text-gray-700',
|
||||
bgColor: 'bg-gray-50',
|
||||
borderColor: 'border-gray-200',
|
||||
canAdd: true,
|
||||
},
|
||||
};
|
||||
|
||||
// 애니메이션 variants
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
|
|
@ -34,6 +61,11 @@ function ScheduleBots() {
|
|||
const [isInitialLoad, setIsInitialLoad] = useState(true); // 첫 로드 여부 (애니메이션용)
|
||||
const [syncing, setSyncing] = useState(null); // 동기화 중인 봇 ID
|
||||
const [quotaWarning, setQuotaWarning] = useState(null); // 할당량 경고 상태
|
||||
const [youtubeDialogOpen, setYoutubeDialogOpen] = useState(false); // YouTube 봇 다이얼로그
|
||||
const [xDialogOpen, setXDialogOpen] = useState(false); // X 봇 다이얼로그
|
||||
const [editingBotId, setEditingBotId] = useState(null); // 수정 중인 봇 DB ID
|
||||
const [editingBotType, setEditingBotType] = useState(null); // 수정 중인 봇 타입
|
||||
const [deletingBot, setDeletingBot] = useState(null); // 삭제할 봇
|
||||
|
||||
// 봇 목록 조회
|
||||
const {
|
||||
|
|
@ -45,7 +77,7 @@ function ScheduleBots() {
|
|||
queryKey: ['admin', 'bots'],
|
||||
queryFn: botsApi.getBots,
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 30000,
|
||||
staleTime: 0, // 항상 fresh 데이터
|
||||
});
|
||||
|
||||
// 할당량 경고 상태 조회
|
||||
|
|
@ -127,6 +159,26 @@ function ScheduleBots() {
|
|||
}
|
||||
};
|
||||
|
||||
// 봇 삭제
|
||||
const handleDeleteBot = async () => {
|
||||
if (!deletingBot) return;
|
||||
|
||||
try {
|
||||
if (deletingBot.type === 'youtube') {
|
||||
await botsApi.deleteYouTubeBot(deletingBot.db_id);
|
||||
} else if (deletingBot.type === 'x') {
|
||||
await botsApi.deleteXBot(deletingBot.db_id);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'bots'] });
|
||||
setToast({ type: 'success', message: `${deletingBot.name} 봇이 삭제되었습니다.` });
|
||||
} catch (error) {
|
||||
console.error('봇 삭제 오류:', error);
|
||||
setToast({ type: 'error', message: error.message || '봇 삭제에 실패했습니다.' });
|
||||
} finally {
|
||||
setDeletingBot(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 상태 아이콘 및 색상
|
||||
const getStatusInfo = (status) => {
|
||||
switch (status) {
|
||||
|
|
@ -195,9 +247,90 @@ function ScheduleBots() {
|
|||
const stoppedCount = bots.filter((b) => b.status === 'stopped').length;
|
||||
const errorCount = bots.filter((b) => b.status === 'error').length;
|
||||
|
||||
// 봇을 타입별로 그룹화
|
||||
const botsByType = useMemo(() => {
|
||||
const grouped = { meilisearch: [], youtube: [], x: [] };
|
||||
bots.forEach((bot) => {
|
||||
if (grouped[bot.type]) {
|
||||
grouped[bot.type].push(bot);
|
||||
}
|
||||
});
|
||||
return grouped;
|
||||
}, [bots]);
|
||||
|
||||
return (
|
||||
<AdminLayout user={user}>
|
||||
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||
<YouTubeBotDialog
|
||||
isOpen={youtubeDialogOpen}
|
||||
onClose={() => {
|
||||
setYoutubeDialogOpen(false);
|
||||
setEditingBotId(null);
|
||||
setEditingBotType(null);
|
||||
}}
|
||||
botId={editingBotId}
|
||||
onSuccess={() => {
|
||||
setToast({ type: 'success', message: editingBotId ? '봇이 수정되었습니다.' : '봇이 추가되었습니다.' });
|
||||
}}
|
||||
/>
|
||||
<XBotDialog
|
||||
isOpen={xDialogOpen}
|
||||
onClose={() => {
|
||||
setXDialogOpen(false);
|
||||
setEditingBotId(null);
|
||||
setEditingBotType(null);
|
||||
}}
|
||||
botId={editingBotId}
|
||||
onSuccess={() => {
|
||||
setToast({ type: 'success', message: editingBotId ? '봇이 수정되었습니다.' : '봇이 추가되었습니다.' });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AnimatePresence>
|
||||
{deletingBot && (
|
||||
<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-sm mx-4 p-6 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<XCircle size={20} className="text-red-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900">봇 삭제</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 mb-6">
|
||||
<strong>{deletingBot.name}</strong> 봇을 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-sm text-gray-400">이 작업은 되돌릴 수 없습니다.</span>
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setDeletingBot(null)}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteBot}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<motion.div
|
||||
|
|
@ -281,56 +414,117 @@ function ScheduleBots() {
|
|||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 봇 목록 */}
|
||||
<motion.div variants={itemVariants} className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<h2 className="font-bold text-gray-900">봇 목록</h2>
|
||||
<Tooltip text="새로고침">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsInitialLoad(true);
|
||||
fetchBots();
|
||||
}}
|
||||
disabled={loading}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-500 hover:text-gray-700 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* 로딩 상태 */}
|
||||
{loading ? (
|
||||
<motion.div variants={itemVariants} className="flex justify-center items-center py-20">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-4 border-primary border-t-transparent"></div>
|
||||
</motion.div>
|
||||
) : bots.length === 0 ? (
|
||||
<motion.div variants={itemVariants} className="text-center py-20 text-gray-400">
|
||||
<Bot size={48} className="mx-auto mb-4 opacity-30" />
|
||||
<p>등록된 봇이 없습니다</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
/* 섹션별 봇 목록 */
|
||||
<div className="space-y-6">
|
||||
{Object.entries(SECTIONS).map(([type, section]) => {
|
||||
const sectionBots = botsByType[type] || [];
|
||||
if (sectionBots.length === 0 && !section.canAdd) return null;
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-4 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
) : bots.length === 0 ? (
|
||||
<div className="text-center py-20 text-gray-400">
|
||||
<Bot size={48} className="mx-auto mb-4 opacity-30" />
|
||||
<p>등록된 봇이 없습니다</p>
|
||||
<p className="text-sm mt-1">위의 버튼을 클릭하여 봇을 추가하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{bots.map((bot, index) => (
|
||||
<BotCard
|
||||
key={bot.id}
|
||||
bot={bot}
|
||||
index={index}
|
||||
isInitialLoad={isInitialLoad}
|
||||
syncing={syncing}
|
||||
statusInfo={getStatusInfo(bot.status)}
|
||||
onSync={handleSyncAllVideos}
|
||||
onToggle={toggleBot}
|
||||
onAnimationComplete={() =>
|
||||
isInitialLoad && index === bots.length - 1 && setIsInitialLoad(false)
|
||||
}
|
||||
formatTime={formatTime}
|
||||
formatInterval={formatInterval}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
const SectionIcon = section.icon;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={type}
|
||||
variants={itemVariants}
|
||||
className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden"
|
||||
>
|
||||
{/* 섹션 헤더 */}
|
||||
<div className={`px-6 py-4 border-b ${section.borderColor} ${section.bgColor} flex items-center justify-between`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-lg ${section.bgColor} flex items-center justify-center`}>
|
||||
<SectionIcon size={18} className={section.color} />
|
||||
</div>
|
||||
<h2 className="font-bold text-gray-900">{section.title}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{section.canAdd && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingBotId(null);
|
||||
setEditingBotType(type);
|
||||
if (type === 'youtube') {
|
||||
setYoutubeDialogOpen(true);
|
||||
} else if (type === 'x') {
|
||||
setXDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-white border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
봇 추가
|
||||
</button>
|
||||
)}
|
||||
<Tooltip text="새로고침">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsInitialLoad(true);
|
||||
fetchBots();
|
||||
}}
|
||||
disabled={loading}
|
||||
className="p-2 hover:bg-white/50 rounded-lg transition-colors text-gray-500 hover:text-gray-700 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 봇 목록 - 테이블형 */}
|
||||
{sectionBots.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<Bot size={36} className="mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">등록된 봇이 없습니다</p>
|
||||
{section.canAdd && (
|
||||
<p className="text-xs mt-1">위의 버튼을 클릭하여 봇을 추가하세요</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<BotTable>
|
||||
{sectionBots.map((bot, index) => (
|
||||
<BotTableRow
|
||||
key={bot.id}
|
||||
bot={bot}
|
||||
index={index}
|
||||
isInitialLoad={isInitialLoad}
|
||||
syncing={syncing}
|
||||
statusInfo={getStatusInfo(bot.status)}
|
||||
onSync={handleSyncAllVideos}
|
||||
onToggle={toggleBot}
|
||||
onEdit={(bot) => {
|
||||
setEditingBotId(bot.db_id);
|
||||
setEditingBotType(bot.type);
|
||||
if (bot.type === 'youtube') {
|
||||
setYoutubeDialogOpen(true);
|
||||
} else if (bot.type === 'x') {
|
||||
setXDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
onDelete={(bot) => setDeletingBot(bot)}
|
||||
onAnimationComplete={() =>
|
||||
isInitialLoad && index === sectionBots.length - 1 && setIsInitialLoad(false)
|
||||
}
|
||||
formatTime={formatTime}
|
||||
formatInterval={formatInterval}
|
||||
/>
|
||||
))}
|
||||
</BotTable>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AdminLayout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -400,7 +400,7 @@ function ScheduleDict() {
|
|||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
className="absolute top-full left-0 mt-1 w-64 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-20"
|
||||
className="absolute top-full left-0 mt-1 w-64 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-40"
|
||||
>
|
||||
{POS_TAGS.map((tag) => (
|
||||
<button
|
||||
|
|
@ -471,7 +471,7 @@ function ScheduleDict() {
|
|||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
className="absolute top-full right-0 mt-1 w-48 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-20"
|
||||
className="absolute top-full right-0 mt-1 w-48 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-40"
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import * as categoriesApi from '@/api/admin/categories';
|
|||
import * as schedulesApi from '@/api/admin/schedules';
|
||||
import { getMembers } from '@/api/public/members';
|
||||
import { getColorStyle } from '@/utils/color';
|
||||
import useAuthStore from '@/stores/useAuthStore';
|
||||
|
||||
function ScheduleForm() {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -285,7 +286,7 @@ function ScheduleForm() {
|
|||
setSaving(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const token = useAuthStore.getState().token;
|
||||
|
||||
// FormData 생성
|
||||
const submitData = new FormData();
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import AdminLayout from "@/components/pc/admin/layout/Layout";
|
|||
import Toast from "@/components/common/Toast";
|
||||
import { useAdminAuth } from "@/hooks/pc/admin";
|
||||
import { useToast } from "@/hooks/common";
|
||||
import useAuthStore from "@/stores/useAuthStore";
|
||||
|
||||
// 애니메이션 variants
|
||||
const containerVariants = {
|
||||
|
|
@ -60,7 +61,7 @@ function YouTubeEditForm() {
|
|||
const { data: schedule, isLoading: scheduleLoading, isError: scheduleError } = useQuery({
|
||||
queryKey: ["schedule", id],
|
||||
queryFn: async () => {
|
||||
const token = localStorage.getItem("adminToken");
|
||||
const token = useAuthStore.getState().token;
|
||||
const res = await fetch(`/api/schedules/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
|
@ -126,7 +127,7 @@ function YouTubeEditForm() {
|
|||
setSaving(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("adminToken");
|
||||
const token = useAuthStore.getState().token;
|
||||
|
||||
const response = await fetch(`/api/admin/youtube/schedule/${id}`, {
|
||||
method: "PUT",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
|
||||
import Toast from "@/components/common/Toast";
|
||||
import { useToast } from "@/hooks/common";
|
||||
import useAuthStore from "@/stores/useAuthStore";
|
||||
|
||||
// X 로고 아이콘
|
||||
const XLogo = ({ size = 24, className = "" }) => (
|
||||
|
|
@ -64,7 +65,7 @@ function XForm() {
|
|||
setPostInfo(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("adminToken");
|
||||
const token = useAuthStore.getState().token;
|
||||
const response = await fetch(
|
||||
`/api/admin/x/post-info?postId=${id}`,
|
||||
{
|
||||
|
|
@ -115,7 +116,7 @@ function XForm() {
|
|||
setSaving(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("adminToken");
|
||||
const token = useAuthStore.getState().token;
|
||||
|
||||
const response = await fetch("/api/admin/x/schedule", {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import Toast from "@/components/common/Toast";
|
||||
import { useToast } from "@/hooks/common";
|
||||
import useAuthStore from "@/stores/useAuthStore";
|
||||
|
||||
/**
|
||||
* YouTube 일정 추가 폼
|
||||
|
|
@ -39,7 +40,7 @@ function YouTubeForm() {
|
|||
setVideoInfo(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("adminToken");
|
||||
const token = useAuthStore.getState().token;
|
||||
const response = await fetch(
|
||||
`/api/admin/youtube/video-info?url=${encodeURIComponent(url)}`,
|
||||
{
|
||||
|
|
@ -90,7 +91,7 @@ function YouTubeForm() {
|
|||
setSaving(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("adminToken");
|
||||
const token = useAuthStore.getState().token;
|
||||
|
||||
const response = await fetch("/api/admin/youtube/schedule", {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
import { useRef } from "react";
|
||||
import { Image, Users, Check } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 콘서트 정보 섹션
|
||||
* - 공연명
|
||||
* - 포스터
|
||||
* - 참여 멤버
|
||||
*/
|
||||
function ConcertInfoSection({
|
||||
title,
|
||||
setTitle,
|
||||
posterPreview,
|
||||
onPosterChange,
|
||||
onPosterRemove,
|
||||
members,
|
||||
selectedMemberIds,
|
||||
onToggleMember,
|
||||
onToggleAllMembers,
|
||||
}) {
|
||||
const posterInputRef = useRef(null);
|
||||
|
||||
const handlePosterChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
onPosterChange(file);
|
||||
}
|
||||
};
|
||||
|
||||
const isAllSelected = members.length > 0 && selectedMemberIds.length === members.length;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-6">공연 정보</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 공연명 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
공연명 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예: fromis_9 WORLD TOUR NOW TOMORROW."
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 포스터 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
포스터
|
||||
</label>
|
||||
<div className="flex items-start gap-6">
|
||||
<div
|
||||
onClick={() => posterInputRef.current?.click()}
|
||||
className="w-40 h-56 rounded-xl border-2 border-dashed border-gray-200 flex items-center justify-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors overflow-hidden"
|
||||
>
|
||||
{posterPreview ? (
|
||||
<img
|
||||
src={posterPreview}
|
||||
alt="포스터 미리보기"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-gray-400">
|
||||
<Image size={32} className="mx-auto mb-2" />
|
||||
<p className="text-xs">클릭하여 업로드</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={posterInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handlePosterChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
권장 크기: 세로형 포스터 (예: 700x1000px)
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">지원 형식: JPG, PNG, WebP</p>
|
||||
{posterPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPosterRemove}
|
||||
className="mt-3 text-sm text-red-500 hover:text-red-600"
|
||||
>
|
||||
이미지 제거
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 참여 멤버 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<Users size={16} />
|
||||
참여 멤버
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleAllMembers}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{isAllSelected ? "전체 해제" : "전체 선택"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{members.map((member) => {
|
||||
const isSelected = selectedMemberIds.includes(member.id);
|
||||
return (
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
onClick={() => onToggleMember(member.id)}
|
||||
className={`relative rounded-xl overflow-hidden transition-all ${
|
||||
isSelected
|
||||
? "ring-2 ring-primary ring-offset-2"
|
||||
: "hover:opacity-80"
|
||||
}`}
|
||||
>
|
||||
<div className="aspect-[3/4] bg-gray-100">
|
||||
{member.image_url ? (
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-200">
|
||||
<Users size={24} className="text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-2">
|
||||
<p className="text-white text-xs font-medium">{member.name}</p>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 w-5 h-5 bg-primary rounded-full flex items-center justify-center">
|
||||
<Check size={12} className="text-white" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConcertInfoSection;
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
import { useRef, useState } from "react";
|
||||
import { motion, AnimatePresence, Reorder } from "framer-motion";
|
||||
import { Image, Plus, Trash2, GripVertical } from "lucide-react";
|
||||
|
||||
import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog";
|
||||
|
||||
/**
|
||||
* 굿즈 섹션
|
||||
* - 다수의 굿즈 이미지 업로드
|
||||
* - 드래그로 순서 변경
|
||||
*/
|
||||
function MerchandiseSection({ items, setItems }) {
|
||||
const fileInputRef = useRef(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState({
|
||||
isOpen: false,
|
||||
itemId: null,
|
||||
itemName: null,
|
||||
});
|
||||
|
||||
// 이미지 추가
|
||||
const handleFileChange = (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const newItems = files.map((file, i) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
return {
|
||||
id: `md-${Date.now()}-${i}`,
|
||||
file,
|
||||
preview: url,
|
||||
};
|
||||
});
|
||||
|
||||
setItems((prev) => [...prev, ...newItems]);
|
||||
|
||||
// input 초기화
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
// 이미지 삭제 시도
|
||||
const handleRemoveItem = (id) => {
|
||||
const item = items.find((it) => it.id === id);
|
||||
setDeleteConfirm({
|
||||
isOpen: true,
|
||||
itemId: id,
|
||||
itemName: item?.file?.name || "이미지",
|
||||
});
|
||||
};
|
||||
|
||||
// 이미지 삭제 실행
|
||||
const confirmRemoveItem = () => {
|
||||
if (deleteConfirm.itemId !== null) {
|
||||
setItems((prev) => {
|
||||
const item = prev.find((it) => it.id === deleteConfirm.itemId);
|
||||
if (item?.preview) {
|
||||
URL.revokeObjectURL(item.preview);
|
||||
}
|
||||
return prev.filter((it) => it.id !== deleteConfirm.itemId);
|
||||
});
|
||||
}
|
||||
setDeleteConfirm({ isOpen: false, itemId: null, itemName: null });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() =>
|
||||
setDeleteConfirm({ isOpen: false, itemId: null, itemName: null })
|
||||
}
|
||||
onConfirm={confirmRemoveItem}
|
||||
title="이미지 삭제"
|
||||
message={
|
||||
<p>
|
||||
<span className="font-medium">{deleteConfirm.itemName}</span>
|
||||
을(를) 삭제하시겠습니까?
|
||||
</p>
|
||||
}
|
||||
confirmText="삭제"
|
||||
cancelText="취소"
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-6">굿즈</h2>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-gray-200 rounded-xl cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
<Image size={36} className="text-gray-300 mb-3" />
|
||||
<p className="text-sm text-gray-400">
|
||||
클릭하여 굿즈 이미지를 추가하세요
|
||||
</p>
|
||||
<p className="text-xs text-gray-300 mt-1">여러 장 선택 가능</p>
|
||||
</div>
|
||||
) : (
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={items}
|
||||
onReorder={setItems}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{items.map((item, index) => (
|
||||
<Reorder.Item
|
||||
key={item.id}
|
||||
value={item}
|
||||
initial={{ opacity: 0, scale: 0.98, y: -8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.98, y: -8 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl"
|
||||
>
|
||||
<div className="cursor-grab active:cursor-grabbing text-gray-300 hover:text-gray-500 transition-colors">
|
||||
<GripVertical size={18} />
|
||||
</div>
|
||||
|
||||
<div className="w-20 h-20 rounded-lg overflow-hidden flex-shrink-0 bg-gray-200">
|
||||
<img
|
||||
src={item.preview}
|
||||
alt={`굿즈 ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-700 truncate">
|
||||
{item.file?.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{index + 1}번째
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</Reorder.Item>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
드래그하여 순서를 변경할 수 있습니다. 순서대로 표시됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MerchandiseSection;
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
import { useState, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Plus, Trash2, MapPin, Search } from "lucide-react";
|
||||
|
||||
import DatePicker from "@/components/pc/admin/common/DatePicker";
|
||||
import TimePicker from "@/components/pc/admin/common/TimePicker";
|
||||
import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog";
|
||||
import VenueSearchDialog from "@/components/pc/admin/common/VenueSearchDialog";
|
||||
|
||||
/**
|
||||
* 공연 일정 섹션
|
||||
* - 다회차 지원 (날짜, 시간, 장소)
|
||||
*/
|
||||
function ScheduleSection({ rounds, setRounds }) {
|
||||
const containerRef = useRef(null);
|
||||
const [nextId, setNextId] = useState(() => {
|
||||
const maxId = rounds.reduce((max, r) => Math.max(max, r.id || 0), 0);
|
||||
return maxId + 1;
|
||||
});
|
||||
|
||||
// 삭제 확인 다이얼로그
|
||||
const [deleteConfirm, setDeleteConfirm] = useState({
|
||||
isOpen: false,
|
||||
roundId: null,
|
||||
roundIndex: null,
|
||||
});
|
||||
|
||||
// 장소 검색 다이얼로그
|
||||
const [locationSearch, setLocationSearch] = useState({
|
||||
isOpen: false,
|
||||
roundId: null,
|
||||
});
|
||||
|
||||
// 장소 삭제 확인 다이얼로그
|
||||
const [venueDeleteConfirm, setVenueDeleteConfirm] = useState({
|
||||
isOpen: false,
|
||||
roundId: null,
|
||||
venueName: null,
|
||||
});
|
||||
|
||||
// 회차 추가
|
||||
const addRound = () => {
|
||||
const newRound = {
|
||||
id: nextId,
|
||||
date: "",
|
||||
time: "",
|
||||
venue: null, // { name, address, lat, lng }
|
||||
};
|
||||
setRounds([...rounds, newRound]);
|
||||
setNextId(nextId + 1);
|
||||
|
||||
// 새 회차로 스크롤
|
||||
setTimeout(() => {
|
||||
if (containerRef.current) {
|
||||
const lastChild = containerRef.current.lastElementChild;
|
||||
if (lastChild) {
|
||||
lastChild.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 회차 삭제 시도
|
||||
const handleRemoveRound = (id) => {
|
||||
if (rounds.length <= 1) return;
|
||||
|
||||
const round = rounds.find((r) => r.id === id);
|
||||
const roundIndex = rounds.findIndex((r) => r.id === id);
|
||||
|
||||
// 입력값이 있으면 확인 다이얼로그 표시
|
||||
if (round && (round.date || round.time || round.venue)) {
|
||||
setDeleteConfirm({
|
||||
isOpen: true,
|
||||
roundId: id,
|
||||
roundIndex: roundIndex + 1,
|
||||
});
|
||||
} else {
|
||||
removeRound(id);
|
||||
}
|
||||
};
|
||||
|
||||
// 회차 삭제 실행
|
||||
const removeRound = (id) => {
|
||||
setRounds(rounds.filter((round) => round.id !== id));
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleConfirmDelete = () => {
|
||||
if (deleteConfirm.roundId !== null) {
|
||||
removeRound(deleteConfirm.roundId);
|
||||
}
|
||||
setDeleteConfirm({ isOpen: false, roundId: null, roundIndex: null });
|
||||
};
|
||||
|
||||
// 회차 업데이트
|
||||
const updateRound = (id, field, value) => {
|
||||
setRounds(
|
||||
rounds.map((round) =>
|
||||
round.id === id ? { ...round, [field]: value } : round
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// 장소 검색 열기
|
||||
const openLocationSearch = (roundId) => {
|
||||
setLocationSearch({ isOpen: true, roundId });
|
||||
};
|
||||
|
||||
// 장소 선택
|
||||
const handleLocationSelect = (place) => {
|
||||
if (locationSearch.roundId !== null) {
|
||||
updateRound(locationSearch.roundId, "venue", place);
|
||||
}
|
||||
setLocationSearch({ isOpen: false, roundId: null });
|
||||
};
|
||||
|
||||
// 장소 삭제 시도
|
||||
const handleRemoveVenue = (roundId) => {
|
||||
const round = rounds.find((r) => r.id === roundId);
|
||||
if (round?.venue) {
|
||||
setVenueDeleteConfirm({
|
||||
isOpen: true,
|
||||
roundId,
|
||||
venueName: round.venue.name,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 장소 삭제 확인
|
||||
const handleConfirmVenueDelete = () => {
|
||||
if (venueDeleteConfirm.roundId !== null) {
|
||||
updateRound(venueDeleteConfirm.roundId, "venue", null);
|
||||
}
|
||||
setVenueDeleteConfirm({ isOpen: false, roundId: null, venueName: null });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() =>
|
||||
setDeleteConfirm({ isOpen: false, roundId: null, roundIndex: null })
|
||||
}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="회차 삭제"
|
||||
message={
|
||||
<p>
|
||||
<span className="font-medium">{deleteConfirm.roundIndex}회차</span>에
|
||||
입력된 정보가 있습니다.
|
||||
<br />
|
||||
정말 삭제하시겠습니까?
|
||||
</p>
|
||||
}
|
||||
confirmText="삭제"
|
||||
cancelText="취소"
|
||||
/>
|
||||
|
||||
{/* 장소 검색 다이얼로그 */}
|
||||
<VenueSearchDialog
|
||||
isOpen={locationSearch.isOpen}
|
||||
onClose={() => setLocationSearch({ isOpen: false, roundId: null })}
|
||||
onSelect={handleLocationSelect}
|
||||
/>
|
||||
|
||||
{/* 장소 삭제 확인 다이얼로그 */}
|
||||
<ConfirmDialog
|
||||
isOpen={venueDeleteConfirm.isOpen}
|
||||
onClose={() =>
|
||||
setVenueDeleteConfirm({ isOpen: false, roundId: null, venueName: null })
|
||||
}
|
||||
onConfirm={handleConfirmVenueDelete}
|
||||
title="장소 삭제"
|
||||
message={
|
||||
<p>
|
||||
<span className="font-medium">{venueDeleteConfirm.venueName}</span>
|
||||
을(를) 삭제하시겠습니까?
|
||||
</p>
|
||||
}
|
||||
confirmText="삭제"
|
||||
cancelText="취소"
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-6">공연 일정</h2>
|
||||
|
||||
<div ref={containerRef} className="flex flex-col gap-4">
|
||||
<AnimatePresence initial={false}>
|
||||
{rounds.map((round, index) => (
|
||||
<motion.div
|
||||
key={round.id}
|
||||
initial={{ opacity: 0, scale: 0.98, y: -8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.98, y: -8 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
<div className="p-4 bg-gray-50 rounded-xl space-y-3">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{index + 1}회차
|
||||
</span>
|
||||
{rounds.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveRound(round.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 날짜 & 시간 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
날짜 *
|
||||
</label>
|
||||
<DatePicker
|
||||
value={round.date}
|
||||
onChange={(val) => updateRound(round.id, "date", val)}
|
||||
placeholder="날짜 선택"
|
||||
minYear={2017}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
시간 (선택)
|
||||
</label>
|
||||
<TimePicker
|
||||
value={round.time}
|
||||
onChange={(val) => updateRound(round.id, "time", val)}
|
||||
placeholder="시간 선택"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 장소 */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
장소 (선택)
|
||||
</label>
|
||||
{round.venue ? (
|
||||
<div className="flex items-center gap-2 p-3 bg-white border border-gray-200 rounded-lg">
|
||||
<MapPin size={16} className="text-primary flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{round.venue.name}
|
||||
</p>
|
||||
{round.venue.country && (
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
{round.venue.country}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{round.venue.address}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveVenue(round.id)}
|
||||
className="p-1 text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLocationSearch(round.id)}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 border border-gray-200 rounded-lg text-gray-500 hover:border-primary hover:text-primary hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
<Search size={16} />
|
||||
<span className="text-sm">장소 검색</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRound}
|
||||
className="w-full mt-4 flex items-center justify-center gap-1.5 py-2 bg-primary/10 rounded-lg text-sm text-primary hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
회차 추가
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
시간과 장소는 선택사항입니다. 미정인 경우 비워두세요.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScheduleSection;
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
import { useState, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Plus, Trash2, Users, Search } from "lucide-react";
|
||||
|
||||
import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog";
|
||||
import SongSearchDialog from "./SongSearchDialog";
|
||||
|
||||
/**
|
||||
* 세트리스트 섹션
|
||||
* - 곡 추가/삭제
|
||||
* - 곡명, 앨범명, 참여 멤버
|
||||
* - 순서 자동 부여
|
||||
*/
|
||||
function SetlistSection({ setlist, setSetlist, members, selectedMemberIds, albums }) {
|
||||
const containerRef = useRef(null);
|
||||
const [nextId, setNextId] = useState(() => {
|
||||
const maxId = setlist.reduce((max, s) => Math.max(max, s.id || 0), 0);
|
||||
return maxId + 1;
|
||||
});
|
||||
|
||||
// 삭제 확인 다이얼로그
|
||||
const [deleteConfirm, setDeleteConfirm] = useState({
|
||||
isOpen: false,
|
||||
songId: null,
|
||||
songName: null,
|
||||
});
|
||||
|
||||
// 곡 검색 다이얼로그
|
||||
const [songSearchOpen, setSongSearchOpen] = useState(false);
|
||||
|
||||
// 직접 입력 곡 추가
|
||||
const addSong = () => {
|
||||
const newSong = {
|
||||
id: nextId,
|
||||
songName: "",
|
||||
albumName: "",
|
||||
memberIds: [...selectedMemberIds],
|
||||
};
|
||||
setSetlist((prev) => [...prev, newSong]);
|
||||
setNextId(nextId + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
if (containerRef.current) {
|
||||
const lastChild = containerRef.current.lastElementChild;
|
||||
if (lastChild) {
|
||||
lastChild.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 검색에서 선택한 곡 추가
|
||||
const addSongsFromSearch = (songs) => {
|
||||
let id = nextId;
|
||||
const newSongs = songs.map((song) => ({
|
||||
id: id++,
|
||||
songName: song.songName,
|
||||
albumName: song.albumName,
|
||||
memberIds: [...selectedMemberIds],
|
||||
}));
|
||||
setSetlist((prev) => [...prev, ...newSongs]);
|
||||
setNextId(id);
|
||||
|
||||
setTimeout(() => {
|
||||
if (containerRef.current) {
|
||||
const lastChild = containerRef.current.lastElementChild;
|
||||
if (lastChild) {
|
||||
lastChild.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 곡 삭제 시도
|
||||
const handleRemoveSong = (id) => {
|
||||
if (setlist.length <= 1) return;
|
||||
|
||||
const song = setlist.find((s) => s.id === id);
|
||||
|
||||
if (song && (song.songName || song.albumName)) {
|
||||
setDeleteConfirm({
|
||||
isOpen: true,
|
||||
songId: id,
|
||||
songName: song.songName || "제목 없음",
|
||||
});
|
||||
} else {
|
||||
removeSong(id);
|
||||
}
|
||||
};
|
||||
|
||||
// 곡 삭제 실행
|
||||
const removeSong = (id) => {
|
||||
setSetlist((prev) => prev.filter((s) => s.id !== id));
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleConfirmDelete = () => {
|
||||
if (deleteConfirm.songId !== null) {
|
||||
removeSong(deleteConfirm.songId);
|
||||
}
|
||||
setDeleteConfirm({ isOpen: false, songId: null, songName: null });
|
||||
};
|
||||
|
||||
// 곡 필드 업데이트
|
||||
const updateSong = (id, field, value) => {
|
||||
setSetlist((prev) =>
|
||||
prev.map((s) => (s.id === id ? { ...s, [field]: value } : s))
|
||||
);
|
||||
};
|
||||
|
||||
// 곡별 멤버 토글
|
||||
const toggleSongMember = (songId, memberId) => {
|
||||
setSetlist((prev) =>
|
||||
prev.map((s) => {
|
||||
if (s.id !== songId) return s;
|
||||
const has = s.memberIds.includes(memberId);
|
||||
return {
|
||||
...s,
|
||||
memberIds: has
|
||||
? s.memberIds.filter((id) => id !== memberId)
|
||||
: [...s.memberIds, memberId],
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// 곡별 멤버 전체 선택/해제
|
||||
const toggleAllSongMembers = (songId) => {
|
||||
setSetlist((prev) =>
|
||||
prev.map((s) => {
|
||||
if (s.id !== songId) return s;
|
||||
const allSelected = members.every((m) => s.memberIds.includes(m.id));
|
||||
return {
|
||||
...s,
|
||||
memberIds: allSelected ? [] : members.map((m) => m.id),
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() =>
|
||||
setDeleteConfirm({ isOpen: false, songId: null, songName: null })
|
||||
}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="곡 삭제"
|
||||
message={
|
||||
<p>
|
||||
<span className="font-medium">{deleteConfirm.songName}</span>
|
||||
을(를) 삭제하시겠습니까?
|
||||
</p>
|
||||
}
|
||||
confirmText="삭제"
|
||||
cancelText="취소"
|
||||
/>
|
||||
|
||||
<SongSearchDialog
|
||||
isOpen={songSearchOpen}
|
||||
onClose={() => setSongSearchOpen(false)}
|
||||
onSelect={addSongsFromSearch}
|
||||
albums={albums}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-6">세트리스트</h2>
|
||||
|
||||
<div ref={containerRef} className="flex flex-col gap-4">
|
||||
<AnimatePresence initial={false}>
|
||||
{setlist.map((song, index) => (
|
||||
<motion.div
|
||||
key={song.id}
|
||||
initial={{ opacity: 0, scale: 0.98, y: -8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.98, y: -8 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
<div className="p-4 bg-gray-50 rounded-xl space-y-3">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{index + 1}번 곡
|
||||
</span>
|
||||
{setlist.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveSong(song.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 곡명 & 앨범명 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
곡명 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={song.songName}
|
||||
onChange={(e) =>
|
||||
updateSong(song.id, "songName", e.target.value)
|
||||
}
|
||||
placeholder="예: Feel Good"
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
앨범명 (선택)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={song.albumName}
|
||||
onChange={(e) =>
|
||||
updateSong(song.id, "albumName", e.target.value)
|
||||
}
|
||||
placeholder="예: Unlock My World"
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 참여 멤버 */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-xs text-gray-500 mb-2">
|
||||
<Users size={14} />
|
||||
참여 멤버
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* 전체 선택 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAllSongMembers(song.id)}
|
||||
className={`flex items-center justify-center px-4 py-1.5 rounded-full border text-sm transition-colors ${
|
||||
members.every((m) =>
|
||||
song.memberIds.includes(m.id)
|
||||
)
|
||||
? "border-primary bg-primary text-white"
|
||||
: "border-gray-200 text-gray-500 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{members.every((m) =>
|
||||
song.memberIds.includes(m.id)
|
||||
)
|
||||
? "전체 해제"
|
||||
: "전체 선택"}
|
||||
</button>
|
||||
|
||||
{members.map((member) => {
|
||||
const isSelected = song.memberIds.includes(member.id);
|
||||
return (
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggleSongMember(song.id, member.id)
|
||||
}
|
||||
className={`flex items-center gap-2 pr-3.5 pl-1.5 py-1.5 rounded-full border transition-colors ${
|
||||
isSelected
|
||||
? "border-primary"
|
||||
: "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full overflow-hidden bg-gray-200 flex-shrink-0">
|
||||
{member.image_url ? (
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-300" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-700">
|
||||
{member.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSongSearchOpen(true)}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-primary/10 rounded-lg text-sm text-primary hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<Search size={14} />
|
||||
곡 검색
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSong}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-gray-100 rounded-lg text-sm text-gray-600 hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
직접 입력
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
곡 추가 시 콘서트 참여 멤버가 자동으로 선택됩니다. 솔로/유닛 곡은
|
||||
개별 조정하세요.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SetlistSection;
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
import { useState, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, Search, Music, Check, Disc3 } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 곡 검색 다이얼로그
|
||||
* - 앨범 목록에서 곡을 검색/선택
|
||||
* - 다중 선택 지원
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.isOpen
|
||||
* @param {Function} props.onClose
|
||||
* @param {Function} props.onSelect - 선택된 곡 배열 반환 [{ songName, albumName }]
|
||||
* @param {Array} props.albums - getAlbums() 결과
|
||||
*/
|
||||
function SongSearchDialog({ isOpen, onClose, onSelect, albums }) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedTracks, setSelectedTracks] = useState([]);
|
||||
|
||||
// 전체 트랙 목록 (앨범 정보 포함)
|
||||
const allTracks = useMemo(() => {
|
||||
if (!albums || albums.length === 0) return [];
|
||||
return albums.flatMap((album) =>
|
||||
(album.tracks || []).map((track) => ({
|
||||
id: `${album.id}-${track.id}`,
|
||||
songName: track.title,
|
||||
albumName: album.title,
|
||||
albumCover: album.cover_thumb_url,
|
||||
isTitleTrack: track.is_title_track,
|
||||
trackNumber: track.track_number,
|
||||
}))
|
||||
);
|
||||
}, [albums]);
|
||||
|
||||
// 검색 필터링
|
||||
const filteredTracks = useMemo(() => {
|
||||
if (!searchQuery.trim()) return allTracks;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return allTracks.filter(
|
||||
(track) =>
|
||||
track.songName.toLowerCase().includes(query) ||
|
||||
track.albumName.toLowerCase().includes(query)
|
||||
);
|
||||
}, [allTracks, searchQuery]);
|
||||
|
||||
// 앨범별 그룹핑
|
||||
const groupedTracks = useMemo(() => {
|
||||
const groups = {};
|
||||
filteredTracks.forEach((track) => {
|
||||
if (!groups[track.albumName]) {
|
||||
groups[track.albumName] = {
|
||||
albumName: track.albumName,
|
||||
albumCover: track.albumCover,
|
||||
tracks: [],
|
||||
};
|
||||
}
|
||||
groups[track.albumName].tracks.push(track);
|
||||
});
|
||||
return Object.values(groups);
|
||||
}, [filteredTracks]);
|
||||
|
||||
// 트랙 선택 토글
|
||||
const toggleTrack = (track) => {
|
||||
setSelectedTracks((prev) => {
|
||||
const exists = prev.find((t) => t.id === track.id);
|
||||
if (exists) {
|
||||
return prev.filter((t) => t.id !== track.id);
|
||||
}
|
||||
return [...prev, track];
|
||||
});
|
||||
};
|
||||
|
||||
const isSelected = (trackId) => selectedTracks.some((t) => t.id === trackId);
|
||||
|
||||
// 확인
|
||||
const handleConfirm = () => {
|
||||
onSelect(
|
||||
selectedTracks.map((t) => ({
|
||||
songName: t.songName,
|
||||
albumName: t.albumName,
|
||||
}))
|
||||
);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
// 닫기
|
||||
const handleClose = () => {
|
||||
setSearchQuery("");
|
||||
setSelectedTracks([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="bg-white rounded-2xl p-6 max-w-lg w-full mx-4 shadow-xl flex flex-col h-[60vh] min-h-[400px]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-900">곡 검색</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="relative mb-4">
|
||||
<Search
|
||||
size={18}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="곡명 또는 앨범명으로 검색"
|
||||
className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 선택 카운트 */}
|
||||
{selectedTracks.length > 0 && (
|
||||
<div className="mb-3 text-sm text-primary font-medium">
|
||||
{selectedTracks.length}곡 선택됨
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 결과 목록 */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{groupedTracks.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{groupedTracks.map((group) => (
|
||||
<div key={group.albumName}>
|
||||
{/* 앨범 헤더 */}
|
||||
<div className="flex items-center gap-2 mb-2 sticky top-0 bg-white py-1">
|
||||
{group.albumCover ? (
|
||||
<img
|
||||
src={group.albumCover}
|
||||
alt={group.albumName}
|
||||
className="w-8 h-8 rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-md bg-gray-100 flex items-center justify-center">
|
||||
<Disc3 size={16} className="text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{group.albumName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 트랙 목록 */}
|
||||
<div className="space-y-1">
|
||||
{group.tracks.map((track) => (
|
||||
<button
|
||||
key={track.id}
|
||||
type="button"
|
||||
onClick={() => toggleTrack(track)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
|
||||
isSelected(track.id)
|
||||
? "bg-primary/10"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 rounded border flex items-center justify-center flex-shrink-0 ${
|
||||
isSelected(track.id)
|
||||
? "bg-primary border-primary"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{isSelected(track.id) && (
|
||||
<Check size={12} className="text-white" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-400 w-5 text-right flex-shrink-0">
|
||||
{track.trackNumber}
|
||||
</span>
|
||||
<span className="text-sm text-gray-900 flex-1 truncate">
|
||||
{track.songName}
|
||||
</span>
|
||||
{!!track.isTitleTrack && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-primary/10 text-primary rounded font-medium flex-shrink-0">
|
||||
타이틀
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<Music size={32} className="mx-auto mb-2 text-gray-300" />
|
||||
<p className="text-sm">
|
||||
{searchQuery
|
||||
? "검색 결과가 없습니다"
|
||||
: "등록된 곡이 없습니다"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex items-center justify-end gap-3 mt-4 pt-4 border-t border-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedTracks.length === 0}
|
||||
className="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50"
|
||||
>
|
||||
{selectedTracks.length > 0
|
||||
? `${selectedTracks.length}곡 추가`
|
||||
: "추가"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export default SongSearchDialog;
|
||||
245
frontend/src/pages/pc/admin/schedules/form/concert/index.jsx
Normal file
245
frontend/src/pages/pc/admin/schedules/form/concert/index.jsx
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { motion } from "framer-motion";
|
||||
import { Save } from "lucide-react";
|
||||
|
||||
import Toast from "@/components/common/Toast";
|
||||
import { useToast } from "@/hooks/common";
|
||||
import { useAdminAuth } from "@/hooks/pc/admin";
|
||||
import { getMembers } from "@/api/public/members";
|
||||
import { getAlbums } from "@/api/public/albums";
|
||||
import { createConcertSchedule } from "@/api/admin/concert";
|
||||
|
||||
import ConcertInfoSection from "./ConcertInfoSection";
|
||||
import ScheduleSection from "./ScheduleSection";
|
||||
import SetlistSection from "./SetlistSection";
|
||||
import MerchandiseSection from "./MerchandiseSection";
|
||||
|
||||
/**
|
||||
* 콘서트 일정 추가 폼
|
||||
*/
|
||||
function ConcertForm() {
|
||||
const navigate = useNavigate();
|
||||
const { toast, setToast } = useToast();
|
||||
const { isAuthenticated } = useAdminAuth();
|
||||
|
||||
// 멤버 목록 조회
|
||||
const { data: membersData = [] } = useQuery({
|
||||
queryKey: ["members"],
|
||||
queryFn: getMembers,
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const members = membersData.filter((m) => !m.is_former);
|
||||
|
||||
// 앨범 목록 조회 (곡 검색용)
|
||||
const { data: albumsData = [] } = useQuery({
|
||||
queryKey: ["albums"],
|
||||
queryFn: getAlbums,
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// 콘서트 정보
|
||||
const [title, setTitle] = useState("");
|
||||
const [posterFile, setPosterFile] = useState(null);
|
||||
const [posterPreview, setPosterPreview] = useState(null);
|
||||
const [selectedMemberIds, setSelectedMemberIds] = useState([]);
|
||||
|
||||
// 공연 일정 (다회차)
|
||||
const [rounds, setRounds] = useState([
|
||||
{ id: 1, date: "", time: "", venue: null },
|
||||
]);
|
||||
|
||||
// 세트리스트
|
||||
const [setlist, setSetlist] = useState([
|
||||
{ id: 1, songName: "", albumName: "", memberIds: [] },
|
||||
]);
|
||||
|
||||
// 굿즈 이미지
|
||||
const [merchandiseItems, setMerchandiseItems] = useState([]);
|
||||
|
||||
// 로딩 상태
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 멤버 토글
|
||||
const toggleMember = (memberId) => {
|
||||
setSelectedMemberIds((prev) =>
|
||||
prev.includes(memberId)
|
||||
? prev.filter((id) => id !== memberId)
|
||||
: [...prev, memberId]
|
||||
);
|
||||
};
|
||||
|
||||
// 전체 선택/해제
|
||||
const toggleAllMembers = () => {
|
||||
if (selectedMemberIds.length === members.length) {
|
||||
setSelectedMemberIds([]);
|
||||
} else {
|
||||
setSelectedMemberIds(members.map((m) => m.id));
|
||||
}
|
||||
};
|
||||
|
||||
// 포스터 변경
|
||||
const handlePosterChange = (file) => {
|
||||
setPosterFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPosterPreview(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
// 포스터 제거
|
||||
const handlePosterRemove = () => {
|
||||
setPosterFile(null);
|
||||
setPosterPreview(null);
|
||||
};
|
||||
|
||||
// 폼 제출
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 유효성 검사
|
||||
if (!title.trim()) {
|
||||
setToast({ type: "error", message: "공연명을 입력해주세요." });
|
||||
return;
|
||||
}
|
||||
|
||||
const validRounds = rounds.filter((r) => r.date);
|
||||
if (validRounds.length === 0) {
|
||||
setToast({ type: "error", message: "최소 1개 이상의 공연 일정이 필요합니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
// 기본 정보
|
||||
formData.append("title", title.trim());
|
||||
formData.append("memberIds", JSON.stringify(selectedMemberIds));
|
||||
|
||||
// 포스터
|
||||
if (posterFile) {
|
||||
formData.append("poster", posterFile);
|
||||
}
|
||||
|
||||
// 회차 정보
|
||||
const roundsData = validRounds.map((r) => ({
|
||||
date: r.date,
|
||||
time: r.time || null,
|
||||
venueId: r.venue?.id || null,
|
||||
venueName: r.venue?.name || null,
|
||||
venueCountry: r.venue?.country || null,
|
||||
venueAddress: r.venue?.address || null,
|
||||
venueLat: r.venue?.lat || null,
|
||||
venueLng: r.venue?.lng || null,
|
||||
}));
|
||||
formData.append("rounds", JSON.stringify(roundsData));
|
||||
|
||||
// 세트리스트
|
||||
const validSetlist = setlist.filter((s) => s.songName?.trim());
|
||||
const setlistData = validSetlist.map((s) => ({
|
||||
songName: s.songName.trim(),
|
||||
albumName: s.albumName?.trim() || null,
|
||||
memberIds: s.memberIds || [],
|
||||
}));
|
||||
formData.append("setlist", JSON.stringify(setlistData));
|
||||
|
||||
// 굿즈 이미지
|
||||
merchandiseItems.forEach((item) => {
|
||||
if (item.file) {
|
||||
formData.append("merchandise", item.file);
|
||||
}
|
||||
});
|
||||
|
||||
await createConcertSchedule(formData);
|
||||
|
||||
setToast({ type: "success", message: "콘서트 일정이 저장되었습니다." });
|
||||
setTimeout(() => navigate("/admin/schedule"), 1000);
|
||||
} catch (err) {
|
||||
console.error("콘서트 저장 실패:", err);
|
||||
setToast({ type: "error", message: err.message || "저장에 실패했습니다." });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* 콘서트 정보 */}
|
||||
<ConcertInfoSection
|
||||
title={title}
|
||||
setTitle={setTitle}
|
||||
posterPreview={posterPreview}
|
||||
onPosterChange={handlePosterChange}
|
||||
onPosterRemove={handlePosterRemove}
|
||||
members={members}
|
||||
selectedMemberIds={selectedMemberIds}
|
||||
onToggleMember={toggleMember}
|
||||
onToggleAllMembers={toggleAllMembers}
|
||||
/>
|
||||
|
||||
{/* 공연 일정 */}
|
||||
<ScheduleSection rounds={rounds} setRounds={setRounds} />
|
||||
|
||||
{/* 굿즈 */}
|
||||
<MerchandiseSection
|
||||
items={merchandiseItems}
|
||||
setItems={setMerchandiseItems}
|
||||
/>
|
||||
|
||||
{/* 세트리스트 */}
|
||||
<SetlistSection
|
||||
setlist={setlist}
|
||||
setSetlist={setSetlist}
|
||||
members={members}
|
||||
selectedMemberIds={selectedMemberIds}
|
||||
albums={albumsData}
|
||||
/>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/admin/schedule")}
|
||||
className="px-6 py-2.5 text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={18} />
|
||||
저장
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConcertForm;
|
||||
|
|
@ -8,6 +8,7 @@ import * as categoriesApi from "@/api/admin/categories";
|
|||
import CategorySelector from "@/components/pc/admin/schedule/CategorySelector";
|
||||
import YouTubeForm from "./YouTubeForm";
|
||||
import XForm from "./XForm";
|
||||
import ConcertForm from "./concert";
|
||||
|
||||
// 애니메이션 variants
|
||||
const containerVariants = {
|
||||
|
|
@ -74,6 +75,9 @@ function ScheduleFormPage() {
|
|||
case 'X':
|
||||
return <XForm />;
|
||||
|
||||
case '콘서트':
|
||||
return <ConcertForm />;
|
||||
|
||||
// 다른 카테고리는 기존 폼으로 리다이렉트
|
||||
default:
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -4,6 +4,57 @@ import { useMembers } from '@/hooks';
|
|||
import { Loading } from '@/components/common';
|
||||
import { formatDate } from '@/utils';
|
||||
|
||||
/**
|
||||
* 멤버 카드 컴포넌트
|
||||
*/
|
||||
function MemberCard({ member, index }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="group w-[calc(33.333%-1rem)]"
|
||||
>
|
||||
<div className="relative bg-white rounded-3xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 h-full flex flex-col">
|
||||
{/* 이미지 */}
|
||||
<div className="aspect-[3/4] bg-gray-100 overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
<h3 className="text-xl font-bold mb-3">{member.name}</h3>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
|
||||
<Cake size={14} />
|
||||
<span>{formatDate(member.birth_date, 'YYYY.MM.DD')}</span>
|
||||
</div>
|
||||
|
||||
{/* 인스타그램 링크 */}
|
||||
{member.instagram && (
|
||||
<a
|
||||
href={member.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-pink-500 transition-colors mt-auto"
|
||||
>
|
||||
<Instagram size={16} />
|
||||
<span>Instagram</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 호버 효과 - 컬러 바 */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PC 멤버 페이지
|
||||
*/
|
||||
|
|
@ -18,9 +69,19 @@ function Members() {
|
|||
);
|
||||
}
|
||||
|
||||
const currentMembers = members.filter((m) => !m.is_former);
|
||||
|
||||
// 2/3 배열
|
||||
const rows = [
|
||||
currentMembers.slice(0, 2),
|
||||
currentMembers.slice(2, 5),
|
||||
];
|
||||
|
||||
let globalIndex = 0;
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="max-w-3xl mx-auto px-6">
|
||||
{/* 헤더 */}
|
||||
<div className="text-center mb-12">
|
||||
<motion.h1
|
||||
|
|
@ -40,110 +101,16 @@ function Members() {
|
|||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* 현재 멤버 그리드 */}
|
||||
<div className="grid grid-cols-5 gap-8">
|
||||
{members
|
||||
.filter((m) => !m.is_former)
|
||||
.map((member, index) => (
|
||||
<motion.div
|
||||
key={member.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="group h-full"
|
||||
>
|
||||
<div className="relative bg-white rounded-3xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 h-full flex flex-col">
|
||||
{/* 이미지 */}
|
||||
<div className="aspect-[3/4] bg-gray-100 overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
<h3 className="text-xl font-bold mb-3">{member.name}</h3>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
|
||||
<Cake size={14} />
|
||||
<span>{formatDate(member.birth_date, 'YYYY.MM.DD')}</span>
|
||||
</div>
|
||||
|
||||
{/* 인스타그램 링크 */}
|
||||
{member.instagram && (
|
||||
<a
|
||||
href={member.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-pink-500 transition-colors mt-auto"
|
||||
>
|
||||
<Instagram size={16} />
|
||||
<span>Instagram</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 호버 효과 - 컬러 바 */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 전 멤버 섹션 */}
|
||||
{members.filter((m) => m.is_former).length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="mt-16"
|
||||
>
|
||||
<h2 className="text-2xl font-bold mb-8 text-gray-400">전 멤버</h2>
|
||||
<div className="grid grid-cols-5 gap-8">
|
||||
{members
|
||||
.filter((m) => m.is_former)
|
||||
.map((member, index) => (
|
||||
<motion.div
|
||||
key={member.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6 + index * 0.1 }}
|
||||
className="group h-full"
|
||||
>
|
||||
<div className="relative bg-white rounded-3xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 h-full flex flex-col">
|
||||
{/* 이미지 - grayscale */}
|
||||
<div className="aspect-[3/4] bg-gray-100 overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
<h3 className="text-xl font-bold mb-3 text-gray-500">
|
||||
{member.name}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Cake size={14} />
|
||||
<span>
|
||||
{formatDate(member.birth_date, 'YYYY.MM.DD')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 호버 효과 - 컬러 바 */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gray-400 transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
{/* 멤버 그리드 - 2/3/3 배열 */}
|
||||
<div className="space-y-6">
|
||||
{rows.map((row, rowIndex) => (
|
||||
<div key={rowIndex} className="flex justify-center gap-6">
|
||||
{row.map((member) => (
|
||||
<MemberCard key={member.id} member={member} index={globalIndex++} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
BirthdayCard,
|
||||
DebutCard,
|
||||
} from '@/components/pc/public';
|
||||
import { DebutCelebrationDialog } from '@/components/common';
|
||||
import { DebutCelebrationDialog, BirthdayCelebrationDialog } from '@/components/common';
|
||||
import { fireBirthdayConfetti, fireDebutConfetti } from '@/utils';
|
||||
import { getSchedules, searchSchedules } from '@/api';
|
||||
import { useScheduleStore } from '@/stores';
|
||||
|
|
@ -57,6 +57,8 @@ function PCSchedule() {
|
|||
const [showCategoryTooltip, setShowCategoryTooltip] = useState(false);
|
||||
const [showDebutDialog, setShowDebutDialog] = useState(false);
|
||||
const [debutDialogInfo, setDebutDialogInfo] = useState({ isDebut: false, anniversaryYear: 0 });
|
||||
const [showBirthdayDialog, setShowBirthdayDialog] = useState(false);
|
||||
const [birthdayInfo, setBirthdayInfo] = useState({ title: '', memberImage: '', date: '' });
|
||||
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
|
|
@ -131,8 +133,15 @@ function PCSchedule() {
|
|||
if (localStorage.getItem(confettiKey)) return;
|
||||
const hasBirthdayToday = schedules.some((s) => s.is_birthday && s.date === today);
|
||||
if (hasBirthdayToday) {
|
||||
const birthdaySchedule = schedules.find((s) => s.is_birthday && s.date === today);
|
||||
const timer = setTimeout(() => {
|
||||
fireBirthdayConfetti();
|
||||
setBirthdayInfo({
|
||||
title: birthdaySchedule.title || '',
|
||||
memberImage: birthdaySchedule.member_image || '',
|
||||
date: birthdaySchedule.date,
|
||||
});
|
||||
setShowBirthdayDialog(true);
|
||||
localStorage.setItem(confettiKey, 'true');
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
|
|
@ -326,7 +335,12 @@ function PCSchedule() {
|
|||
|
||||
<div className="flex-1 min-h-0 grid grid-cols-3 gap-8">
|
||||
{/* 왼쪽: 달력 + 카테고리 */}
|
||||
<div className="space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.4 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<Calendar
|
||||
currentDate={currentDate}
|
||||
onDateChange={setCurrentDate}
|
||||
|
|
@ -344,10 +358,15 @@ function PCSchedule() {
|
|||
categoryCounts={categoryCounts}
|
||||
disabled={isSearchMode && searchResults.length === 0}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 오른쪽: 스케줄 리스트 */}
|
||||
<div className="col-span-2 flex flex-col min-h-0">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.4 }}
|
||||
className="col-span-2 flex flex-col min-h-0"
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between h-11 mb-2">
|
||||
<AnimatePresence mode="wait">
|
||||
|
|
@ -460,7 +479,7 @@ function PCSchedule() {
|
|||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="flex items-center gap-3"
|
||||
className="flex items-center gap-3 w-full"
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsSearchMode(true)}
|
||||
|
|
@ -478,6 +497,7 @@ function PCSchedule() {
|
|||
})()
|
||||
: `${month + 1}월 전체 일정`}
|
||||
</h2>
|
||||
<div className="flex-1" />
|
||||
{selectedCategories.length > 0 && (
|
||||
<div className="relative" ref={categoryRef}>
|
||||
<button
|
||||
|
|
@ -510,10 +530,10 @@ function PCSchedule() {
|
|||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm text-gray-400">{filteredSchedules.length}개 일정</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{!isSearchMode && <span className="text-sm text-gray-500">{filteredSchedules.length}개 일정</span>}
|
||||
</div>
|
||||
|
||||
{/* 스케줄 목록 */}
|
||||
|
|
@ -596,7 +616,7 @@ function PCSchedule() {
|
|||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -607,6 +627,13 @@ function PCSchedule() {
|
|||
isDebut={debutDialogInfo.isDebut}
|
||||
anniversaryYear={debutDialogInfo.anniversaryYear}
|
||||
/>
|
||||
<BirthdayCelebrationDialog
|
||||
isOpen={showBirthdayDialog}
|
||||
onClose={() => setShowBirthdayDialog(false)}
|
||||
title={birthdayInfo.title}
|
||||
memberImage={birthdayInfo.memberImage}
|
||||
date={birthdayInfo.date}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,49 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Linkify from 'react-linkify';
|
||||
import { decodeHtmlEntities, formatXDateTimeWithTime } from './utils';
|
||||
import { Lightbox } from '@/components/common';
|
||||
|
||||
/**
|
||||
* URL을 링크로 변환하는 함수
|
||||
*/
|
||||
function linkifyText(text) {
|
||||
if (!text) return null;
|
||||
|
||||
// URL 패턴: http(s)://로 시작하거나 일반적인 도메인 패턴
|
||||
const urlPattern = /(https?:\/\/[^\s]+|(?:bit\.ly|youtu\.be|t\.co|goo\.gl|tinyurl\.com)\/[^\s]+)/gi;
|
||||
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = urlPattern.exec(text)) !== null) {
|
||||
// 매치 전 텍스트
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
// URL
|
||||
let url = match[0];
|
||||
// http(s)://가 없으면 추가
|
||||
const href = url.startsWith('http') ? url : `https://${url}`;
|
||||
|
||||
parts.push(
|
||||
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// 나머지 텍스트
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* PC X(트위터) 섹션 컴포넌트
|
||||
*/
|
||||
|
|
@ -46,13 +86,6 @@ function XSection({ schedule }) {
|
|||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [lightboxOpen]);
|
||||
|
||||
// 링크 데코레이터 (새 탭에서 열기)
|
||||
const linkDecorator = (href, text, key) => (
|
||||
<a key={key} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* X 스타일 카드 */}
|
||||
|
|
@ -88,7 +121,7 @@ function XSection({ schedule }) {
|
|||
{/* 본문 */}
|
||||
<div className="p-5">
|
||||
<p className="text-gray-900 text-[17px] leading-relaxed whitespace-pre-wrap">
|
||||
<Linkify componentDecorator={linkDecorator}>{decodeHtmlEntities(schedule.content || schedule.title)}</Linkify>
|
||||
{linkifyText(decodeHtmlEntities(schedule.content || schedule.title))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,32 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import { Calendar, Link2 } from 'lucide-react';
|
||||
import { Calendar, Link2, Clock } from 'lucide-react';
|
||||
import { decodeHtmlEntities, formatXDateTimeWithTime } from './utils';
|
||||
|
||||
/**
|
||||
* 영상 정보 컴포넌트 (공통)
|
||||
*/
|
||||
function VideoInfo({ schedule, isShorts }) {
|
||||
function VideoInfo({ schedule, isShorts, isScheduled = false }) {
|
||||
const members = schedule.members || [];
|
||||
const isFullGroup = members.length === 5;
|
||||
// 채널명: channelName 또는 source.name에서 가져옴
|
||||
const channelName = schedule.channelName || schedule.source?.name;
|
||||
|
||||
return (
|
||||
<div className={`bg-gradient-to-br from-gray-100 to-gray-200/80 rounded-2xl p-6 ${isShorts ? 'flex-1' : ''}`}>
|
||||
{/* 제목 */}
|
||||
<h1 className={`font-bold text-gray-900 mb-4 leading-relaxed ${isShorts ? 'text-lg' : 'text-xl'}`}>
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
</h1>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<h1 className={`font-bold text-gray-900 leading-relaxed ${isShorts || isScheduled ? 'text-lg' : 'text-xl'}`}>
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
</h1>
|
||||
{isScheduled && (
|
||||
<span className="flex-shrink-0 px-2.5 py-1 bg-amber-100 text-amber-700 text-xs font-semibold rounded-full">
|
||||
예정
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 메타 정보 */}
|
||||
<div className={`flex flex-wrap items-center gap-4 text-sm ${isShorts ? 'gap-3' : ''}`}>
|
||||
<div className={`flex flex-wrap items-center gap-4 text-sm ${isShorts || isScheduled ? 'gap-3' : ''}`}>
|
||||
{/* 날짜/시간 */}
|
||||
<div className="flex items-center gap-1.5 text-gray-500">
|
||||
<Calendar size={14} />
|
||||
|
|
@ -25,12 +34,12 @@ function VideoInfo({ schedule, isShorts }) {
|
|||
</div>
|
||||
|
||||
{/* 채널명 */}
|
||||
{schedule.channelName && (
|
||||
{channelName && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-gray-300" />
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Link2 size={14} className="opacity-60" />
|
||||
<span className="font-medium">{schedule.channelName}</span>
|
||||
<span className="font-medium">{channelName}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -51,19 +60,54 @@ function VideoInfo({ schedule, isShorts }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 유튜브에서 보기 버튼 */}
|
||||
<div className="mt-6 pt-5 border-t border-gray-300/60">
|
||||
<a
|
||||
href={schedule.videoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-red-500 hover:bg-red-600 text-white rounded-xl font-medium transition-colors shadow-lg shadow-red-500/20"
|
||||
{/* 유튜브에서 보기 버튼 (예정 일정이 아닐 때만) */}
|
||||
{!isScheduled && (
|
||||
<div className="mt-6 pt-5 border-t border-gray-300/60">
|
||||
<a
|
||||
href={schedule.videoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-red-500 hover:bg-red-600 text-white rounded-xl font-medium transition-colors shadow-lg shadow-red-500/20"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||
</svg>
|
||||
YouTube에서 보기
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 예정 일정 Placeholder 컴포넌트
|
||||
*/
|
||||
function ScheduledPlaceholder({ bannerUrl }) {
|
||||
return (
|
||||
<div className="relative aspect-video bg-gradient-to-br from-gray-800 to-gray-900 rounded-2xl overflow-hidden shadow-lg shadow-black/10">
|
||||
{/* 배경: 배너 이미지 또는 패턴 */}
|
||||
{bannerUrl ? (
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${bannerUrl})` }}
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||
</svg>
|
||||
YouTube에서 보기
|
||||
</a>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 하단 텍스트 */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6">
|
||||
<div className="flex items-center gap-2 text-white/90">
|
||||
<Clock size={18} className="text-amber-400" />
|
||||
<span className="text-lg font-medium">업로드 예정</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -75,8 +119,29 @@ function VideoInfo({ schedule, isShorts }) {
|
|||
function YoutubeSection({ schedule }) {
|
||||
const videoId = schedule.videoId;
|
||||
const isShorts = schedule.videoType === 'shorts';
|
||||
const isScheduled = !videoId; // videoId가 없으면 예정 일정
|
||||
|
||||
if (!videoId) return null;
|
||||
// 예정 일정: 세로 레이아웃 (Placeholder + 정보)
|
||||
if (isScheduled) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 예정 Placeholder */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="w-full"
|
||||
>
|
||||
<ScheduledPlaceholder bannerUrl={schedule.bannerUrl} />
|
||||
</motion.div>
|
||||
|
||||
{/* 영상 정보 카드 */}
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}>
|
||||
<VideoInfo schedule={schedule} isShorts={false} isScheduled={true} />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 숏츠: 가로 레이아웃 (영상 + 정보)
|
||||
if (isShorts) {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import AdminYouTubeEditForm from '@/pages/pc/admin/schedules/edit/YouTubeEditFor
|
|||
import AdminScheduleCategory from '@/pages/pc/admin/schedules/ScheduleCategory';
|
||||
import AdminScheduleDict from '@/pages/pc/admin/schedules/ScheduleDict';
|
||||
import AdminScheduleBots from '@/pages/pc/admin/schedules/ScheduleBots';
|
||||
import AdminLogs from '@/pages/pc/admin/logs/Logs';
|
||||
import AdminNotFound from '@/pages/pc/admin/common/NotFound';
|
||||
|
||||
/**
|
||||
|
|
@ -59,6 +60,7 @@ export default function AdminRoutes() {
|
|||
<Route path="/admin/schedule/categories" element={<RequireAuth><AdminScheduleCategory /></RequireAuth>} />
|
||||
<Route path="/admin/schedule/dict" element={<RequireAuth><AdminScheduleDict /></RequireAuth>} />
|
||||
<Route path="/admin/schedule/bots" element={<RequireAuth><AdminScheduleBots /></RequireAuth>} />
|
||||
<Route path="/admin/logs" element={<RequireAuth><AdminLogs /></RequireAuth>} />
|
||||
<Route path="/admin/*" element={<AdminNotFound />} />
|
||||
</Routes>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ export default defineConfig({
|
|||
host: true,
|
||||
port: 80,
|
||||
allowedHosts: true,
|
||||
hmr: {
|
||||
overlay: false,
|
||||
},
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://fromis9-backend:80",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue