Compare commits

...

75 commits

Author SHA1 Message Date
9d18449d3a feat: 생일 축하 다이얼로그 추가 (PC/모바일)
생일 당일 접속 시 폭죽과 함께 멤버 사진, HAPPY OOO DAY 제목,
날짜를 보여주는 축하 다이얼로그 표시. 데뷔 다이얼로그와 동일하게
localStorage로 하루 1회만 표시.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:39:55 +09:00
8effebf681 fix: 로그 시간 KST 중복 보정, 일정 카운트 애니메이션, HMR 오버레이 비활성화
- 로그 formatDateTime에서 UTC 메서드 사용하여 KST 이중 변환 방지
- 일반 일정 페이지 n개 일정 텍스트에 모드 전환 애니메이션 적용
- Vite HMR 오버레이 비활성화 (외부 봇 malformed URI 에러 방지)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:49:13 +09:00
159dd5c000 fix(admin): localStorage 토큰 조회를 useAuthStore로 통일
localStorage.getItem("adminToken")이 null을 반환하여 401 인증
에러가 발생하던 문제 수정. Zustand auth-storage에서 토큰을 올바르게
조회하도록 7개 파일 수정.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:44:24 +09:00
4005228270 style(admin): 활동 로그 테이블 컬럼 비율 조정
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:28:53 +09:00
aa95f737ba refactor(admin): 활동 로그 컴포넌트 분리 및 빈 상세정보 처리
Logs.jsx에서 상수/유틸과 다이얼로그를 components/pc/admin/log/로 분리하여
프로젝트 구조 패턴에 맞춤. 빈 객체 {} details가 표시되던 버그 수정.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:49:14 +09:00
abf71d97d7 docs: 새 기능 추가 시 logActivity 호출 필수 안내 추가
development.md에 로그가 자동 수집이 아님을 명시하고,
CLAUDE.md 작업 주의사항에 활동 로그 필수 항목 추가.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:23:00 +09:00
607a652c2b fix(admin): 활동 로그 필터 UI 개선
- 카테고리를 하드코딩 대신 DB에서 조회하도록 변경 (GET /admin/logs/categories)
- 카테고리 칩을 체크박스 멀티셀렉트 드롭다운으로 교체
- 카테고리가 없을 때 드롭다운 비활성화
- DatePicker에 min/max/compact prop 추가 (날짜 범위 제한, 높이 통일)
- 날짜 선택 칸 너비 축소, 초기화 버튼 여백 축소

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:16:52 +09:00
aa6c05e6b5 docs: 활동 로그 시스템 문서 업데이트
api.md에 GET /admin/logs 명세 추가, architecture.md에
logs 테이블/파일 추가, development.md에 로그 시스템 가이드 추가,
logs.md를 실제 구현 결과에 맞게 갱신.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:08:35 +09:00
414b798914 feat(frontend): 활동 로그 API 연동 및 더미데이터 제거
더미데이터를 실제 API 호출(React Query)로 교체.
서버 사이드 필터링/페이지네이션, 검색 디바운스(300ms),
keepPreviousData로 페이지 전환 시 깜빡임 방지,
페이지 수가 많을 때 생략 부호(...) 페이지네이션 추가.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:06:39 +09:00
1f1d6987d1 feat(backend): 관리자/봇 라우트에 logActivity 호출 추가
12개 관리자 라우트와 3개 봇 서비스 파일에 활동 로그 기록 추가.
관리자 작업(일정/앨범/멤버/봇 CRUD)과 봇 동기화(완료/에러)를
logs 테이블에 fire-and-forget으로 기록.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:04:07 +09:00
357fd7fc88 feat(backend): 활동 로그 유틸리티 및 API 엔드포인트 추가
- logActivity() fire-and-forget 유틸리티 함수
- GET /api/admin/logs 엔드포인트 (필터/페이지네이션)
- 라우트 등록

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:56:44 +09:00
c6332c4f96 refactor: activity_logs → logs로 네이밍 통일
- DB 테이블: activity_logs → logs
- 문서: activity-logs.md → logs.md
- 컴포넌트: ActivityLogs.jsx → Logs.jsx
- 라우트 import 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:54:08 +09:00
01cf083da2 feat(admin): 활동 로그 라우트/메뉴 연결 및 UI 개선
- /admin/logs 라우트 등록, 대시보드 메뉴에 활동 로그 항목 추가
- 테이블 컬럼 비율 조정 (내용 컬럼 공간 확보)
- 날짜 선택기를 커스텀 DatePicker로 교체
- 행위자 드롭다운에 애니메이션 추가
- reorder 액션 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:40:27 +09:00
c4cd0dec30 feat(admin): 활동 로그 페이지 컴포넌트 및 설계 문서 추가
더미데이터로 활동 로그 UI 구현 (필터, 테이블, 페이지네이션)
라우트/메뉴 연결은 다음 단계에서 진행

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:16:59 +09:00
9335720fa8 docs: YouTube 봇 API 최적화 관련 문서 업데이트
- architecture.md: YouTube 서비스 파일 구조 추가
- development.md: 동기화 흐름, API 할당량, 주요 함수 목록 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:55:50 +09:00
9bb6aedca7 chore: PubSubHubbub 환경변수 제거
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:54:18 +09:00
45adaaf0dc refactor(youtube): API 호출 최적화 - 새 영상만 상세 조회
기존: 매 폴링마다 activities.list + videos.list (2 units)
변경: activities.list로 videoId 확인 후 DB에 없는 새 영상만 videos.list 호출
결과: 일일 API 사용량 약 50% 감소 (1분 간격 3채널도 가능)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:52:42 +09:00
f8acb5450f fix(bot): 동시성 중복 INSERT 시 ER_DUP_ENTRY 에러 무시 처리
YouTube/X 봇의 영상 저장 트랜잭션에서 UNIQUE 제약 위반 발생 시
크래시 대신 null을 반환하여 gracefully 무시하도록 변경.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:28:27 +09:00
3feb23f67f fix(scheduler): 봇 중지 상태가 서버 재시작 후 유지되지 않는 문제 수정
DB 조회 시 WHERE enabled = 1 필터를 제거하여 비활성 봇도 시스템에서
인식되도록 변경. 이전에는 비활성 봇이 목록/검색에서 완전히 제외되어
재시작 불가 및 관리 UI에서 사라지는 문제가 있었음.
PUT 엔드포인트의 stopBot/startBot 조건 로직도 함께 정리.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:27:16 +09:00
91d4442d30 docs: X 봇 extract_youtube 관련 문서 및 스키마 업데이트
- bot_x.sql에 누락된 컬럼 추가 (text_filters, include_retweets, extract_youtube)
- api.md에 X 봇 API 응답 스키마 및 필드 설명 추가
- architecture.md bot_x 테이블 설명 구체화
- development.md API 클라이언트 함수 목록 보완

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 13:12:00 +09:00
d4ed9ef66b chore: x-bots-plan.md 삭제
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 22:56:15 +09:00
ba7def935c feat(x-bot): YouTube 영상 추출 옵션 추가
X 봇 설정에서 트윗 내 YouTube 링크 자동 추출 기능을 온/오프 가능하게 함:
- bot_x 테이블에 extract_youtube 컬럼 추가 (기본값: false)
- 고급 설정에 "YouTube 영상 추출" 토글 추가
- extractYoutube가 true일 때만 YouTube 일정 자동 생성

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 22:40:52 +09:00
8ecc4e6263 feat(mobile): 예정된 YouTube 영상 상세 페이지 구현
PC와 동일하게 videoId가 없는 예정 일정에 대한 UI 추가:
- MobileScheduledPlaceholder 컴포넌트 (배너 이미지/패턴 배경)
- "예정" 배지 표시
- 예정 일정일 때 "YouTube에서 보기" 버튼 숨김

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 22:34:10 +09:00
406098d1b9 fix(schedule): X 일정에서 단축 URL 인식 개선
- react-linkify 대신 커스텀 linkifyText 함수 사용
- bit.ly, t.co, youtu.be 등 단축 URL 도메인 지원
- PC, 모바일 양쪽 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 22:28:41 +09:00
d01f7e60dc fix(schedule): YouTube 일정 상세 조회 쿼리 단순화
- 불필요한 channel_id 선택 제거
- banner_url만 조회하도록 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 22:23:24 +09:00
f90a5f4b17 refactor(members): PC 멤버 페이지 2/3 배열 레이아웃으로 변경
- 5열에서 2/3 배열로 변경
- 카드 크기 축소 (max-w-3xl)
- 첫 줄 2명, 둘째 줄 3명 가운데 정렬

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 22:20:56 +09:00
d50488d7e3 refactor(members): PC 멤버 페이지 3열 레이아웃 + 마지막 줄 가운데 정렬
- 5열에서 3열 레이아웃으로 변경
- max-w-4xl로 카드 크기 축소
- flexbox로 마지막 줄 가운데 정렬

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 22:15:51 +09:00
45da9c6277 feat(members): 전 멤버 섹션 제거
- PC 멤버 페이지에서 전 멤버 섹션 제거
- 모바일 멤버 페이지에서 전 멤버 섹션 제거

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 22:10:47 +09:00
9163ade56d feat(schedule): 달력 및 일정 목록 영역에 진입 애니메이션 추가
- 왼쪽 영역(달력, 카테고리): 왼쪽에서 슬라이드 인
- 오른쪽 영역(일정 목록): 오른쪽에서 슬라이드 인
- 타이틀 애니메이션과 연속되도록 delay 적용

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 22:09:11 +09:00
7d140aa1f3 fix(x-bot): 동기화 결과에 total 필드 추가
- syncNewTweets(), syncAllTweets()에 total 필드 추가
- 프론트엔드 토스트에서 undefined 표시되던 문제 해결

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 09:57:04 +09:00
c86cda00ae fix(meilisearch): 전체 동기화 시 DB에 없는 문서 삭제
- syncAllSchedules()에서 Meilisearch의 모든 문서 ID 조회
- DB에 없는 문서는 Meilisearch에서 삭제
- 삭제된 일정이 검색에 계속 나타나는 문제 해결

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 09:50:54 +09:00
294018c93b refactor(x-bot): x_profiles 테이블 제거, bot_x로 통합
- x_profiles 테이블 삭제 (bot_x에 프로필 정보 포함)
- saveProfile(), getProfile() 함수가 bot_x 테이블 사용하도록 수정
- Redis 캐시는 그대로 유지 (성능)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 09:42:26 +09:00
5d44434e36 feat(x-bot): 리트윗 옵션 및 고정 트윗 제외 기능 추가
- include_retweets 옵션으로 리트윗 포함 여부 설정 가능
- 고정된 트윗(pinned)은 기본적으로 파싱에서 제외
- XBotDialog에서 Twitter 아이콘을 X 아이콘으로 변경
- schedule_x의 username을 source_name으로 활용

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 09:37:57 +09:00
9ceef6c656 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>
2026-02-08 09:23:45 +09:00
eeb5e7234c feat(frontend): X 봇 관리 UI 추가
- XBotDialog 컴포넌트 생성
- ScheduleBots 페이지에 X 봇 추가/수정/삭제 통합
- X 섹션에 canAdd 활성화

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 09:12:38 +09:00
ec0e587434 feat(frontend): X 봇 API 클라이언트 함수 추가
- getXBot, lookupXProfile, createXBot, updateXBot, deleteXBot

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 09:08:52 +09:00
86769f1edc feat(admin): 봇 목록 API에 X 봇 상세 정보 추가
- 스키마에 X 봇 필드 추가 (username, display_name, avatar_url)
- X 봇 응답에 db_id, cron_interval 포함

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 23:56:45 +09:00
25c2b45cf5 feat(scheduler): X 봇 DB 기반 로드 추가
- getXBotsFromDB() 함수 추가
- getAllBots()에서 X 봇도 DB에서 로드
- config/bots.js에서 X 봇 제거 (meilisearch만 남음)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 23:51:38 +09:00
2355068c77 feat(admin): X 봇 CRUD API 추가
- POST /api/admin/x-bots/lookup: 프로필 조회
- GET /api/admin/x-bots: 목록 조회
- GET /api/admin/x-bots/🆔 상세 조회
- POST /api/admin/x-bots: 봇 추가
- PUT /api/admin/x-bots/🆔 봇 수정
- DELETE /api/admin/x-bots/🆔 봇 삭제

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 23:48:58 +09:00
535fbb6768 feat(x): Nitter 프로필 조회 함수 추가
- fetchProfile() 함수 추가: username으로 프로필 정보 조회
- displayName, avatarUrl 반환

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 20:08:59 +09:00
4f11e14b12 refactor(db): 봇 테이블 이름 통일 및 X 봇 스키마 추가
- youtube_bots → bot_youtube, x_bots → bot_x로 테이블 이름 변경
- bot_x 테이블 생성 및 시드 데이터 추가
- 관련 백엔드 코드에서 테이블 참조 업데이트
- X 봇 동적 관리 구현 계획 문서 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 19:52:41 +09:00
2e7fe697fc feat(admin): YouTube 봇 추가/수정/삭제 기능 완성
- 채널 핸들로 채널 정보 조회 API 추가 (POST /youtube-bots/lookup)
- getChannelByHandle 함수 추가 (YouTube API forHandle 사용)
- 봇 추가 시 채널 조회 후 배너 이미지 표시
- 봇 수정 API 스키마에 null 허용 추가
- 삭제 확인 다이얼로그 및 삭제 기능 구현
- 디버깅 로그 제거

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-07 10:51:45 +09:00
ec3839bcc7 feat(admin): YouTube 봇 CRUD API 및 수정 다이얼로그 개선
- YouTube 봇 전용 API 라우트 추가 (GET/POST/PUT/DELETE /api/admin/youtube-bots)
- 봇 목록 API에 YouTube 봇 상세 정보 포함 (db_id, channel_id 등)
- 수정 다이얼로그에서 useQuery로 봇 데이터 조회
- 채널 배너 이미지 표시 추가
- Fastify 스키마에 additionalProperties 설정으로 auto_schedule_config 정상 반환

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-07 10:43:06 +09:00
a8c12aa76d feat: YouTube 봇 DB 기반 관리로 마이그레이션
- YouTube 봇 설정을 bots.js에서 youtube_bots 테이블로 이동
- 봇 ID를 AUTO_INCREMENT로 변경 (youtube-{id} 형식)
- 고정 멤버 다중 선택 지원 (default_member_ids JSON)
- 제목 필터 다중 키워드 지원 (title_filters JSON)
- Redis 캐싱 제거 (Activities API 사용으로 불필요)
- 채널 배너 URL DB 저장 (youtube_bots.banner_url)
- YouTubeBotDialog UI 개선:
  - Portal 기반 드롭다운 (overflow 문제 해결)
  - AnimatePresence 애니메이션 적용
  - 다중 선택 컴포넌트 추가
  - 태그 입력 형태의 제목 필터
  - 뒷배경 클릭 방지

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-07 10:15:07 +09:00
730da864a4 fix: YouTube 봇 fetchRecentVideos를 Activities API로 변경
- playlistItems API 대신 Activities API 사용
- playlistItems는 새 영상 반영이 지연되는 문제가 있었음
- Activities API는 새 업로드를 즉시 반영함
- upload 외 다른 활동(좋아요, 플레이리스트 등)도 포함되므로 2배로 조회 후 필터링
- API 할당량 비용은 동일 (1 단위)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 21:58:38 +09:00
f3f99c7428 fix: 드롭다운 z-index 수정 및 YouTubeBotDialog 커스텀 드롭다운 적용
- WordItem, ScheduleDict 드롭다운 z-index를 z-20에서 z-40으로 변경
  (테이블 헤더의 z-30보다 높게 설정하여 가려지는 문제 해결)
- YouTubeBotDialog에 커스텀 Dropdown 컴포넌트 추가
- 네이티브 select 요소를 커스텀 드롭다운으로 교체
- 시간 선택을 위한 TIME_OPTIONS (00:00~23:00) 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 18:30:34 +09:00
3fa9f1520a feat: YouTube 봇 추가/수정 다이얼로그 UI 구현
- 채널 핸들 입력 및 조회 기능 (UI만)
- 동기화 간격 선택
- 예정 일정 자동 생성 설정 (요일, 시간, 제목 템플릿, 마감 요일)
- 고급 설정 (제목 필터, 멤버 추출)
- 추가/수정 모드 지원

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 18:22:56 +09:00
802aacd22e fix: 봇 테이블 액션 컬럼 너비 조정 (20% → 16%)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 18:18:38 +09:00
2417cd287d fix: 봇 테이블 액션 컬럼 너비 확대 (12% → 20%)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 18:16:48 +09:00
de3cb91191 feat: 봇 테이블 액션 버튼 완성
- 삭제 버튼 추가 (YouTube만)
- 버튼 순서: 전체 동기화 → 시작/정지 → 수정 → 삭제
- 모든 버튼에 Tooltip 적용

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 18:16:12 +09:00
0c9dd44c2b feat: 봇 테이블 액션 버튼 개선
- 전체 동기화 아이콘을 RotateCcw로 변경
- YouTube 봇에만 수정(Pencil) 버튼 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 18:12:22 +09:00
6b39cf043f fix: 봇 테이블 중간 4개 컬럼 너비 통일 (10%)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 18:09:57 +09:00
47afd68921 fix: 봇 테이블 컬럼 너비 고정
- table-fixed 적용으로 컬럼 너비 통일
- 각 컬럼에 퍼센트 너비 지정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 18:09:02 +09:00
e729d33aee refactor: 봇 관리 UI 통일 및 개선
- 모든 섹션에 테이블형 디자인 통일
- 섹션 헤더에서 "N개의 봇" 텍스트 제거
- Meilisearch 섹션에 전용 아이콘 적용

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 18:06:01 +09:00
b5118f2dea feat: 봇 관리 섹션별 다른 디자인 적용
- Meilisearch: 리스트형 (BotListItem) - 한 줄에 모든 정보
- YouTube: 미니 카드형 (BotMiniCard) - 호버시 액션 버튼
- X: 테이블형 (BotTableRow) - 표 형식으로 정보 비교

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 18:04:16 +09:00
dbfee503d5 fix: BotCard 구분 개선
- 테두리 및 그림자 강화
- 통계 영역 배경색 추가로 구분감 향상

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 18:01:49 +09:00
b3357e0663 refactor: BotCard 디자인 개선
- 타입별 아이콘 제거 (섹션 헤더에서 표시)
- 통계 정보 가로 배열로 컴팩트하게 변경
- 버튼을 하단에 플랫하게 배치
- 전체적으로 미니멀하고 깔끔한 스타일로 변경

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 18:01:15 +09:00
68027f0654 fix: X 섹션 아이콘 및 텍스트 수정
- Twitter 아이콘 → X 아이콘으로 변경
- "X (Twitter)" → "X"로 간소화

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 17:59:53 +09:00
1c04f4ed6d refactor: 봇 관리 페이지 타입별 섹션 분리
- Meilisearch, YouTube, X 세 섹션으로 분리
- 각 섹션에 아이콘 및 색상 적용
- YouTube 섹션에 "봇 추가" 버튼 추가 (기능은 추후 구현)
- YouTube 봇 동적 관리 계획서 추가 (docs/youtube-bots-plan.md)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 17:58:30 +09:00
46295a5f15 fix: Meilisearch 동기화 시간을 00시로 변경
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 18:06:07 +09:00
78eb513c28 Revert "fix: 봇 관리 페이지 시간 24시간제로 표시"
This reverts commit 7d56531bee.
2026-02-05 18:05:47 +09:00
7d56531bee fix: 봇 관리 페이지 시간 24시간제로 표시
- hour12: false 옵션 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 18:05:01 +09:00
47cd93173c fix: 예정 배지 세로 중앙 정렬
- items-start → items-center로 변경

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 18:25:36 +09:00
277c6a79c9 refactor: 예정 일정 placeholder UI 개선
- 유튜브 아이콘 제거
- 업로드 예정 텍스트 하단으로 이동
- 배경 오버레이를 하단 그라데이션으로 변경

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 18:24:55 +09:00
f01b2b8054 fix: 유튜브 채널 배너 이미지 고해상도로 변경
- bannerUrl에 =w2560 파라미터 추가하여 2560px 너비로 요청

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 18:22:45 +09:00
cb184e4fa5 feat: 유튜브 예정 일정에 채널 배너 이미지 표시
- YouTube API에서 채널 정보(배너 이미지) 조회 함수 추가
- 채널 정보 Redis 캐싱 (24시간)
- 일정 상세 API에 bannerUrl 필드 추가
- 예정 일정 placeholder에 배너 이미지 배경 표시

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 18:20:49 +09:00
eb7d2005b7 refactor: 예정 일정 placeholder 텍스트 변경
- "영상 준비 중" → "업로드 예정"으로 변경
- 불필요한 부연 설명 제거

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 18:14:35 +09:00
b16aa963cd feat: 유튜브 예정 일정 UI 추가
- PC YoutubeSection에 예정 일정 placeholder UI 추가
- 예정 일정일 경우 "예정" 배지 표시
- 영상 준비 중 placeholder 컴포넌트 추가
- 예정 일정에도 channelName 반환하도록 API 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 14:57:58 +09:00
e759d14ed6 feat: 스프 채널 다음 주 예정 일정 자동 생성 기능
- 새 영상(쇼츠 제외) 추가 시 다음 주 같은 요일 예정 일정 자동 생성
- 실제 영상 업로드 시 예정 일정을 실제 정보로 덮어씌움
- 금요일 00시까지 영상 없으면 예정 일정 삭제 + 다음 주 예정 일정 생성
- autoScheduleNext 설정: dayOfWeek, time, title, deadlineDayOfWeek, excludeShorts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 14:51:12 +09:00
9735206da7 feat: 콘서트 폼 프론트엔드-백엔드 연결
- createConcertSchedule API 함수 추가
- handleSubmit에서 FormData 구성 및 API 호출 구현
- 유효성 검사 및 저장 성공 시 목록으로 이동

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 14:13:53 +09:00
48f41c6db0 feat: 장소 검색 API 추가 (카카오/구글)
- 국내: 카카오맵 API (/api/admin/kakao/places)
- 해외: 구글 Places API (/api/admin/google/places)
- YOUTUBE_API_KEY를 GOOGLE_API_KEY로 통합

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 14:06:33 +09:00
65b1d931f3 feat: 콘서트 일정 저장 API 구현
콘서트 폼 데이터를 저장하는 백엔드 API 추가.
multipart/form-data로 포스터, 굿즈 이미지, 회차, 세트리스트를 처리하고
트랜잭션으로 관련 테이블에 일괄 저장 후 Meilisearch 동기화.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 11:48:50 +09:00
ad8406fdd7 feat: 세트리스트 섹션 및 곡 검색 다이얼로그 추가
- 세트리스트 섹션: 곡명, 앨범명, 참여 멤버 선택
- 곡 검색 다이얼로그: 앨범별 트랙 검색 및 다중 선택
- 직접 입력과 곡 검색 두 가지 방식으로 곡 추가 가능
- 공연 일정/세트리스트 추가 버튼을 하단으로 이동
- ConfirmDialog에 createPortal 적용
- 콘서트 정보 → 공연 정보로 명칭 변경

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 23:05:15 +09:00
169c584d31 feat: 콘서트 폼에 굿즈 섹션 추가
- 굿즈 이미지 다중 업로드 및 드래그 순서 변경
- 삭제 시 확인 다이얼로그 표시

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:20:01 +09:00
7f3fe7e251 feat: 콘서트 일정 추가 폼 UI 구현
- 콘서트 정보 섹션: 공연명, 포스터, 참여 멤버 선택
- 공연 일정 섹션: 다회차 지원 (날짜, 시간, 장소)
- VenueSearchDialog 컴포넌트 추가 (국내/해외 장소 검색)
- 회차 추가/삭제 애니메이션 적용

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 23:57:33 +09:00
81 changed files with 7438 additions and 671 deletions

7
.env
View file

@ -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

View file

@ -33,3 +33,4 @@ DB 및 외부 서비스 접근 정보는 `.env` 파일 참조:
## 작업 시 주의사항
- **문서 업데이트 필수**: 작업이 완료되면 항상 `docs/` 폴더의 관련 문서를 업데이트할 것
- **활동 로그 필수**: 새로운 관리자 라우트나 봇 기능을 추가할 때 `logActivity` 호출을 포함할 것 (자세한 사용법은 `docs/development.md` 참조)

15
backend/sql/bot_x.sql Normal file
View 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;

View 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);

View 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)
);

View 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';

View file

@ -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;

View file

@ -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,
},
];

View file

@ -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,

View file

@ -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,
});
// 앱 종료 시 모든 봇 정지

View file

@ -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,

View 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);
}
});
}

View 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);
}
});
}

View 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);
}
});
}

View 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 };
});
}

View file

@ -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}`);

View 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 };
});
}

View file

@ -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}`);

View file

@ -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;
});
}

View file

@ -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: '사진이 삭제되었습니다.' };
});
});

View file

@ -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: '티저가 삭제되었습니다.' };
});
});

View file

@ -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' });
}

View file

@ -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);

View file

@ -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);

View file

@ -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}`);

View file

@ -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 };
}

View file

@ -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 => ({

View file

@ -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

View file

@ -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(

View file

@ -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++;

View file

@ -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);
}
/**

View file

@ -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
View 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) {
// 로그 실패는 무시 — 비즈니스 로직에 영향 주지 않음
}
}

View file

@ -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

View file

@ -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)

View file

@ -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
View 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 | 노랑 |

View file

@ -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

View 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');
}

View file

@ -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

View 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}` : ''}`);
}

View 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;

View file

@ -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';

View file

@ -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;

View 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;

View 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;

View file

@ -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';

View file

@ -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
);
}

View file

@ -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}

View 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;

View file

@ -12,3 +12,6 @@ export * from './album';
// 봇 관련
export * from './bot';
// 로그 관련
export * from './log';

View 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>
);
}

View 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;
}

View file

@ -0,0 +1,2 @@
export * from './constants';
export { default as LogDetailDialog, ActorBadge, Summary } from './LogDetailDialog';

View file

@ -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}`,

View file

@ -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

View file

@ -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 && (

View file

@ -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}
/>
</>
);
}

View file

@ -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>

View file

@ -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) => ({

View file

@ -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 (

View 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;

View file

@ -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>
);

View file

@ -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={() => {

View file

@ -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();

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View 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;

View file

@ -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 (

View file

@ -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>
);

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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) {

View file

@ -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>
);

View file

@ -13,6 +13,9 @@ export default defineConfig({
host: true,
port: 80,
allowedHosts: true,
hmr: {
overlay: false,
},
proxy: {
"/api": {
target: "http://fromis9-backend:80",