feat: YouTube API 할당량 경고 시스템 구현
- RSS 방식에서 YouTube API 방식으로 변경 (최근 10개 영상 조회) - rss_url 컬럼 삭제 - Google Cloud Webhook으로 할당량 경고 수신 - 95% 도달 시 봇 자동 중지 - LA 시간 자정(할당량 리셋)에 봇 자동 재시작 - 봇 관리 페이지에 경고 배너 표시
This commit is contained in:
parent
030c495c01
commit
20546599cc
5 changed files with 316 additions and 11 deletions
|
|
@ -1748,7 +1748,7 @@ router.delete("/schedules/:id", authenticateToken, async (req, res) => {
|
||||||
router.get("/bots", authenticateToken, async (req, res) => {
|
router.get("/bots", authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [bots] = await pool.query(`
|
const [bots] = await pool.query(`
|
||||||
SELECT b.*, c.channel_id, c.rss_url, c.channel_name
|
SELECT b.*, c.channel_id, c.channel_name
|
||||||
FROM bots b
|
FROM bots b
|
||||||
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
|
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
|
||||||
ORDER BY b.id ASC
|
ORDER BY b.id ASC
|
||||||
|
|
@ -1806,4 +1806,154 @@ router.post("/bots/:id/sync-all", authenticateToken, async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// YouTube API 할당량 경고 Webhook
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 메모리에 경고 상태 저장 (서버 재시작 시 초기화)
|
||||||
|
let quotaWarning = {
|
||||||
|
active: false,
|
||||||
|
timestamp: null,
|
||||||
|
message: null,
|
||||||
|
stoppedBots: [], // 할당량 초과로 중지된 봇 ID 목록
|
||||||
|
};
|
||||||
|
|
||||||
|
// 자정 재시작 타이머
|
||||||
|
let quotaResetTimer = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자정(LA 시간)에 봇 재시작 예약
|
||||||
|
* YouTube 할당량은 LA 태평양 시간 자정에 리셋됨
|
||||||
|
*/
|
||||||
|
async function scheduleQuotaReset() {
|
||||||
|
// 기존 타이머 취소
|
||||||
|
if (quotaResetTimer) {
|
||||||
|
clearTimeout(quotaResetTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LA 시간으로 다음 자정 계산
|
||||||
|
const now = new Date();
|
||||||
|
const laTime = new Date(
|
||||||
|
now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" })
|
||||||
|
);
|
||||||
|
const tomorrow = new Date(laTime);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
tomorrow.setHours(0, 1, 0, 0); // 자정 1분 후 (안전 마진)
|
||||||
|
|
||||||
|
// 현재 LA 시간과 다음 자정까지의 밀리초 계산
|
||||||
|
const nowLA = new Date(
|
||||||
|
now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" })
|
||||||
|
);
|
||||||
|
const msUntilReset = tomorrow.getTime() - nowLA.getTime();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Quota Reset] ${Math.round(
|
||||||
|
msUntilReset / 1000 / 60
|
||||||
|
)}분 후 봇 재시작 예약됨`
|
||||||
|
);
|
||||||
|
|
||||||
|
quotaResetTimer = setTimeout(async () => {
|
||||||
|
console.log("[Quota Reset] 할당량 리셋 시간 도달, 봇 재시작 중...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 할당량 초과로 중지된 봇들만 재시작
|
||||||
|
for (const botId of quotaWarning.stoppedBots) {
|
||||||
|
await startBot(botId);
|
||||||
|
console.log(`[Quota Reset] Bot ${botId} 재시작됨`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 경고 상태 초기화
|
||||||
|
quotaWarning = {
|
||||||
|
active: false,
|
||||||
|
timestamp: null,
|
||||||
|
message: null,
|
||||||
|
stoppedBots: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("[Quota Reset] 모든 봇 재시작 완료, 경고 상태 초기화");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Quota Reset] 봇 재시작 오류:", error.message);
|
||||||
|
}
|
||||||
|
}, msUntilReset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webhook 인증 정보
|
||||||
|
const WEBHOOK_USERNAME = "fromis9_quota_webhook";
|
||||||
|
const WEBHOOK_PASSWORD = "Qw8$kLm3nP2xVr7tYz!9";
|
||||||
|
|
||||||
|
// Basic Auth 검증 미들웨어
|
||||||
|
const verifyWebhookAuth = (req, res, next) => {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith("Basic ")) {
|
||||||
|
return res.status(401).json({ error: "인증이 필요합니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Credentials = authHeader.split(" ")[1];
|
||||||
|
const credentials = Buffer.from(base64Credentials, "base64").toString(
|
||||||
|
"utf-8"
|
||||||
|
);
|
||||||
|
const [username, password] = credentials.split(":");
|
||||||
|
|
||||||
|
if (username !== WEBHOOK_USERNAME || password !== WEBHOOK_PASSWORD) {
|
||||||
|
return res.status(401).json({ error: "인증 실패" });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Google Cloud 할당량 경고 Webhook 수신
|
||||||
|
router.post("/quota-alert", verifyWebhookAuth, async (req, res) => {
|
||||||
|
console.log("[Quota Alert] Google Cloud에서 할당량 경고 수신:", req.body);
|
||||||
|
|
||||||
|
quotaWarning = {
|
||||||
|
active: true,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
message:
|
||||||
|
"일일 할당량의 95%를 사용했습니다. (9,500 / 10,000 units) - 봇이 자동 중지되었습니다.",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모든 실행 중인 봇 중지
|
||||||
|
try {
|
||||||
|
const [runningBots] = await pool.query(
|
||||||
|
"SELECT id, name FROM bots WHERE status = 'running'"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 중지된 봇 ID 저장 (자정에 재시작용)
|
||||||
|
quotaWarning.stoppedBots = runningBots.map((bot) => bot.id);
|
||||||
|
|
||||||
|
for (const bot of runningBots) {
|
||||||
|
await stopBot(bot.id);
|
||||||
|
console.log(`[Quota Alert] Bot ${bot.name} 중지됨`);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`[Quota Alert] ${runningBots.length}개 봇이 할당량 초과로 중지됨`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 자정에 봇 재시작 예약 (LA 시간 기준 = YouTube 할당량 리셋 시간)
|
||||||
|
scheduleQuotaReset();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Quota Alert] 봇 중지 오류:", error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
res
|
||||||
|
.status(200)
|
||||||
|
.json({ success: true, message: "경고가 등록되고 봇이 중지되었습니다." });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 할당량 경고 상태 조회 (프론트엔드용)
|
||||||
|
router.get("/quota-warning", authenticateToken, (req, res) => {
|
||||||
|
res.json(quotaWarning);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 할당량 경고 해제 (수동)
|
||||||
|
router.delete("/quota-warning", authenticateToken, (req, res) => {
|
||||||
|
quotaWarning = {
|
||||||
|
active: false,
|
||||||
|
timestamp: null,
|
||||||
|
message: null,
|
||||||
|
};
|
||||||
|
res.json({ success: true, message: "경고가 해제되었습니다." });
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,83 @@ function parseDuration(duration) {
|
||||||
return hours * 3600 + minutes * 60 + seconds;
|
return hours * 3600 + minutes * 60 + seconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube API로 최근 N개 영상 수집 (정기 동기화용)
|
||||||
|
* @param {string} channelId - 채널 ID
|
||||||
|
* @param {number} maxResults - 조회할 영상 수 (기본 10)
|
||||||
|
*/
|
||||||
|
export async function fetchRecentVideosFromAPI(channelId, maxResults = 10) {
|
||||||
|
const videos = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 채널의 업로드 플레이리스트 ID 조회
|
||||||
|
const channelResponse = await fetch(
|
||||||
|
`https://www.googleapis.com/youtube/v3/channels?part=contentDetails&id=${channelId}&key=${YOUTUBE_API_KEY}`
|
||||||
|
);
|
||||||
|
const channelData = await channelResponse.json();
|
||||||
|
|
||||||
|
if (!channelData.items || channelData.items.length === 0) {
|
||||||
|
throw new Error("채널을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadsPlaylistId =
|
||||||
|
channelData.items[0].contentDetails.relatedPlaylists.uploads;
|
||||||
|
|
||||||
|
// 플레이리스트 아이템 조회 (최근 N개만)
|
||||||
|
const url = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&playlistId=${uploadsPlaylistId}&maxResults=${maxResults}&key=${YOUTUBE_API_KEY}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 영상 ID 목록 추출
|
||||||
|
const videoIds = data.items.map((item) => item.snippet.resourceId.videoId);
|
||||||
|
|
||||||
|
// videos API로 duration 조회 (Shorts 판별용)
|
||||||
|
const videosResponse = await fetch(
|
||||||
|
`https://www.googleapis.com/youtube/v3/videos?part=contentDetails&id=${videoIds.join(
|
||||||
|
","
|
||||||
|
)}&key=${YOUTUBE_API_KEY}`
|
||||||
|
);
|
||||||
|
const videosData = await videosResponse.json();
|
||||||
|
|
||||||
|
// duration으로 Shorts 판별 맵 생성
|
||||||
|
const durationMap = {};
|
||||||
|
if (videosData.items) {
|
||||||
|
for (const v of videosData.items) {
|
||||||
|
const duration = v.contentDetails.duration;
|
||||||
|
const seconds = parseDuration(duration);
|
||||||
|
durationMap[v.id] = seconds <= 60 ? "shorts" : "video";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of data.items) {
|
||||||
|
const snippet = item.snippet;
|
||||||
|
const videoId = snippet.resourceId.videoId;
|
||||||
|
const publishedAt = toKST(new Date(snippet.publishedAt));
|
||||||
|
const videoType = durationMap[videoId] || "video";
|
||||||
|
|
||||||
|
videos.push({
|
||||||
|
videoId,
|
||||||
|
title: snippet.title,
|
||||||
|
description: snippet.description || "",
|
||||||
|
publishedAt,
|
||||||
|
date: formatDate(publishedAt),
|
||||||
|
time: formatTime(publishedAt),
|
||||||
|
videoUrl: getVideoUrl(videoId, videoType),
|
||||||
|
videoType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return videos;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("YouTube API 오류:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* YouTube API로 전체 영상 수집 (초기 동기화용)
|
* YouTube API로 전체 영상 수집 (초기 동기화용)
|
||||||
* Shorts 판별: duration이 60초 이하이면 Shorts
|
* Shorts 판별: duration이 60초 이하이면 Shorts
|
||||||
|
|
@ -356,14 +433,14 @@ function extractMemberIdsFromDescription(description, memberNameMap) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 봇의 새 영상 동기화 (RSS 기반)
|
* 봇의 새 영상 동기화 (YouTube API 기반)
|
||||||
*/
|
*/
|
||||||
export async function syncNewVideos(botId) {
|
export async function syncNewVideos(botId) {
|
||||||
try {
|
try {
|
||||||
// 봇 정보 조회 (bot_youtube_config 조인)
|
// 봇 정보 조회 (bot_youtube_config 조인)
|
||||||
const [bots] = await pool.query(
|
const [bots] = await pool.query(
|
||||||
`
|
`
|
||||||
SELECT b.*, c.channel_id, c.rss_url
|
SELECT b.*, c.channel_id
|
||||||
FROM bots b
|
FROM bots b
|
||||||
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
|
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
|
||||||
WHERE b.id = ?
|
WHERE b.id = ?
|
||||||
|
|
@ -377,8 +454,8 @@ export async function syncNewVideos(botId) {
|
||||||
|
|
||||||
const bot = bots[0];
|
const bot = bots[0];
|
||||||
|
|
||||||
if (!bot.rss_url) {
|
if (!bot.channel_id) {
|
||||||
throw new Error("RSS URL이 설정되지 않았습니다.");
|
throw new Error("Channel ID가 설정되지 않았습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 봇별 커스텀 설정 조회
|
// 봇별 커스텀 설정 조회
|
||||||
|
|
@ -386,8 +463,8 @@ export async function syncNewVideos(botId) {
|
||||||
|
|
||||||
const categoryId = await getYoutubeCategory();
|
const categoryId = await getYoutubeCategory();
|
||||||
|
|
||||||
// RSS 피드 파싱
|
// YouTube API로 최근 10개 영상 조회
|
||||||
const videos = await parseRSSFeed(bot.rss_url);
|
const videos = await fetchRecentVideosFromAPI(bot.channel_id, 10);
|
||||||
let addedCount = 0;
|
let addedCount = 0;
|
||||||
|
|
||||||
// 멤버 추출을 위한 이름 맵 조회 (필요 시)
|
// 멤버 추출을 위한 이름 맵 조회 (필요 시)
|
||||||
|
|
@ -467,7 +544,7 @@ export async function syncAllVideos(botId) {
|
||||||
// 봇 정보 조회 (bot_youtube_config 조인)
|
// 봇 정보 조회 (bot_youtube_config 조인)
|
||||||
const [bots] = await pool.query(
|
const [bots] = await pool.query(
|
||||||
`
|
`
|
||||||
SELECT b.*, c.channel_id, c.rss_url
|
SELECT b.*, c.channel_id
|
||||||
FROM bots b
|
FROM bots b
|
||||||
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
|
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
|
||||||
WHERE b.id = ?
|
WHERE b.id = ?
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,14 @@ function AdminSchedule() {
|
||||||
selectedCategories, setSelectedCategories,
|
selectedCategories, setSelectedCategories,
|
||||||
selectedDate, setSelectedDate,
|
selectedDate, setSelectedDate,
|
||||||
currentDate, setCurrentDate,
|
currentDate, setCurrentDate,
|
||||||
|
scrollPosition, setScrollPosition,
|
||||||
} = useScheduleStore();
|
} = useScheduleStore();
|
||||||
|
|
||||||
// 로컬 상태 (페이지 이동 시 유지할 필요 없는 것들)
|
// 로컬 상태 (페이지 이동 시 유지할 필요 없는 것들)
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [toast, setToast] = useState(null);
|
const [toast, setToast] = useState(null);
|
||||||
|
const scrollContainerRef = useRef(null);
|
||||||
const SEARCH_LIMIT = 5; // 테스트용 5개
|
const SEARCH_LIMIT = 5; // 테스트용 5개
|
||||||
|
|
||||||
// Intersection Observer for infinite scroll
|
// Intersection Observer for infinite scroll
|
||||||
|
|
@ -85,9 +87,9 @@ function AdminSchedule() {
|
||||||
}
|
}
|
||||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]);
|
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]);
|
||||||
|
|
||||||
// selectedDate가 없으면 오늘 날짜로 초기화
|
// selectedDate가 undefined이면 오늘 날짜로 초기화 (null은 전체보기이므로 유지)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedDate) {
|
if (selectedDate === undefined) {
|
||||||
setSelectedDate(getTodayKST());
|
setSelectedDate(getTodayKST());
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -236,6 +238,18 @@ function AdminSchedule() {
|
||||||
searchSchedules(searchTerm);
|
searchSchedules(searchTerm);
|
||||||
}
|
}
|
||||||
}, []); // 컴포넌트 마운트 시에만
|
}, []); // 컴포넌트 마운트 시에만
|
||||||
|
|
||||||
|
// 스크롤 위치 복원
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollContainerRef.current && scrollPosition > 0) {
|
||||||
|
scrollContainerRef.current.scrollTop = scrollPosition;
|
||||||
|
}
|
||||||
|
}, [loading]); // 로딩이 끝나면 스크롤 복원
|
||||||
|
|
||||||
|
// 스크롤 위치 저장
|
||||||
|
const handleScroll = (e) => {
|
||||||
|
setScrollPosition(e.target.scrollTop);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// 카테고리 로드 함수
|
// 카테고리 로드 함수
|
||||||
|
|
@ -1019,6 +1033,8 @@ function AdminSchedule() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
id="adminScheduleScrollContainer"
|
id="adminScheduleScrollContainer"
|
||||||
className="max-h-[calc(100vh-280px)] overflow-y-auto divide-y divide-gray-100 py-2"
|
className="max-h-[calc(100vh-280px)] overflow-y-auto divide-y divide-gray-100 py-2"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ function AdminScheduleBots() {
|
||||||
const [bots, setBots] = useState([]);
|
const [bots, setBots] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [syncing, setSyncing] = useState(null); // 동기화 중인 봇 ID
|
const [syncing, setSyncing] = useState(null); // 동기화 중인 봇 ID
|
||||||
|
const [quotaWarning, setQuotaWarning] = useState(null); // 할당량 경고 상태
|
||||||
|
|
||||||
// Toast 자동 숨김
|
// Toast 자동 숨김
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -35,6 +36,7 @@ function AdminScheduleBots() {
|
||||||
|
|
||||||
setUser(JSON.parse(userData));
|
setUser(JSON.parse(userData));
|
||||||
fetchBots();
|
fetchBots();
|
||||||
|
fetchQuotaWarning();
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
// 봇 목록 조회
|
// 봇 목록 조회
|
||||||
|
|
@ -58,6 +60,38 @@ function AdminScheduleBots() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 할당량 경고 상태 조회
|
||||||
|
const fetchQuotaWarning = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('adminToken');
|
||||||
|
const response = await fetch('/api/admin/quota-warning', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.active) {
|
||||||
|
setQuotaWarning(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('할당량 경고 조회 오류:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 할당량 경고 해제
|
||||||
|
const dismissQuotaWarning = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('adminToken');
|
||||||
|
await fetch('/api/admin/quota-warning', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
setQuotaWarning(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('할당량 경고 해제 오류:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem('adminToken');
|
localStorage.removeItem('adminToken');
|
||||||
localStorage.removeItem('adminUser');
|
localStorage.removeItem('adminUser');
|
||||||
|
|
@ -265,6 +299,29 @@ function AdminScheduleBots() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* API 할당량 경고 배너 */}
|
||||||
|
{quotaWarning && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-8 flex items-start justify-between">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<XCircle size={18} className="text-red-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-red-700">YouTube API 할당량 경고</h3>
|
||||||
|
<p className="text-sm text-red-600 mt-0.5">
|
||||||
|
{quotaWarning.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={dismissQuotaWarning}
|
||||||
|
className="text-red-400 hover:text-red-600 transition-colors text-sm px-2 py-1"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 봇 목록 */}
|
{/* 봇 목록 */}
|
||||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
<div 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">
|
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,12 @@ const useScheduleStore = create((set) => ({
|
||||||
|
|
||||||
// 필터 및 선택
|
// 필터 및 선택
|
||||||
selectedCategories: [],
|
selectedCategories: [],
|
||||||
selectedDate: null, // null이면 getTodayKST() 사용
|
selectedDate: null, // null이면 전체보기, undefined이면 getTodayKST() 사용
|
||||||
currentDate: new Date(),
|
currentDate: new Date(),
|
||||||
|
|
||||||
|
// 스크롤 위치
|
||||||
|
scrollPosition: 0,
|
||||||
|
|
||||||
// 상태 업데이트 함수
|
// 상태 업데이트 함수
|
||||||
setSearchInput: (value) => set({ searchInput: value }),
|
setSearchInput: (value) => set({ searchInput: value }),
|
||||||
setSearchTerm: (value) => set({ searchTerm: value }),
|
setSearchTerm: (value) => set({ searchTerm: value }),
|
||||||
|
|
@ -20,6 +23,7 @@ const useScheduleStore = create((set) => ({
|
||||||
setSelectedCategories: (value) => set({ selectedCategories: value }),
|
setSelectedCategories: (value) => set({ selectedCategories: value }),
|
||||||
setSelectedDate: (value) => set({ selectedDate: value }),
|
setSelectedDate: (value) => set({ selectedDate: value }),
|
||||||
setCurrentDate: (value) => set({ currentDate: value }),
|
setCurrentDate: (value) => set({ currentDate: value }),
|
||||||
|
setScrollPosition: (value) => set({ scrollPosition: value }),
|
||||||
|
|
||||||
// 상태 초기화
|
// 상태 초기화
|
||||||
reset: () =>
|
reset: () =>
|
||||||
|
|
@ -30,6 +34,7 @@ const useScheduleStore = create((set) => ({
|
||||||
selectedCategories: [],
|
selectedCategories: [],
|
||||||
selectedDate: null,
|
selectedDate: null,
|
||||||
currentDate: new Date(),
|
currentDate: new Date(),
|
||||||
|
scrollPosition: 0,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue