feat(x-bot): 키워드 필터링 및 전체 동기화 기능 추가
Backend: - bot_x 테이블에 text_filters 컬럼 추가 - syncNewTweets/syncAllTweets에 텍스트 필터링 로직 적용 - 봇 추가 시 전체 트윗 동기화 수행 (백그라운드) - X 봇 API에 text_filters 필드 처리 Frontend: - XBotDialog에 고급 설정 (키워드 필터) UI 추가 - BotTableRow에서 X 봇 수정/삭제 버튼 활성화 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
eeb5e7234c
commit
9ceef6c656
8 changed files with 265 additions and 11 deletions
|
|
@ -64,6 +64,11 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
nitterUrl: process.env.NITTER_URL || 'http://nitter:8080',
|
nitterUrl: process.env.NITTER_URL || 'http://nitter:8080',
|
||||||
cron: `*/${row.cron_interval} * * * *`,
|
cron: `*/${row.cron_interval} * * * *`,
|
||||||
enabled: row.enabled === 1,
|
enabled: row.enabled === 1,
|
||||||
|
textFilters: row.text_filters
|
||||||
|
? (typeof row.text_filters === 'string'
|
||||||
|
? JSON.parse(row.text_filters)
|
||||||
|
: row.text_filters)
|
||||||
|
: [],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ const botResponse = {
|
||||||
username: { type: 'string' },
|
username: { type: 'string' },
|
||||||
display_name: { type: 'string' },
|
display_name: { type: 'string' },
|
||||||
avatar_url: { type: 'string' },
|
avatar_url: { type: 'string' },
|
||||||
|
text_filters: { type: 'array', items: { type: 'string' } },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -122,6 +123,7 @@ export default async function botsRoutes(fastify) {
|
||||||
botData.username = bot.username;
|
botData.username = bot.username;
|
||||||
botData.display_name = bot.displayName;
|
botData.display_name = bot.displayName;
|
||||||
botData.avatar_url = bot.avatarUrl;
|
botData.avatar_url = bot.avatarUrl;
|
||||||
|
botData.text_filters = bot.textFilters || [];
|
||||||
botData.cron_interval = checkInterval;
|
botData.cron_interval = checkInterval;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ const xBotResponse = {
|
||||||
username: { type: 'string' },
|
username: { type: 'string' },
|
||||||
display_name: { type: 'string' },
|
display_name: { type: 'string' },
|
||||||
avatar_url: { type: 'string' },
|
avatar_url: { type: 'string' },
|
||||||
|
text_filters: { type: 'array', items: { type: 'string' } },
|
||||||
cron_interval: { type: 'integer' },
|
cron_interval: { type: 'integer' },
|
||||||
enabled: { type: 'boolean' },
|
enabled: { type: 'boolean' },
|
||||||
},
|
},
|
||||||
|
|
@ -34,6 +35,11 @@ function formatBotResponse(row) {
|
||||||
username: row.username,
|
username: row.username,
|
||||||
display_name: row.display_name,
|
display_name: row.display_name,
|
||||||
avatar_url: row.avatar_url,
|
avatar_url: row.avatar_url,
|
||||||
|
text_filters: row.text_filters
|
||||||
|
? (typeof row.text_filters === 'string'
|
||||||
|
? JSON.parse(row.text_filters)
|
||||||
|
: row.text_filters)
|
||||||
|
: [],
|
||||||
cron_interval: row.cron_interval,
|
cron_interval: row.cron_interval,
|
||||||
enabled: row.enabled === 1,
|
enabled: row.enabled === 1,
|
||||||
};
|
};
|
||||||
|
|
@ -150,6 +156,7 @@ export default async function xBotsRoutes(fastify) {
|
||||||
username: { type: 'string' },
|
username: { type: 'string' },
|
||||||
display_name: { type: ['string', 'null'] },
|
display_name: { type: ['string', 'null'] },
|
||||||
avatar_url: { type: ['string', 'null'] },
|
avatar_url: { type: ['string', 'null'] },
|
||||||
|
text_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
||||||
cron_interval: { type: 'integer', default: 1 },
|
cron_interval: { type: 'integer', default: 1 },
|
||||||
},
|
},
|
||||||
required: ['username'],
|
required: ['username'],
|
||||||
|
|
@ -165,6 +172,7 @@ export default async function xBotsRoutes(fastify) {
|
||||||
username,
|
username,
|
||||||
display_name,
|
display_name,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
|
text_filters,
|
||||||
cron_interval = 1,
|
cron_interval = 1,
|
||||||
} = request.body;
|
} = request.body;
|
||||||
|
|
||||||
|
|
@ -178,14 +186,41 @@ export default async function xBotsRoutes(fastify) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const [result] = await db.query(
|
const [result] = await db.query(
|
||||||
`INSERT INTO bot_x (username, display_name, avatar_url, cron_interval, enabled)
|
`INSERT INTO bot_x (username, display_name, avatar_url, text_filters, cron_interval, enabled)
|
||||||
VALUES (?, ?, ?, ?, 1)`,
|
VALUES (?, ?, ?, ?, ?, 1)`,
|
||||||
[username, display_name || null, avatar_url || null, cron_interval]
|
[
|
||||||
|
username,
|
||||||
|
display_name || null,
|
||||||
|
avatar_url || null,
|
||||||
|
text_filters && text_filters.length > 0 ? JSON.stringify(text_filters) : null,
|
||||||
|
cron_interval,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 스케줄러 캐시 무효화 및 봇 시작
|
// 스케줄러 캐시 무효화
|
||||||
scheduler.invalidateCache();
|
scheduler.invalidateCache();
|
||||||
const botId = `x-${result.insertId}`;
|
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 || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전체 동기화 (async, 응답 대기하지 않음)
|
||||||
|
fastify.xBot.syncAllTweets(bot)
|
||||||
|
.then((syncResult) => {
|
||||||
|
fastify.log.info(`[${botId}] 초기 전체 동기화 완료: ${syncResult.addedCount}개 추가`);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
fastify.log.error(`[${botId}] 초기 전체 동기화 실패:`, err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 봇 시작 (스케줄러 등록)
|
||||||
try {
|
try {
|
||||||
await scheduler.startBot(botId);
|
await scheduler.startBot(botId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -212,6 +247,7 @@ export default async function xBotsRoutes(fastify) {
|
||||||
properties: {
|
properties: {
|
||||||
display_name: { type: ['string', 'null'] },
|
display_name: { type: ['string', 'null'] },
|
||||||
avatar_url: { type: ['string', 'null'] },
|
avatar_url: { type: ['string', 'null'] },
|
||||||
|
text_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
||||||
cron_interval: { type: 'integer' },
|
cron_interval: { type: 'integer' },
|
||||||
enabled: { type: 'boolean' },
|
enabled: { type: 'boolean' },
|
||||||
},
|
},
|
||||||
|
|
@ -244,6 +280,12 @@ export default async function xBotsRoutes(fastify) {
|
||||||
fields.push('avatar_url = ?');
|
fields.push('avatar_url = ?');
|
||||||
values.push(updates.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.cron_interval !== undefined) {
|
if (updates.cron_interval !== undefined) {
|
||||||
fields.push('cron_interval = ?');
|
fields.push('cron_interval = ?');
|
||||||
values.push(updates.cron_interval);
|
values.push(updates.cron_interval);
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,15 @@ async function xBotPlugin(fastify, opts) {
|
||||||
return addedCount;
|
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()));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 최근 트윗 동기화 (정기 실행)
|
* 최근 트윗 동기화 (정기 실행)
|
||||||
*/
|
*/
|
||||||
|
|
@ -169,6 +178,11 @@ async function xBotPlugin(fastify, opts) {
|
||||||
let ytAddedCount = 0;
|
let ytAddedCount = 0;
|
||||||
|
|
||||||
for (const tweet of tweets) {
|
for (const tweet of tweets) {
|
||||||
|
// 텍스트 필터 적용
|
||||||
|
if (!matchesFilter(tweet.text, bot.textFilters)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const scheduleId = await saveTweet(tweet);
|
const scheduleId = await saveTweet(tweet);
|
||||||
if (scheduleId) {
|
if (scheduleId) {
|
||||||
// Meilisearch 동기화
|
// Meilisearch 동기화
|
||||||
|
|
@ -192,6 +206,11 @@ async function xBotPlugin(fastify, opts) {
|
||||||
let ytAddedCount = 0;
|
let ytAddedCount = 0;
|
||||||
|
|
||||||
for (const tweet of tweets) {
|
for (const tweet of tweets) {
|
||||||
|
// 텍스트 필터 적용
|
||||||
|
if (!matchesFilter(tweet.text, bot.textFilters)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const scheduleId = await saveTweet(tweet);
|
const scheduleId = await saveTweet(tweet);
|
||||||
if (scheduleId) {
|
if (scheduleId) {
|
||||||
// Meilisearch 동기화
|
// Meilisearch 동기화
|
||||||
|
|
|
||||||
106
docs/api.md
106
docs/api.md
|
|
@ -290,6 +290,112 @@ 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 봇 목록 조회
|
||||||
|
|
||||||
|
### GET /admin/x-bots/:id
|
||||||
|
X 봇 상세 조회
|
||||||
|
|
||||||
|
### POST /admin/x-bots
|
||||||
|
X 봇 추가
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "realfromis_9",
|
||||||
|
"display_name": "프로미스나인 (fromis_9)",
|
||||||
|
"avatar_url": "https://...",
|
||||||
|
"cron_interval": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PUT /admin/x-bots/:id
|
||||||
|
X 봇 수정
|
||||||
|
|
||||||
|
### DELETE /admin/x-bots/:id
|
||||||
|
X 봇 삭제
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 관리자 - YouTube (인증 필요)
|
## 관리자 - YouTube (인증 필요)
|
||||||
|
|
||||||
### GET /admin/youtube/video-info
|
### GET /admin/youtube/video-info
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ fromis_9/
|
||||||
│ │ ├── routes/ # API 라우트
|
│ │ ├── routes/ # API 라우트
|
||||||
│ │ │ ├── admin/ # 관리자 API
|
│ │ │ ├── admin/ # 관리자 API
|
||||||
│ │ │ │ ├── bots.js # 봇 관리
|
│ │ │ │ ├── bots.js # 봇 관리
|
||||||
|
│ │ │ │ ├── youtube-bots.js # YouTube 봇 CRUD
|
||||||
|
│ │ │ │ ├── x-bots.js # X 봇 CRUD
|
||||||
│ │ │ │ ├── youtube.js # YouTube 일정 관리
|
│ │ │ │ ├── youtube.js # YouTube 일정 관리
|
||||||
│ │ │ │ └── x.js # X 일정 관리
|
│ │ │ │ └── x.js # X 일정 관리
|
||||||
│ │ │ ├── albums/
|
│ │ │ ├── albums/
|
||||||
|
|
@ -150,7 +152,9 @@ fromis_9/
|
||||||
│ │ │ │ │ ├── PendingFileItem.jsx
|
│ │ │ │ │ ├── PendingFileItem.jsx
|
||||||
│ │ │ │ │ └── BulkEditPanel.jsx
|
│ │ │ │ │ └── BulkEditPanel.jsx
|
||||||
│ │ │ │ └── bot/
|
│ │ │ │ └── bot/
|
||||||
│ │ │ │ └── BotCard.jsx
|
│ │ │ │ ├── BotCard.jsx
|
||||||
|
│ │ │ │ ├── YouTubeBotDialog.jsx
|
||||||
|
│ │ │ │ └── XBotDialog.jsx
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ │ └── mobile/ # 모바일 컴포넌트
|
│ │ │ └── mobile/ # 모바일 컴포넌트
|
||||||
│ │ │ ├── layout/
|
│ │ │ ├── layout/
|
||||||
|
|
@ -280,7 +284,7 @@ fromis_9/
|
||||||
|
|
||||||
## 데이터베이스
|
## 데이터베이스
|
||||||
|
|
||||||
### 테이블 목록 (25개)
|
### 테이블 목록 (27개)
|
||||||
|
|
||||||
#### 사용자/인증
|
#### 사용자/인증
|
||||||
- `admin_users` - 관리자 계정
|
- `admin_users` - 관리자 계정
|
||||||
|
|
@ -315,6 +319,10 @@ fromis_9/
|
||||||
#### X(Twitter) 프로필
|
#### X(Twitter) 프로필
|
||||||
- `x_profiles` - X 프로필 캐시 (프로필 이미지, 이름 등)
|
- `x_profiles` - X 프로필 캐시 (프로필 이미지, 이름 등)
|
||||||
|
|
||||||
|
#### 봇
|
||||||
|
- `bot_youtube` - YouTube 봇 설정 (채널 정보, 동기화 간격, 필터 등)
|
||||||
|
- `bot_x` - X 봇 설정 (username, 동기화 간격 등)
|
||||||
|
|
||||||
#### 이미지
|
#### 이미지
|
||||||
- `images` - 이미지 메타데이터 (3개 해상도 URL)
|
- `images` - 이미지 메타데이터 (3개 해상도 URL)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -270,8 +270,8 @@ export const BotTableRow = memo(function BotTableRow({
|
||||||
{bot.status === 'running' ? <Square size={16} /> : <Play size={16} />}
|
{bot.status === 'running' ? <Square size={16} /> : <Play size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/* 수정 (YouTube만) */}
|
{/* 수정 (YouTube, X) */}
|
||||||
{bot.type === 'youtube' && onEdit && (
|
{(bot.type === 'youtube' || bot.type === 'x') && onEdit && (
|
||||||
<Tooltip text="수정">
|
<Tooltip text="수정">
|
||||||
<button
|
<button
|
||||||
onClick={() => onEdit(bot)}
|
onClick={() => onEdit(bot)}
|
||||||
|
|
@ -281,8 +281,8 @@ export const BotTableRow = memo(function BotTableRow({
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{/* 삭제 (YouTube만) */}
|
{/* 삭제 (YouTube, X) */}
|
||||||
{bot.type === 'youtube' && onDelete && (
|
{(bot.type === 'youtube' || bot.type === 'x') && onDelete && (
|
||||||
<Tooltip text="삭제">
|
<Tooltip text="삭제">
|
||||||
<button
|
<button
|
||||||
onClick={() => onDelete(bot)}
|
onClick={() => onDelete(bot)}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useState, useEffect, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Twitter, Search, X, ChevronDown, Loader2 } from 'lucide-react';
|
import { Twitter, Search, X, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
|
||||||
import { getXBot, createXBot, updateXBot, lookupXProfile } from '@/api/admin/bots';
|
import { getXBot, createXBot, updateXBot, lookupXProfile } from '@/api/admin/bots';
|
||||||
|
|
||||||
// 동기화 간격 옵션
|
// 동기화 간격 옵션
|
||||||
|
|
@ -127,6 +127,11 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
const [interval, setInterval] = useState(1);
|
const [interval, setInterval] = useState(1);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// 고급 설정
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
const [textFilters, setTextFilters] = useState([]);
|
||||||
|
const [filterInput, setFilterInput] = useState('');
|
||||||
|
|
||||||
// X 봇 상세 조회 (수정 모드)
|
// X 봇 상세 조회 (수정 모드)
|
||||||
const { data: bot, isLoading: botLoading } = useQuery({
|
const { data: bot, isLoading: botLoading } = useQuery({
|
||||||
queryKey: ['admin', 'x-bot', botId],
|
queryKey: ['admin', 'x-bot', botId],
|
||||||
|
|
@ -148,11 +153,16 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
avatarUrl: bot.avatar_url,
|
avatarUrl: bot.avatar_url,
|
||||||
});
|
});
|
||||||
setInterval(bot.cron_interval || 1);
|
setInterval(bot.cron_interval || 1);
|
||||||
|
setTextFilters(bot.text_filters || []);
|
||||||
|
setShowAdvanced((bot.text_filters && bot.text_filters.length > 0) || false);
|
||||||
} else if (!botId) {
|
} else if (!botId) {
|
||||||
// 추가 모드
|
// 추가 모드
|
||||||
setUsername('');
|
setUsername('');
|
||||||
setProfileInfo(null);
|
setProfileInfo(null);
|
||||||
setInterval(1);
|
setInterval(1);
|
||||||
|
setTextFilters([]);
|
||||||
|
setFilterInput('');
|
||||||
|
setShowAdvanced(false);
|
||||||
}
|
}
|
||||||
}, [isOpen, bot, botId]);
|
}, [isOpen, bot, botId]);
|
||||||
|
|
||||||
|
|
@ -186,6 +196,7 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
username: profileInfo.username,
|
username: profileInfo.username,
|
||||||
display_name: profileInfo.displayName,
|
display_name: profileInfo.displayName,
|
||||||
avatar_url: profileInfo.avatarUrl,
|
avatar_url: profileInfo.avatarUrl,
|
||||||
|
text_filters: textFilters.length > 0 ? textFilters : null,
|
||||||
cron_interval: interval,
|
cron_interval: interval,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -319,6 +330,67 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
placeholder="간격 선택"
|
placeholder="간격 선택"
|
||||||
/>
|
/>
|
||||||
</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]">
|
||||||
|
{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>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue