Compare commits
105 Commits
b7ee9fe3fd
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
| e6659a416a | |||
| 3abd46c0fd | |||
| c42d3fe8d4 | |||
| 1e8542f6c7 | |||
| a11475db57 | |||
| bc2c020f71 | |||
| cd6072727f | |||
| 42ebd5a87c | |||
| 3b66a47316 | |||
| f7323a5b72 | |||
| ccf6d4e551 | |||
| a20315ce34 | |||
| 3fa4dbda3c | |||
| baf34dd7aa | |||
| 4ef76f6cce | |||
| 0bf1233e96 | |||
| ff7ac48c6b | |||
| 329141c732 | |||
| cd3c538eb7 | |||
| 9d2dfad512 | |||
| 42073a5bf3 | |||
| 6b2fcda2af | |||
| acac2cd20e | |||
| 95edc9d232 | |||
| ec22321d56 | |||
| a80b869878 | |||
| 93d5f49cdb | |||
| 3f5cd32c77 | |||
| 120c39a3ef | |||
| 08fce2d4f6 | |||
| 9c12de4593 | |||
| 53e9938903 | |||
| 522b7695aa | |||
| 9ffd7889e7 | |||
| 5bba880c23 | |||
| 4498124514 | |||
| b6748ecd27 | |||
| 397257cf3b | |||
| d38ee553c3 | |||
| 4acdc451c0 | |||
| f3b0b2c109 | |||
| 4281c1873f | |||
| 8a7b5e8a38 | |||
| 08981a292a | |||
| ed95f6678f | |||
| 1847771ad2 | |||
| 0f0ca8610d | |||
| 3f2fdb095c | |||
| 3e54b2c98d | |||
| 16b8cc59ae | |||
| a89de57b79 | |||
| 413dccb655 | |||
| d1526af32c | |||
| abd8762b5c | |||
| 8514232775 | |||
| 6c1f19e690 | |||
| 35ce362d20 | |||
| 11e4f00ae6 | |||
| b11d1c421d | |||
| f6d95264c3 | |||
| 7cbdbe6e8b | |||
| 573c0364bb | |||
| 7f42ff3594 | |||
| 1c331f209a | |||
| c87e764063 | |||
| 80fcb07fc0 | |||
| a9a6808005 | |||
| 0a0ab05e41 | |||
| f6e78ac0ca | |||
| 60f17ff3e0 | |||
| 344caace3a | |||
| 9e5521d784 | |||
| 3b3e4a1ee1 | |||
| a9d9540f61 | |||
| c68cee502a | |||
| 1bd680e47f | |||
| 60655f8ba9 | |||
| a50c6c8be2 | |||
| b88ae331d7 | |||
| a56923a6b3 | |||
| a6dd2ef747 | |||
| bebd55874c | |||
| 6cbdf95596 | |||
| 3e4f2e0934 | |||
| 31fc2dfb0d | |||
| 403046c4d0 | |||
| b03f438935 | |||
| 22a37cf6d9 | |||
| 6bd6cbd635 | |||
| 4c930c2cf8 | |||
| efeecadbef | |||
| a712a2f43b | |||
| ce245609f9 | |||
| 43904d033a | |||
| 379ad41e32 | |||
| f3de315272 | |||
| 71fe91cc85 | |||
| 7dd2cc9793 | |||
| f01a432329 | |||
| d4279f2e3b | |||
| 8207205418 | |||
| 95b3f2b37c | |||
| eab8ef295b | |||
| f11f9c529e | |||
| d24c04f9fa |
34
.claude/settings.json
Normal file
34
.claude/settings.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git status:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git show:*)",
|
||||
"Bash(git branch:*)",
|
||||
"Bash(git stash list:*)",
|
||||
"Bash(git remote -v)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(npm test:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npm ci:*)",
|
||||
"Bash(npm list:*)",
|
||||
"Bash(npm view:*)",
|
||||
"Bash(npm outdated:*)",
|
||||
"Bash(npx eslint:*)",
|
||||
"Bash(npx vite:*)",
|
||||
"Bash(node -v)",
|
||||
"Bash(ls:*)"
|
||||
],
|
||||
"deny": [
|
||||
"Read(.env)",
|
||||
"Read(.env.*)",
|
||||
"Read(**/.env)",
|
||||
"Read(**/.env.*)",
|
||||
"Read(**/credentials*)",
|
||||
"Read(**/secrets*)",
|
||||
"Read(**/*.pem)",
|
||||
"Read(**/*.key)"
|
||||
]
|
||||
}
|
||||
}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -22,3 +22,7 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.env.local
|
||||
|
||||
# Superpowers visual companion (mockup files)
|
||||
.superpowers/
|
||||
|
||||
26
CLAUDE.md
26
CLAUDE.md
@@ -17,7 +17,8 @@
|
||||
| `/lotto` | `Lotto` | 로또 추천/통계 |
|
||||
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
||||
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
|
||||
| `/realestate` | `Subscription` | 청약 자격·일정 관리 |
|
||||
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
|
||||
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
|
||||
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
||||
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
||||
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
|
||||
@@ -27,6 +28,7 @@
|
||||
| `/todo` | `Todo` | 태스크 보드 |
|
||||
| `/blog-lab` | `BlogMarketing` | 블로그 마케팅 수익화 대시보드 |
|
||||
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅) |
|
||||
| `/portfolio` | `Portfolio` | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개) |
|
||||
|
||||
라우트 정의: `src/routes.jsx` / 라우터 설정: `src/Router.jsx`
|
||||
|
||||
@@ -63,7 +65,7 @@ proxy: {
|
||||
}
|
||||
```
|
||||
|
||||
- `/api/*` → NAS 백엔드
|
||||
- `/api/*` → NAS 백엔드 (nginx가 서비스별 라우팅: lotto, personal, stock-lab, music-lab 등)
|
||||
- `/media/*` → NAS 미디어 파일 (여행 사진 `/media/travel/`, 음악 `/media/music/`)
|
||||
- 개발 서버 포트: **3007**
|
||||
|
||||
@@ -84,6 +86,12 @@ proxy: {
|
||||
| 주식 | GET | `/api/stock/news`, `/api/stock/indices` |
|
||||
| 트레이딩 | GET | `/api/trade/balance` |
|
||||
| 트레이딩 | POST | `/api/trade/order` |
|
||||
| 스크리너 | GET | `/api/stock/screener/nodes` |
|
||||
| 스크리너 | GET/PUT | `/api/stock/screener/settings` |
|
||||
| 스크리너 | POST | `/api/stock/screener/run` — body: `{ mode, asof?, weights?, ... }` |
|
||||
| 스크리너 | POST | `/api/stock/screener/snapshot/refresh` |
|
||||
| 스크리너 | GET | `/api/stock/screener/runs?limit=N` |
|
||||
| 스크리너 | GET | `/api/stock/screener/runs/:id` |
|
||||
| 포트폴리오 | GET/POST | `/api/portfolio` |
|
||||
| 포트폴리오 | PUT/DELETE | `/api/portfolio/:id` |
|
||||
| 예수금 | PUT | `/api/portfolio/cash` — body: `{ broker, cash }` |
|
||||
@@ -93,10 +101,10 @@ proxy: {
|
||||
| 실현손익 | GET | `/api/portfolio/sell-history?broker=X&days=N` — response: `{ records: [...] }` |
|
||||
| 실현손익 | POST/PUT | `/api/portfolio/sell-history`, `/api/portfolio/sell-history/:id` |
|
||||
| 실현손익 | DELETE | `/api/portfolio/sell-history/:id` |
|
||||
| TODO | GET/POST | `/api/todos` |
|
||||
| TODO | PUT/DELETE | `/api/todos/:id`, `/api/todos/done` |
|
||||
| 블로그 | GET/POST | `/api/blog/posts` |
|
||||
| 블로그 | PUT/DELETE | `/api/blog/posts/:id` |
|
||||
| TODO | GET/POST | `/api/todos` — personal 서비스 |
|
||||
| TODO | PUT/DELETE | `/api/todos/:id`, `/api/todos/done` — personal 서비스 |
|
||||
| 블로그 | GET/POST | `/api/blog/posts` — personal 서비스 |
|
||||
| 블로그 | PUT/DELETE | `/api/blog/posts/:id` — personal 서비스 |
|
||||
| AI 음악 | POST | `/api/music/generate` — body: `{ title, genre, moods, instruments, duration_sec, bpm, key, scale, prompt }` → `{ task_id }` |
|
||||
| AI 음악 | GET | `/api/music/status/:task_id` → `{ status, progress, message, audio_url?, error?, track? }` |
|
||||
| AI 음악 라이브러리 | GET/POST | `/api/music/library` — response: `{ tracks: [...] }` |
|
||||
@@ -112,8 +120,12 @@ proxy: {
|
||||
| 에이전트 | POST | `/api/agent-office/command`, `/api/agent-office/approve` |
|
||||
| 에이전트 | WS | `/api/agent-office/ws` |
|
||||
| 부동산 | GET | `/api/realestate/announcements`, `/api/realestate/matches` |
|
||||
| 부동산 | PUT | `/api/realestate/profile` |
|
||||
| 부동산 | GET | `/api/realestate/profile` — 프로필 조회 |
|
||||
| 부동산 | PUT | `/api/realestate/profile` — body: `{ preferred_districts: { "S": [...], "A": [...], "B": [...], "C": [...], "D": [...] }, min_match_score: int, notify_enabled: bool, ... }` |
|
||||
| AI 큐레이터 | GET | `/api/lotto/briefing/latest`, `/api/lotto/curator/usage` |
|
||||
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
|
||||
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |
|
||||
| 포트폴리오 | CRUD | `/api/profile/careers`, `/api/profile/projects`, `/api/profile/skills`, `/api/profile/introductions` — personal 서비스 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
126
STATUS.md
Normal file
126
STATUS.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# web-ui — 구현 현황 & 로드맵
|
||||
|
||||
> 최종 갱신: 2026-05-07
|
||||
> 자세한 페이지·API 표는 [CLAUDE.md](./CLAUDE.md) 참조.
|
||||
|
||||
---
|
||||
|
||||
## 1. 구현 완료
|
||||
|
||||
### 1-1. 메인 페이지
|
||||
|
||||
| 경로 | 상태 | 비고 |
|
||||
|------|------|------|
|
||||
| `/` Home | ✅ | 메인 허브 |
|
||||
| `/blog` Blog | ✅ | 마크다운 기반 |
|
||||
| `/portfolio` Portfolio | ✅ | 프로필·경력·프로젝트·자기소개 |
|
||||
| `/todo` Todo | ✅ | 태스크 보드 |
|
||||
|
||||
### 1-2. 로또 (`/lotto`)
|
||||
|
||||
| 영역 | 상태 |
|
||||
|------|------|
|
||||
| 3탭 구조 (브리핑 / 분석·통계 / 구매·성과) | ✅ |
|
||||
| AI 큐레이터 브리핑 탭 | ✅ |
|
||||
| 성과 배너 + ReportPanel + ConfidenceRing | ✅ |
|
||||
| 개인 분석 패널 | ✅ |
|
||||
| 구매 내역 CRUD + 성과 통계 | ✅ |
|
||||
|
||||
### 1-3. 주식 (`/stock`, `/stock/trade`)
|
||||
|
||||
| 영역 | 상태 |
|
||||
|------|------|
|
||||
| 뉴스·지수 | ✅ |
|
||||
| 트레이딩 + 잔고 | ✅ |
|
||||
| 포트폴리오 (수동 입력 종목 + 예수금 + 자산 추이) | ✅ |
|
||||
| 자산 스냅샷 + 7/30/90일 차트 | ✅ |
|
||||
| 실현손익(매도이력) Drawer | ✅ |
|
||||
| 포트폴리오 카드 모바일 금액 줄바꿈 대응 | ✅ (2026-05-06) |
|
||||
|
||||
### 1-4. 청약 (`/realestate`, `/realestate/property`)
|
||||
|
||||
| 영역 | 상태 |
|
||||
|------|------|
|
||||
| 자치구 5티어 (S/A/B/C/D) 드래그&드롭 + 슬라이더 + 토글 | ✅ |
|
||||
| 카드/매칭 결과에 district 뱃지 + 5티어 뱃지 | ✅ |
|
||||
| AnnouncementDetail 매칭 분석 섹션 | ✅ |
|
||||
| 5축 점수 breakdown 시각화 + 알림 대상 카운트 | ✅ |
|
||||
| 청약 일정 캘린더 뷰 | ✅ |
|
||||
| 프로필 완성도 힌트 배너 + 소득 기준 힌트 | ✅ |
|
||||
|
||||
### 1-5. 여행 (`/travel`)
|
||||
|
||||
| 영역 | 상태 |
|
||||
|------|------|
|
||||
| Dark Room 테마 갤러리 | ✅ |
|
||||
| 앨범 카드 + Masonry + Lightbox + MiniMap | ✅ |
|
||||
| 지역 변경 + 핀 좌표 지정 | ✅ |
|
||||
| 영상(VideoTab) | 🚧 준비 중 |
|
||||
|
||||
### 1-6. 음악 스튜디오 (`/lab/music` — Sonic Forge)
|
||||
|
||||
| 영역 | 상태 |
|
||||
|------|------|
|
||||
| Create 탭 (장르/무드/악기/BPM/Key) + 트랙 제목 직접 입력 | ✅ |
|
||||
| Library 탭 + 트랙 카드 + 삭제/재생 | ✅ |
|
||||
| YouTube 탭 (서브탭 4개: VideoProjects / Trends / Revenue / Compile) | ✅ (2026-05-01~05-06) |
|
||||
| 다중 트랙 컴파일 (FFmpeg concat → MP4) | ✅ |
|
||||
| 시장 트렌드 리포트 (장르/추천수/이력) | ✅ |
|
||||
|
||||
### 1-7. 기타 Lab
|
||||
|
||||
| 경로 | 상태 |
|
||||
|------|------|
|
||||
| `/lab/sword-stream` Three.js 파티클 | ✅ |
|
||||
| `/lab/day-calc` 날짜 계산기 | ✅ |
|
||||
| `/agent-office` 에이전트 가상 오피스 (WebSocket) | ✅ |
|
||||
| `/blog-lab` 블로그 마케팅 수익화 대시보드 | ✅ |
|
||||
|
||||
### 1-8. 인프라 / DX
|
||||
|
||||
| 항목 | 상태 |
|
||||
|------|------|
|
||||
| Vite 개발 서버 프록시 (`/api`, `/media`, `/ext`) | ✅ |
|
||||
| Windows robocopy + macOS SSH/SMB 배포 (`scripts/deploy-nas.cjs`) | ✅ |
|
||||
| Mac SSH 배포 + tar\|ssh 전환 (Synology rsync 우회) | ✅ |
|
||||
| 반응형 웹 디자인 패스 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 2. 진행 중 / 향후 계획
|
||||
|
||||
### 2-1. Travel 영상 탭 완성
|
||||
- 현재 "준비 중" 플레이스홀더 → 실제 영상 업로드/재생 UI 구현
|
||||
- 백엔드 `travel-proxy`에 영상 메타·썸네일 API 필요
|
||||
|
||||
### 2-2. 로또 프리미엄 구독 UI (백엔드 Phase 3 연동)
|
||||
- 회원 가입/로그인 UI (JWT)
|
||||
- 구독 플랜 선택 + Toss/Stripe 결제 플로우
|
||||
- 구독자 전용 리포트·알림 영역
|
||||
- 백엔드 로드맵: `web-backend/docs/lotto-premium-roadmap.md`
|
||||
|
||||
### 2-3. Music YouTube 탭 후속
|
||||
- VideoProjects 실제 렌더링 진행률 시각화 강화
|
||||
- Compile 탭에 트랙 트림/페이드 옵션
|
||||
- Revenue 대시보드 차트 강화
|
||||
|
||||
### 2-4. 청약 후속
|
||||
- 알림 dry-run 미리보기 UI (어떤 공고가 매칭됐을지 사전 확인)
|
||||
- 모바일 5티어 편집 모드 (현재 PC 전용)
|
||||
|
||||
### 2-5. 포트폴리오/주식 후속
|
||||
- 종목별 평균 매입가 분할 입력 UI
|
||||
- 매도 시뮬레이터 (수익률 시나리오 비교)
|
||||
|
||||
### 2-6. 일반
|
||||
- 다크/라이트 테마 토글 (현재 다크 단일)
|
||||
- PWA 설치 + 홈화면 단축 (모바일 사용 빈도 증가)
|
||||
|
||||
---
|
||||
|
||||
## 3. 참고 문서
|
||||
|
||||
- 페이지·라우트·API 전체 표: [CLAUDE.md](./CLAUDE.md)
|
||||
- 워크스페이스 통합 가이드: `../CLAUDE.md`
|
||||
- 백엔드 상태: `../web-backend/STATUS.md`
|
||||
- 백엔드 Spec/Plan 디렉토리: `../web-backend/docs/superpowers/`
|
||||
2837
docs/superpowers/plans/2026-05-11-lotto-curator-evolution-plan.md
Normal file
2837
docs/superpowers/plans/2026-05-11-lotto-curator-evolution-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
4353
docs/superpowers/plans/2026-05-12-stock-screener-board.md
Normal file
4353
docs/superpowers/plans/2026-05-12-stock-screener-board.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,358 @@
|
||||
# Lotto Curator Evolution — Design Spec
|
||||
|
||||
- 일자: 2026-05-11
|
||||
- 범위: `web-ui` (브리핑 탭 재구성), `web-backend/lotto` (스키마·잡), `web-backend/agent-office` (큐레이터·텔레그램)
|
||||
- 컨셉 한 줄: **매주 같은 시간에 큐레이터가 한 번 더 똑똑해진다**
|
||||
|
||||
## 1. 동기와 문제
|
||||
|
||||
현재 `/lotto`는 3탭(브리핑·분석·구매)으로 구성되어 정보가 풍부하지만, 사용자가 5천~1만원 어치를 즐기며 구매하기에 다음 페인이 있다.
|
||||
|
||||
- 분석·통계·브리핑이 모두 *결정용 화면*처럼 노출되어 정보 과다.
|
||||
- 큐레이터가 매주 5세트를 추천하지만, 5세트의 *역할*과 *왜 이 분배인지*가 와닿지 않는다.
|
||||
- 큐레이터·시스템에 시간축이 없다. 매주 동일 알고리즘을 새로 도는 느낌.
|
||||
- 1만원어치 구매 시 5세트로는 부족하다. 추가 게임에 대한 설계가 없다.
|
||||
|
||||
## 2. 컨셉
|
||||
|
||||
다음 두 축으로 강화한다.
|
||||
|
||||
- **서사적 진화**: 큐레이터가 매주 *지난 주를 회고*하고 이번 주 전략으로 이어간다. 자기 추천 결과 + 사용자 실제 구매 결과를 둘 다 회고 데이터로 사용한다.
|
||||
- **포트폴리오 명료성**: 5게임이 단순 5장이 아니라 안정/균형/공격 분배가 그 주 데이터에 따라 동적으로 바뀌고, 그 이유가 한 줄로 와닿는다. 5~20세트로 위계적으로 확장된다.
|
||||
|
||||
## 3. 주간 사이클
|
||||
|
||||
```
|
||||
토 20:35 추첨
|
||||
│
|
||||
일 03:00 추첨결과 sync (기존)
|
||||
↓
|
||||
채점 잡 (신규) → weekly_review INSERT
|
||||
lotto_purchase auto_graded UPDATE
|
||||
│
|
||||
월 09:00 큐레이션 트리거 (lotto_agent.on_schedule)
|
||||
├─ build_retrospective(target_draw)
|
||||
├─ collect_candidates(N=30)
|
||||
├─ build_context (+retrospective)
|
||||
├─ Claude 호출 (회고+계층 규칙)
|
||||
└─ briefings INSERT (4계층 picks)
|
||||
│
|
||||
월 09:05 텔레그램 헤드라인 푸시
|
||||
│
|
||||
월~토 사용자: 사이트 결정 카드 → 모드 선택(5/10/15/20) → 1탭 구매 기록
|
||||
│
|
||||
토 20:35 추첨 → 다음 사이클
|
||||
```
|
||||
|
||||
cron 시간(일 03:00 / 월 09:00)은 운영하며 조정 가능한 기본값.
|
||||
|
||||
## 4. 결정 카드 (브리핑 탭 메인)
|
||||
|
||||
브리핑 탭을 단일 `DecisionCard`로 재구성한다. 정보 위계는 위→아래로:
|
||||
|
||||
1. **헤더** — 회차 + 한 줄 헤드라인 + 신뢰도(0~100, 큐레이터 자기 평가)
|
||||
2. **회고 박스** (▸ 보라색 라벨) — 지난 주 너 + 큐레이터 한 줄 회고. *시간축*의 핵심.
|
||||
3. **헤드라인 + 3줄** — 이번 주 전망 + 근거 3줄(기존 narrative 유지).
|
||||
4. **분배 칩** — 선택 모드까지의 안정/균형/공격 합산 + "왜 이 분배인지" 한 줄.
|
||||
5. **모드 토글** — 4단계 칩(코어 5 / +보너스 5 / +확장 5 / +풀 5).
|
||||
6. **계층 섹션 × 4** — 각 계층마다 타이틀 + 사유 한 줄 + 5장 PickCard. 코어는 항상 펼침, 그 외는 모드에 따라.
|
||||
7. **하단 액션** — "이대로 N세트 구매했음" 한 클릭 → 자동 기록.
|
||||
|
||||
### 4계층 위계
|
||||
|
||||
| 계층 | 누적 게임 | 비용 | 큐레이터의 의도 |
|
||||
|---|---|---|---|
|
||||
| 코어(필수) | 5 | 5천 | 안정 2 / 균형 2 / 공격 1, 그 주 주축 |
|
||||
| + 보너스 | 10 | 1만 | 코어 분배의 공백 보완 |
|
||||
| + 확장 | 15 | 1.5만 | 코어·보너스에 없던 시각(합계 극단·콜드 누적·4주 미등장) |
|
||||
| + 풀 | 20 | 2만 | 한 번도 누르지 않은 패턴(연속·동끝·5수 균등) |
|
||||
|
||||
각 5세트는 *큐레이터가 의도한 한 묶음*이며, 늘어날수록 *서사가 더해지는 구조*. 마지막 모드 선택은 브라우저 `localStorage` 에 `lotto.tier_mode` 키로 저장하여 다음 주 진입 시 디폴트로 사용한다(서버 저장 X — 사용자 디바이스 단위 기억).
|
||||
|
||||
### 분석 탭은 "Deep Dive" 자료실로 강등
|
||||
|
||||
- 라벨 변경: `📊 분석·통계` → `📚 자료실 / Deep Dive`
|
||||
- 첫 진입 시 모든 패널 접힘
|
||||
- 기존 패널 모두 보존 (CombinedRecommendPanel, ReportPanel, 시뮬레이션, 통계, 빈도, PersonalAnalysisPanel, 수동 추천, 히스토리)
|
||||
- PerformanceBanner는 결정 카드 헤더와 역할 중복 없도록 자료실에만 둠
|
||||
|
||||
## 5. 데이터 모델
|
||||
|
||||
### 신규 테이블 — `weekly_review`
|
||||
|
||||
```sql
|
||||
CREATE TABLE weekly_review (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
draw_no INTEGER NOT NULL UNIQUE,
|
||||
|
||||
-- 큐레이터 자기 평가 (briefings.picks vs 추첨)
|
||||
curator_avg_match REAL,
|
||||
curator_best_tier TEXT, -- 안정 | 균형 | 공격
|
||||
curator_best_match INTEGER,
|
||||
curator_5plus_prizes INTEGER, -- 3개↑ 일치 카운트(5등 이상)
|
||||
|
||||
-- 사용자 구매 평가 (lotto_purchase vs 추첨)
|
||||
user_avg_match REAL,
|
||||
user_best_match INTEGER,
|
||||
user_5plus_prizes INTEGER,
|
||||
|
||||
-- 패턴 갭 (서사 재료)
|
||||
user_pattern_summary TEXT,
|
||||
draw_pattern_summary TEXT,
|
||||
pattern_delta TEXT, -- "너 저번호 편향 +1.2 / 합계 -18"
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### `lotto_purchase` 컬럼 추가
|
||||
|
||||
```sql
|
||||
ALTER TABLE lotto_purchase ADD COLUMN numbers TEXT; -- JSON [3,11,17,25,33,41]
|
||||
ALTER TABLE lotto_purchase ADD COLUMN match_count INTEGER;
|
||||
ALTER TABLE lotto_purchase ADD COLUMN auto_graded INTEGER DEFAULT 0;
|
||||
ALTER TABLE lotto_purchase ADD COLUMN curator_tier TEXT; -- core | bonus | extended | pool
|
||||
ALTER TABLE lotto_purchase ADD COLUMN curator_role TEXT; -- 안정 | 균형 | 공격
|
||||
```
|
||||
|
||||
### `briefings.picks` 구조 변경
|
||||
|
||||
JSON 컬럼을 4계층 구조로 마이그레이션:
|
||||
|
||||
```json
|
||||
{
|
||||
"core": [/* 5세트 */],
|
||||
"bonus": [/* 5세트 */],
|
||||
"extended": [/* 5세트 */],
|
||||
"pool": [/* 5세트 */]
|
||||
}
|
||||
```
|
||||
|
||||
기존 단일 배열 데이터는 `core` 키에만 매핑하고 나머지 키는 빈 배열로 채우는 1회 마이그레이션 스크립트.
|
||||
|
||||
## 6. 큐레이터 변경
|
||||
|
||||
### 출력 스키마 (`agent-office/curator/schema.py`)
|
||||
|
||||
```python
|
||||
class CuratorOutput(BaseModel):
|
||||
core_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||
bonus_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||
extended_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||
pool_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||
tier_rationale: TierRationale # bonus / extended / pool 각 30자 이내
|
||||
narrative: Narrative # retrospective(60자 이내) 필드 추가
|
||||
confidence: int # 0~100
|
||||
```
|
||||
|
||||
### SYSTEM_PROMPT 추가 규칙
|
||||
|
||||
```
|
||||
회고 규칙:
|
||||
- context.retrospective 가 있으면 narrative.retrospective 에 한 줄(60자 이내).
|
||||
- 큐레이터 자기 결과(curator_avg, best_tier) + 사용자 결과(user_avg, pattern_delta) 둘 다 짚을 것.
|
||||
- 이번 주 코어 분배는 회고에 근거해 조정. 사유는 narrative.headline 에 한 줄로.
|
||||
|
||||
계층별 큐레이션 규칙:
|
||||
- core_picks (5): 안정 2 / 균형 2 / 공격 1. 그 주 주축.
|
||||
- bonus_picks (5): 코어 분배의 공백을 메움. 코어와 상보적.
|
||||
- extended_picks (5): 코어·보너스에 없는 시각(합계 극단 / 콜드 누적 / 4주 미등장).
|
||||
- pool_picks (5): 이번 주 한 번도 누르지 않은 패턴(연속·동끝·5수 균등).
|
||||
- tier_rationale 의 3개 키(bonus·extended·pool)에 각각 30자 이내 사유.
|
||||
- 후보에 없는 번호 조합은 절대 사용 금지(기존 규칙 유지).
|
||||
```
|
||||
|
||||
### 회고 컨텍스트 — `agent-office/curator/retrospective.py` (신규)
|
||||
|
||||
```python
|
||||
def build_retrospective(target_draw_no: int) -> dict | None:
|
||||
last = lotto_get_review(target_draw_no - 1)
|
||||
prev3 = lotto_get_reviews(target_draw_no - 4, target_draw_no - 2)
|
||||
if not last:
|
||||
return None
|
||||
return {
|
||||
"last_draw": {
|
||||
"draw_no": last["draw_no"],
|
||||
"curator_avg": last["curator_avg_match"],
|
||||
"curator_best_tier": last["curator_best_tier"],
|
||||
"user_avg": last["user_avg_match"],
|
||||
"user_5plus": last["user_5plus_prizes"],
|
||||
"pattern_delta": last["pattern_delta"],
|
||||
},
|
||||
"trend_4w": {
|
||||
"curator_avg_4w": mean(curator_avg_match for r in [last, *prev3]),
|
||||
"user_avg_4w": mean(user_avg_match for r in [last, *prev3] if user_avg_match is not None),
|
||||
"user_persistent_bias": _detect_bias([last, *prev3]), # 3주↑ 유지된 패턴 편향(예: "저번호 편향")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 후보 풀 N=30
|
||||
|
||||
`collect_candidates(n=30)` — 20세트 선별 + 다양성 여유. 기존 4개 소스(simulation/heatmap/statistics/meta) 추출량을 비례 확대.
|
||||
|
||||
## 7. 자동 채점 잡 — `lotto/app/jobs/grade_weekly_review.py`
|
||||
|
||||
```
|
||||
실행: 매주 일요일 03:00 KST (cron)
|
||||
입력: 가장 최근 sync된 추첨 회차
|
||||
처리:
|
||||
1) briefings 에서 해당 회차의 4계층 picks 로드 (없으면 curator_* NULL)
|
||||
2) lotto_purchase 에서 해당 회차의 사용자 구매 로드 (없으면 user_* NULL)
|
||||
3) 각 세트별 일치 수 계산 → 큐레이터/사용자 집계
|
||||
4) 패턴 요약(저번호·홀짝·합계 평균) → user/draw_pattern_summary
|
||||
5) 패턴 갭 한 줄(가장 큰 격차 1~2개) → pattern_delta
|
||||
6) weekly_review UPSERT (draw_no 유니크)
|
||||
7) lotto_purchase 채점:
|
||||
- 일치 3개 → prize=5000, auto_graded=1
|
||||
- 일치 4개 → prize=NULL, note 에 "4등 가능성 — 동행복권 확인" 플래그
|
||||
- 일치 5+ → prize=NULL, note 에 "🚨 큰 당첨 가능성 — 즉시 확인" 플래그
|
||||
+ agent-office HTTP webhook(`POST /api/agent-office/notify/lotto-prize`)
|
||||
호출하여 텔레그램 별도 알림 트리거
|
||||
- numbers NULL 인 행은 스킵
|
||||
```
|
||||
|
||||
## 8. 텔레그램 알림 — `agent-office/notifiers/telegram_lotto.py` (신규)
|
||||
|
||||
큐레이션 성공 후 `lotto_agent` 가 호출. 발송 실패는 try/except 로 흡수(briefing 저장과 분리).
|
||||
4등 이상 당첨 알림은 lotto-backend 채점 잡이 `POST /api/agent-office/notify/lotto-prize` webhook 으로 트리거(agent-office 측 라우터 신규 추가).
|
||||
|
||||
```
|
||||
🎟 1154회 · 큐레이션 떴음
|
||||
|
||||
"이번 주는 안정 +1, 콜드 누적 보강."
|
||||
신뢰도 72 · 분배 안정 3·균형 1·공격 1
|
||||
|
||||
▸ 회고: 너 2.0 / 나 1.8
|
||||
너 저번호 편향 → 보너스 고번호 보강
|
||||
|
||||
👉 결정 카드 보러가기 (https://gahusb.synology.me/lotto)
|
||||
```
|
||||
|
||||
회고 단락은 retrospective 가 있을 때만(첫 주 생략).
|
||||
|
||||
## 9. 프론트 변경
|
||||
|
||||
### 파일 변경 맵
|
||||
|
||||
| 파일 | 종류 | 내용 |
|
||||
|------|------|------|
|
||||
| `pages/lotto/Functions.jsx` | 수정 | 분석탭 라벨 변경 |
|
||||
| `pages/lotto/tabs/BriefingTab.jsx` | 수정 | DecisionCard 단일로 재구성 |
|
||||
| `pages/lotto/components/decision/DecisionCard.jsx` | 신규 | 결정 카드 메인 |
|
||||
| `pages/lotto/components/decision/RetrospectiveBox.jsx` | 신규 | 회고 박스 |
|
||||
| `pages/lotto/components/decision/TierModeToggle.jsx` | 신규 | 4단계 칩 토글 |
|
||||
| `pages/lotto/components/decision/TierSection.jsx` | 신규 | 한 계층 영역(타이틀+사유+5장) |
|
||||
| `pages/lotto/components/decision/PickCard.jsx` | 신규 | 한 세트 카드(역할+번호+사유) |
|
||||
| `pages/lotto/components/decision/BulkPurchaseButton.jsx` | 신규 | 원클릭 구매 |
|
||||
| `pages/lotto/components/briefing/*` | 삭제·이동 | DecisionCard 하위로 흡수, CuratorUsageFooter 는 자료실 이동 |
|
||||
| `pages/lotto/components/PurchasePanel.jsx` | 수정 | auto_graded 표시 + 4등 이상 플래그 |
|
||||
| `pages/lotto/components/PurchaseTrendChart.jsx` | 신규 | 4주 추세 라인(너 vs 큐레이터 평균 일치) |
|
||||
| `pages/lotto/hooks/useBriefing.js` | 수정 | 4계층 + retrospective 수용 |
|
||||
| `pages/lotto/hooks/useReview.js` | 신규 | weekly_review 로드 |
|
||||
| `pages/lotto/hooks/usePurchases.js` | 수정 | bulkPurchase 추가 |
|
||||
| `api.js` | 수정 | getLatestReview, getReviewHistory, bulkPurchase 헬퍼 |
|
||||
|
||||
### 컴포넌트 격리 원칙
|
||||
|
||||
- `DecisionCard` 는 `briefing` + `review` 두 객체만 props 로 받음(내부 hook 호출 X).
|
||||
- `TierSection` 은 `tier`, `picks`, `rationale` 만 받아 4번 재사용.
|
||||
- `BulkPurchaseButton` 은 `draw_no`, `tier_mode`, `sets`, `amount` 4개로 작동.
|
||||
|
||||
## 10. 백엔드 변경
|
||||
|
||||
### `web-backend/lotto/`
|
||||
|
||||
| 파일 | 종류 | 내용 |
|
||||
|------|------|------|
|
||||
| `app/db/migrations/00X_weekly_review.sql` | 신규 | 테이블 생성 |
|
||||
| `app/db/migrations/00X_purchase_grading.sql` | 신규 | lotto_purchase 컬럼 추가 |
|
||||
| `app/db/migrations/00X_briefings_tiers.sql` | 신규 | briefings.picks 4계층 마이그레이션 |
|
||||
| `app/jobs/grade_weekly_review.py` | 신규 | 채점 잡 |
|
||||
| `app/curator_helpers.py` | 수정 | collect_candidates(N=30) 기본값, build_context 에 retrospective 합치기 |
|
||||
| `app/routers/briefing.py` | 수정 | BriefingRequest 4계층 + narrative.retrospective 수용 |
|
||||
| `app/routers/review.py` | 신규 | GET /api/lotto/review/latest, GET /api/lotto/review/history?limit=N |
|
||||
| `app/routers/purchase.py` | 수정 | POST /api/lotto/purchase/bulk |
|
||||
| `app/cron.py` (또는 compose 스케줄러) | 수정 | 채점 잡 일 03:00 등록 |
|
||||
|
||||
### `web-backend/agent-office/`
|
||||
|
||||
| 파일 | 종류 | 내용 |
|
||||
|------|------|------|
|
||||
| `app/curator/retrospective.py` | 신규 | build_retrospective |
|
||||
| `app/curator/schema.py` | 수정 | 4계층 + tier_rationale + narrative.retrospective |
|
||||
| `app/curator/prompt.py` | 수정 | 회고·계층 규칙 추가 |
|
||||
| `app/curator/pipeline.py` | 수정 | retrospective 빌드 호출, 4계층 직렬화 |
|
||||
| `app/agents/lotto.py` | 수정 | on_schedule 월 09:00, 성공 시 텔레그램 호출 |
|
||||
| `app/notifiers/telegram_lotto.py` | 신규 | 알림 포맷·발송(큐레이션 완료, 4등 이상 당첨 알림 둘 다) |
|
||||
| `app/routers/notify.py` | 신규 | `POST /api/agent-office/notify/lotto-prize` — lotto-backend 채점 잡이 호출 |
|
||||
| `app/service_proxy.py` | 수정 | review 헬퍼 추가 |
|
||||
|
||||
## 11. API 추가·변경
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/lotto/review/latest` | 최신 weekly_review 1건 |
|
||||
| GET | `/api/lotto/review/history?limit=N` | 최근 N건 (4주 추세 차트용) |
|
||||
| POST | `/api/lotto/purchase/bulk` | 결정 카드 원클릭 — body: `{ draw_no, tier_mode, sets, amount }` |
|
||||
| POST | `/api/agent-office/notify/lotto-prize` | 4등 이상 당첨 시 lotto-backend 가 트리거 — body: `{ draw_no, match_count, numbers, purchase_id }` |
|
||||
|
||||
기존 엔드포인트는 그대로 유지(스키마 호환).
|
||||
|
||||
## 12. 에러 처리 / 격리
|
||||
|
||||
| 단계 | 실패 | 처리 |
|
||||
|------|------|------|
|
||||
| 추첨결과 sync | 동행복권 API down | 기존 정책(재시도). 채점 잡은 자동 지연만. |
|
||||
| 채점 — 큐레이터 picks 없음 | 첫 주, 큐레이션 실패 회차 | curator_* NULL 로 INSERT |
|
||||
| 채점 — 사용자 구매 없음 | 그 주 미구매 | user_* NULL |
|
||||
| 채점 — numbers NULL 행 | 마이그레이션 이전 데이터 | 스킵, auto_graded=0 유지 |
|
||||
| build_retrospective — review 없음 | 첫 주 | None 반환 → 프롬프트 분기 자연 처리 |
|
||||
| Claude 스키마 실패 | 4계층 미준수 등 | 기존 1회 retry, 2회 실패 시 텔레그램 에러 알림 |
|
||||
| 텔레그램 발송 실패 | 봇/네트워크 | try/except, 로그만. briefing 저장은 영향 없음 |
|
||||
| bulk purchase — briefing 없음 | 큐레이션 실패 회차 | 400 + 토스트 |
|
||||
| bulk purchase — 중복 호출 | 더블클릭 | (draw_no, tier_mode) 유니크 → idempotent |
|
||||
| 자동채점 — 4등 이상 | 큰 당첨 | prize NULL + 메모 플래그 + 텔레그램 별도 알림 |
|
||||
|
||||
## 13. 테스트
|
||||
|
||||
### 백엔드 (`lotto/`)
|
||||
|
||||
- `grade_weekly_review`: (a) 정상 (b) user 구매 없음 (c) numbers NULL 스킵 (d) 일치 3개 → prize 5000 (e) 일치 4개 → 메모 플래그
|
||||
- 마이그레이션: 빈 DB → 더미 → 잡 실행 → 행 정확
|
||||
- briefings 마이그레이션: 구 단일 picks → core 매핑, 나머지 빈 배열
|
||||
- `POST /purchase/bulk`: 정상 / 잘못된 tier_mode / briefing 없음 / 중복 호출
|
||||
- `GET /review/latest`: 데이터 있음 / 빈 DB → 404
|
||||
|
||||
### 큐레이터 (`agent-office/curator/`)
|
||||
|
||||
- `build_retrospective`: review 1건 / 4건 / 0건
|
||||
- `validate_response`: 정상 / 계층 누락 / 후보 외 번호 / tier_rationale 누락
|
||||
- `curate_weekly` (Claude API mock): retrospective 있음·없음 / 1차 실패 → 2차 성공 / 2회 실패
|
||||
- `telegram_lotto.format`: retrospective 있음·없음
|
||||
|
||||
### 프론트
|
||||
|
||||
- `DecisionCard` 수동: retrospective 있음·없음 / 모드 토글 5/10/15/20 / confidence 색
|
||||
- `TierModeToggle` 단위: onChange 콜백 정확
|
||||
- `BulkPurchaseButton` 수동 E2E: 클릭 → POST → 토스트 → 구매탭 갱신
|
||||
- 자료실 탭 수동: 첫 진입 모두 접힘
|
||||
- 모바일: DecisionCard 좁은 화면에서 깨짐 없음
|
||||
|
||||
## 14. 운영 점검 (배포 후 1주차)
|
||||
|
||||
수동으로 확인:
|
||||
|
||||
1. 일 03:00 채점 잡 1회 실행(`weekly_review` 1행 추가)
|
||||
2. 월 09:00 큐레이션 실행(`briefings` 1행, 4계층 5×4=20개)
|
||||
3. 텔레그램 알림 도착(회고 단락 정확 포함/생략)
|
||||
4. 결정 카드 렌더링 정상(모바일 + PC)
|
||||
5. 원클릭 구매 정확 N건 INSERT
|
||||
6. cron 시간(03:00 / 09:00) 운영 패턴에 맞게 조정
|
||||
|
||||
## 15. Out of Scope
|
||||
|
||||
- 4등 이상 당첨금 자동 입력(회차별 변동, 사용자 PUT 으로 갱신)
|
||||
- 큐레이터 호출 재무 비용 모니터링 강화(기존 `curator_usage` 그대로)
|
||||
- 분석 탭 패널 자체의 리팩토링(라벨·디폴트 접힘만 변경)
|
||||
- 1만원 외 임의 분량(7세트 등) 토글(4계층 5단위로 고정)
|
||||
822
docs/superpowers/specs/2026-05-12-stock-screener-board-design.md
Normal file
822
docs/superpowers/specs/2026-05-12-stock-screener-board-design.md
Normal file
@@ -0,0 +1,822 @@
|
||||
# Stock Screener Board — 설계 문서 (MVP 슬라이스 1)
|
||||
|
||||
- **상태**: 설계 (Draft)
|
||||
- **작성일**: 2026-05-12
|
||||
- **대상 프로젝트**: `web-ui` (프론트엔드) + `web-backend/stock-lab` (백엔드) + `web-backend/agent-office` (스케줄러/텔레그램)
|
||||
- **저자**: 개인 웹 플랫폼 CEO + Claude (brainstorming)
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 & 목표
|
||||
|
||||
현재 `/stock`은 뉴스·지수·공포탐욕, `/stock/trade`는 포트폴리오·매매·AI 코치까지 다룹니다. **시장 전체에서 강세주를 발굴하는 기능은 없습니다.**
|
||||
|
||||
이 작업은 KRX 전체 종목을 매일 분석해 강세주 후보를 점수화·순위화하고, 평일 장 마감 후 텔레그램으로 자동 전송하는 **노드 기반 분석 보드**를 만듭니다. 노드 인터페이스를 일관되게 정의해 후속 슬라이스에서 노드 캔버스 UI·AI 뉴스 노드·백테스트로 자연스럽게 확장 가능한 구조를 둡니다.
|
||||
|
||||
### 비전 (장기)
|
||||
|
||||
n8n 같은 노드 캔버스에서 시그널 노드를 연결·점수화하고, 결과를 표·텔레그램으로 받는 개인용 스크리닝/분석 워크벤치.
|
||||
|
||||
### 본 슬라이스 (MVP)
|
||||
|
||||
| 요소 | 범위 |
|
||||
|------|------|
|
||||
| 데이터 | pykrx로 매일 KRX 전종목 일봉 + 외국인/기관 수급 → SQLite 캐시 |
|
||||
| 분석 노드 | 점수 7개 + 위생 게이트 1개 = 총 8개 |
|
||||
| 결합 | 가중합 (게이트 통과군 내 백분위 정규화 기반) |
|
||||
| 출력 | Top N(기본 20) 결과 표 + 진입가/손절/익절 + 텔레그램 |
|
||||
| 실행 | 평일 16:30 KST 자동 + 사용자 수동 미리보기 |
|
||||
| UI | `/stock/screener` 별도 페이지, 좌(설정)-중(표)-우(히스토리) |
|
||||
| 자동 잡 | `agent-office`가 트리거, 텔레그램 전송 책임 |
|
||||
|
||||
### 비목표 (후속 슬라이스에 명시 예약)
|
||||
|
||||
1. AI 뉴스 호재/악재 노드
|
||||
2. 노드 캔버스 UI (react-flow)
|
||||
3. 주간 자가학습 (가중치 자동 조정 제안)
|
||||
4. DART 공시·재무제표 노드
|
||||
5. 분봉 기반 노드 (한투 API)
|
||||
6. 진짜 미너비니 VCP (베이스 카운트·피벗 포인트)
|
||||
7. 멀티 프리셋 ("공격형"/"안정형")
|
||||
8. 백테스트 화면
|
||||
9. KRX 호가단위 적용
|
||||
10. 메트릭/대시보드 (Prometheus 등)
|
||||
|
||||
---
|
||||
|
||||
## 2. 전체 아키텍처
|
||||
|
||||
```
|
||||
[agent-office 평일 16:30 KST] [사용자: Stock 스크리너 페이지]
|
||||
│ │
|
||||
▼ ▼
|
||||
POST /api/stock/screener/snapshot/refresh POST /api/stock/screener/run
|
||||
POST /api/stock/screener/run {mode:"auto"} {mode:"preview"|"manual_save"}
|
||||
│ │
|
||||
└──────────► Screener.run() ◄──────────────────┘
|
||||
│
|
||||
▼
|
||||
ScreenContext.load(asof)
|
||||
(KRX 마스터·일봉·수급 SQLite 캐시)
|
||||
│
|
||||
▼
|
||||
HygieneGate.filter() ← Survivors ~500-800종
|
||||
│
|
||||
▼
|
||||
[ScoreNode.compute() × 7 활성 노드]
|
||||
│
|
||||
▼
|
||||
combine + rank Top N
|
||||
│
|
||||
▼
|
||||
position_sizer (entry/stop/target)
|
||||
│
|
||||
┌─────────────┴───────────────┐
|
||||
▼ ▼
|
||||
screener_runs + screener_results 응답 JSON (results, telegram_payload)
|
||||
(mode='auto'·'manual_save') │
|
||||
▼
|
||||
agent-office가 telegram_payload 전송
|
||||
(mode='auto')
|
||||
```
|
||||
|
||||
데이터 신선도 가정: pykrx의 외국인/기관 수급은 KRX 마감 후 30-60분 뒤 갱신. **16:30 KST 트리거는 안전 마진**.
|
||||
|
||||
---
|
||||
|
||||
## 3. 백엔드 컴포넌트 구조 (stock-lab)
|
||||
|
||||
### 3.1 디렉토리
|
||||
|
||||
```
|
||||
web-backend/stock-lab/app/
|
||||
├─ main.py # router.include_router(screener_router) 1줄 추가
|
||||
├─ db.py
|
||||
├─ price_fetcher.py
|
||||
├─ scraper.py
|
||||
├─ ai_summarizer.py
|
||||
├─ holidays.json
|
||||
├─ test_*.py # 기존
|
||||
├─ test_screener_*.py # 신규 (각 노드/엔진/라우터)
|
||||
└─ screener/ # ← NEW
|
||||
├─ __init__.py
|
||||
├─ router.py # FastAPI: /api/stock/screener/*
|
||||
├─ schemas.py # Pydantic 요청/응답
|
||||
├─ engine.py # Screener / ScreenContext / ScreenerResult / combine()
|
||||
├─ snapshot.py # pykrx 일봉·수급 갱신
|
||||
├─ position_sizer.py # ATR 기반 진입/손절/익절
|
||||
├─ registry.py # NODE_REGISTRY, GATE_REGISTRY
|
||||
├─ telegram.py # agent-office payload 빌더 (전송 책임은 agent-office)
|
||||
├─ _test_fixtures.py # 합성 ScreenContext 헬퍼
|
||||
└─ nodes/
|
||||
├─ __init__.py
|
||||
├─ base.py # ScoreNode, GateNode 추상
|
||||
├─ hygiene.py
|
||||
├─ foreign_buy.py
|
||||
├─ volume_surge.py
|
||||
├─ momentum.py
|
||||
├─ high52w.py
|
||||
├─ rs_rating.py
|
||||
├─ ma_alignment.py
|
||||
└─ vcp_lite.py
|
||||
```
|
||||
|
||||
### 3.2 핵심 추상
|
||||
|
||||
```python
|
||||
# nodes/base.py
|
||||
class ScoreNode(ABC):
|
||||
name: ClassVar[str] # "foreign_buy"
|
||||
label: ClassVar[str] # "외국인 누적 순매수"
|
||||
default_params: ClassVar[dict]
|
||||
param_schema: ClassVar[dict] # 프론트 폼 자동 생성용 JSON Schema
|
||||
@abstractmethod
|
||||
def compute(self, ctx: "ScreenContext", params: dict) -> "pd.Series":
|
||||
"""index=ticker, dtype=float, range 0..100."""
|
||||
|
||||
class GateNode(ABC):
|
||||
name: ClassVar[str]
|
||||
label: ClassVar[str]
|
||||
default_params: ClassVar[dict]
|
||||
param_schema: ClassVar[dict]
|
||||
@abstractmethod
|
||||
def filter(self, ctx: "ScreenContext", params: dict) -> "pd.Index":
|
||||
"""returns surviving tickers."""
|
||||
|
||||
# engine.py
|
||||
@dataclass(frozen=True)
|
||||
class ScreenContext:
|
||||
prices: pd.DataFrame # long form: date·ticker·open·high·low·close·volume·value
|
||||
flow: pd.DataFrame # date·ticker·foreign_net·institution_net
|
||||
master: pd.DataFrame # ticker·name·market·market_cap·is_managed·listed_date·is_preferred·is_spac
|
||||
kospi: pd.Series # date → close (시장 비교용)
|
||||
asof: datetime.date
|
||||
@classmethod
|
||||
def load(cls, asof: datetime.date) -> "ScreenContext": ...
|
||||
def restrict(self, tickers) -> "ScreenContext": ...
|
||||
|
||||
class Screener:
|
||||
def __init__(self, gate: GateNode, score_nodes: list[ScoreNode], weights: dict[str, float],
|
||||
node_params: dict[str, dict], gate_params: dict, top_n: int,
|
||||
sizer_params: dict):
|
||||
...
|
||||
def run(self, ctx: ScreenContext) -> "ScreenerResult":
|
||||
survivors = self.gate.filter(ctx, self.gate_params)
|
||||
scoped = ctx.restrict(survivors)
|
||||
active = [n for n in self.score_nodes if self.weights.get(n.name, 0) > 0]
|
||||
scores = {n.name: n.compute(scoped, self.node_params.get(n.name, {})) for n in active}
|
||||
total = combine(scores, self.weights)
|
||||
ranked = total.sort_values(ascending=False).head(self.top_n)
|
||||
rows = position_sizer.expand(ranked, scoped, self.sizer_params)
|
||||
return ScreenerResult(rows=rows, scores=scores, weights=self.weights,
|
||||
survivors_count=len(survivors), warnings=[...])
|
||||
```
|
||||
|
||||
### 3.3 registry
|
||||
|
||||
```python
|
||||
# registry.py
|
||||
from .nodes import (foreign_buy, volume_surge, momentum, high52w,
|
||||
rs_rating, ma_alignment, vcp_lite, hygiene)
|
||||
|
||||
NODE_REGISTRY: dict[str, type[ScoreNode]] = {
|
||||
"foreign_buy": foreign_buy.ForeignBuy,
|
||||
"volume_surge": volume_surge.VolumeSurge,
|
||||
"momentum": momentum.Momentum20,
|
||||
"high52w": high52w.High52WProximity,
|
||||
"rs_rating": rs_rating.RsRating,
|
||||
"ma_alignment": ma_alignment.MaAlignment,
|
||||
"vcp_lite": vcp_lite.VcpLite,
|
||||
}
|
||||
GATE_REGISTRY: dict[str, type[GateNode]] = {
|
||||
"hygiene": hygiene.HygieneGate,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 데이터 모델 (stock.db 신규 7테이블)
|
||||
|
||||
### 4.1 KRX 캐시 (3테이블)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS krx_master (
|
||||
ticker TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
market TEXT NOT NULL, -- 'KOSPI'|'KOSDAQ'
|
||||
market_cap INTEGER, -- 원, nullable (pykrx 누락 케이스)
|
||||
is_managed INTEGER NOT NULL DEFAULT 0,
|
||||
is_preferred INTEGER NOT NULL DEFAULT 0,
|
||||
is_spac INTEGER NOT NULL DEFAULT 0,
|
||||
listed_date TEXT, -- 'YYYY-MM-DD'
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS krx_daily_prices (
|
||||
ticker TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
open INTEGER, high INTEGER, low INTEGER, close INTEGER,
|
||||
volume INTEGER,
|
||||
value INTEGER, -- 거래대금(원)
|
||||
PRIMARY KEY (ticker, date)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_prices_date ON krx_daily_prices(date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS krx_flow (
|
||||
ticker TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
foreign_net INTEGER, -- 원
|
||||
institution_net INTEGER,
|
||||
PRIMARY KEY (ticker, date)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_date ON krx_flow(date);
|
||||
```
|
||||
|
||||
**용량**: KRX 2,700종목 × 252거래일 × 5년 ≈ 340만 행. SQLite 충분 (수십 MB).
|
||||
**갱신**: 마스터는 매일 전체 재기록, 일봉·수급은 당일 행 upsert.
|
||||
|
||||
**초기 백필 (최초 배포 시 1회)**: 백분위 정규화·52주 신고가·RS Rating(1년 수익률)·MA200 계산을 위해 **최소 1년(252거래일), 권장 2년**의 일봉·수급을 시드 데이터로 백필. `snapshot.py`에 `backfill(start_date, end_date)` 함수를 두고 첫 배포·이전 캐시 손실 시 수동 호출. 자동 잡은 일일 증분만.
|
||||
|
||||
### 4.2 사용자 설정 (싱글톤 1테이블)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS screener_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
weights_json TEXT NOT NULL, -- {"foreign_buy":1.0, ...}
|
||||
node_params_json TEXT NOT NULL, -- {"foreign_buy":{"window_days":5}, ...}
|
||||
gate_params_json TEXT NOT NULL, -- {"min_market_cap_won":50_000_000_000, ...}
|
||||
top_n INTEGER NOT NULL DEFAULT 20,
|
||||
rr_ratio REAL NOT NULL DEFAULT 2.0,
|
||||
atr_window INTEGER NOT NULL DEFAULT 14,
|
||||
atr_stop_mult REAL NOT NULL DEFAULT 2.0,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
`ensure_schema()` 시 초기 row 삽입 (디폴트 가중치 §6 참조).
|
||||
|
||||
### 4.3 실행 스냅샷 (2테이블)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS screener_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
asof TEXT NOT NULL,
|
||||
mode TEXT NOT NULL, -- 'auto' | 'manual_save'
|
||||
status TEXT NOT NULL, -- 'success' | 'failed' | 'skipped_holiday'
|
||||
error TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
weights_json TEXT NOT NULL,
|
||||
node_params_json TEXT NOT NULL,
|
||||
gate_params_json TEXT NOT NULL,
|
||||
top_n INTEGER NOT NULL,
|
||||
survivors_count INTEGER,
|
||||
telegram_sent INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_asof ON screener_runs(asof DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS screener_results (
|
||||
run_id INTEGER NOT NULL,
|
||||
rank INTEGER NOT NULL,
|
||||
ticker TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
total_score REAL NOT NULL,
|
||||
scores_json TEXT NOT NULL,
|
||||
close INTEGER,
|
||||
market_cap INTEGER,
|
||||
entry_price INTEGER,
|
||||
stop_price INTEGER,
|
||||
target_price INTEGER,
|
||||
atr14 REAL,
|
||||
PRIMARY KEY (run_id, ticker),
|
||||
FOREIGN KEY (run_id) REFERENCES screener_runs(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_results_run_rank ON screener_results(run_id, rank);
|
||||
```
|
||||
|
||||
**`mode='preview'`는 저장하지 않습니다.** `auto`·`manual_save`만 행을 만듭니다.
|
||||
보관 기간 정책 없음 (디스크 부담 미미). 후속에서 cleanup 잡 필요시 추가.
|
||||
|
||||
### 4.4 마이그레이션 방식
|
||||
|
||||
stock-lab의 기존 `db.py` 패턴(`CREATE TABLE IF NOT EXISTS`)을 그대로 따릅니다. `screener/snapshot.py`·`screener/engine.py` import 시점에 1회 `ensure_screener_schema()` 호출. 별도 alembic 도입은 본 작업 스코프 밖.
|
||||
|
||||
---
|
||||
|
||||
## 5. 노드 8개 알고리즘
|
||||
|
||||
모든 점수 노드는 0~100 정수로 정규화. 표준 정규화는 **게이트 통과군 내 백분위(percentile)**, 룰 기반이 더 자연스러운 노드(이평선·52주 근접도)는 룰을 사용.
|
||||
|
||||
### 5.1 위생 게이트 — `HygieneGate` (점수 ❌)
|
||||
|
||||
```text
|
||||
params:
|
||||
min_market_cap_won = 50_000_000_000 # 500억 이상
|
||||
min_avg_value_won = 500_000_000 # 20일 평균 거래대금 5억 이상
|
||||
min_listed_days = 60 # 신규 상장 60일 미만 제외
|
||||
skip_managed = true
|
||||
skip_preferred = true
|
||||
skip_spac = true
|
||||
skip_halted_days = 3 # 최근 3일 거래정지(close 또는 volume=0)
|
||||
통과 조건: 위 AND market_cap NOT NULL AND close NOT NULL
|
||||
출력: 통과 종목 Index (보통 500~800종)
|
||||
```
|
||||
|
||||
### 5.2 외국인 누적 순매수 — `ForeignBuy`
|
||||
|
||||
```text
|
||||
params: window_days = 5
|
||||
raw = sum(foreign_net[-5:]) / market_cap # 시총 대비 비율
|
||||
score = percentile_rank(raw, 통과군) × 100
|
||||
debug: foreign_net_sum, market_cap, raw_ratio_pct
|
||||
```
|
||||
|
||||
### 5.3 거래량 급증 — `VolumeSurge`
|
||||
|
||||
```text
|
||||
params: baseline_days = 20, eval_days = 3
|
||||
baseline = mean(volume[-23:-3])
|
||||
recent = mean(volume[-3:])
|
||||
raw = log1p(recent / baseline) # 극값 평탄화
|
||||
score = percentile_rank(raw, 통과군) × 100
|
||||
debug: baseline, recent, ratio
|
||||
```
|
||||
|
||||
### 5.4 20일 모멘텀 — `Momentum20`
|
||||
|
||||
```text
|
||||
params: window_days = 20
|
||||
raw = close[today] / close[today - 20] - 1
|
||||
score = percentile_rank(raw, 통과군) × 100
|
||||
debug: return_20d_pct
|
||||
```
|
||||
|
||||
### 5.5 52주 신고가 근접도 — `High52WProximity` (룰 기반)
|
||||
|
||||
```text
|
||||
params: window_days = 252
|
||||
high_52w = max(high[-252:])
|
||||
proximity = close / high_52w # 0..1
|
||||
score = clip((proximity - 0.7) / 0.3, 0, 1) × 100
|
||||
# 70% 미만 = 0, 100% 도달 = 100, 선형
|
||||
debug: high_52w, proximity_pct
|
||||
```
|
||||
|
||||
### 5.6 RS Rating — `RsRating`
|
||||
|
||||
```text
|
||||
params: weights = {3m:2, 6m:1, 9m:1, 12m:1} # IBD 표준 가중
|
||||
for k in [63, 126, 189, 252] 거래일:
|
||||
r_stock = close[t]/close[t-k] - 1
|
||||
r_kospi = kospi[t]/kospi[t-k] - 1
|
||||
excess_k = r_stock - r_kospi
|
||||
raw = Σ w_k × excess_k
|
||||
score = percentile_rank(raw, 통과군) × 100 # IBD RS Rating 정의
|
||||
debug: excess_1y, excess_3m, raw
|
||||
```
|
||||
|
||||
### 5.7 이평선 정배열 — `MaAlignment` (룰 기반)
|
||||
|
||||
```text
|
||||
params: ma_periods = [50, 150, 200]
|
||||
5개 조건의 만족 개수 / 5 × 100:
|
||||
① close > MA50
|
||||
② MA50 > MA150
|
||||
③ MA150 > MA200
|
||||
④ close > MA200
|
||||
⑤ close ≥ min(close[-252:]) × 1.25 # Stage 2 진입
|
||||
debug: 각 조건 boolean
|
||||
```
|
||||
|
||||
### 5.8 VCP-lite (변동성 수축률) — `VcpLite`
|
||||
|
||||
```text
|
||||
params: short_window = 40, long_window = 252 # 8주 / 52주
|
||||
daily_range_pct = (high - low) / close
|
||||
short_vol = mean(daily_range_pct[-40:])
|
||||
long_vol = mean(daily_range_pct[-252:])
|
||||
raw = 1 - (short_vol / long_vol) # 양수면 수축
|
||||
score = percentile_rank(raw, 통과군) × 100
|
||||
debug: short_vol, long_vol, contraction_ratio
|
||||
주: 진짜 미너비니 VCP(베이스 카운트·피벗 포인트)는 후속 슬라이스
|
||||
```
|
||||
|
||||
### 5.9 결합 (`engine.combine`)
|
||||
|
||||
```python
|
||||
total = Σ(w[n] * scores[n]) / Σ(w[n]) # active 노드만
|
||||
# 가중치 0 → 노드 실행 스킵. 모든 가중치 0이면 422 에러.
|
||||
```
|
||||
|
||||
### 5.10 디폴트 가중치
|
||||
|
||||
| 노드 | w | 근거 |
|
||||
|------|----|------|
|
||||
| foreign_buy | 1.0 | 한국 시장 강한 시그널 |
|
||||
| volume_surge | 1.0 | 표준 |
|
||||
| momentum | 1.0 | 표준 |
|
||||
| high52w | **1.2** | 미너비니 SEPA 핵심 |
|
||||
| rs_rating | **1.2** | 미너비니 + IBD 핵심 |
|
||||
| ma_alignment | 1.0 | Stage 2 확인용 |
|
||||
| vcp_lite | 0.8 | 단순 버전이라 보수적 가중 |
|
||||
|
||||
### 5.11 포지션 사이징 — `position_sizer.py`
|
||||
|
||||
```text
|
||||
params (settings):
|
||||
atr_window = 14
|
||||
atr_stop_mult = 2.0 # 2 × ATR 손절
|
||||
rr_ratio = 2.0 # 익절 = 진입가 + 2R
|
||||
|
||||
atr14 = ATR_Wilder(high, low, close, 14) # Wilder's smoothing (RMA), Pandas .ewm(alpha=1/14)
|
||||
entry = round_won(close × 1.005) # 다음날 시초 0.5% 위
|
||||
stop = round_won(close - 2.0 × atr14)
|
||||
target = round_won(entry + 2.0 × (entry - stop))
|
||||
r_pct = (entry - stop) / entry × 100 # 손실 위험 %
|
||||
|
||||
# round_won(x) = int(round(x)) — 1원 단위 반올림 (Python builtin)
|
||||
```
|
||||
|
||||
ATR은 **Wilder's smoothing** (RMA). 일반 SMA보다 트레이딩 표준. MVP는 1원 단위 라운딩. KRX 호가단위(1·5·10·50·100·500·1000원)는 후속.
|
||||
|
||||
### 5.12 정규화 시 주의점
|
||||
|
||||
- 게이트 통과군이 100종목 미만이면 백분위 의미 ↓. 응답 `warnings`에 경고.
|
||||
- 데이터 부족(상장 60일 미만 등)으로 NaN 발생 시 자동 0점 처리 (게이트가 이미 걸러줄 것).
|
||||
|
||||
---
|
||||
|
||||
## 6. API 명세 (prefix `/api/stock/screener/*`)
|
||||
|
||||
### 6.1 엔드포인트 표
|
||||
|
||||
| 메서드 | 경로 | 호출 주체 | 책임 |
|
||||
|--------|------|----------|------|
|
||||
| GET | `/nodes` | 프론트 | 노드 메타데이터 (label, default_params, param_schema) |
|
||||
| GET | `/settings` | 프론트 | 현재 설정 조회 |
|
||||
| PUT | `/settings` | 프론트 | 설정 업서트 (id=1 싱글톤) |
|
||||
| POST | `/run` | 프론트 · agent-office | 분석 1회 실행. mode 매트릭스로 분기 |
|
||||
| POST | `/snapshot/refresh` | agent-office | KRX 캐시 강제 갱신 |
|
||||
| GET | `/runs?limit=30` | 프론트 | 최근 실행 메타 리스트 |
|
||||
| GET | `/runs/{id}` | 프론트 | 특정 실행 결과 전체 |
|
||||
|
||||
### 6.2 `/run` 시맨틱
|
||||
|
||||
```jsonc
|
||||
// REQUEST
|
||||
POST /api/stock/screener/run
|
||||
{
|
||||
"mode": "preview" | "manual_save" | "auto",
|
||||
"asof": "2026-05-12", // 생략 시 직전 거래일
|
||||
"weights": { ... }, // optional override
|
||||
"node_params": { ... }, // optional override
|
||||
"gate_params": { ... }, // optional override
|
||||
"top_n": 20 // optional override
|
||||
}
|
||||
|
||||
// RESPONSE
|
||||
{
|
||||
"asof": "2026-05-12",
|
||||
"mode": "preview",
|
||||
"status": "success",
|
||||
"run_id": null, // manual_save·auto만
|
||||
"survivors_count": 612,
|
||||
"weights": { ... }, // 실제 사용된 값
|
||||
"top_n": 20,
|
||||
"results": [
|
||||
{
|
||||
"rank": 1,
|
||||
"ticker": "005930",
|
||||
"name": "삼성전자",
|
||||
"total_score": 84.3,
|
||||
"scores": {
|
||||
"foreign_buy": 92, "volume_surge": 78, "momentum": 73,
|
||||
"high52w": 88, "rs_rating": 95, "ma_alignment": 80, "vcp_lite": 70
|
||||
},
|
||||
"close": 74500,
|
||||
"market_cap": 444800000000000,
|
||||
"entry_price": 74872,
|
||||
"stop_price": 71200,
|
||||
"target_price": 82216,
|
||||
"atr14": 1835.5,
|
||||
"r_pct": 4.9
|
||||
}
|
||||
],
|
||||
"telegram_payload": null, // auto · manual_save만
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 mode 매트릭스
|
||||
|
||||
| mode | settings_override | DB 저장 | telegram_payload 반환 | telegram 실전송 |
|
||||
|------|------------------|---------|----------------------|----------------|
|
||||
| `preview` | 허용 (DB 미반영) | ❌ | ✅ (미리보기 표시용) | ❌ |
|
||||
| `manual_save` | 허용 (DB 미반영) | ✅ | ✅ | ❌ |
|
||||
| `auto` | 무시 (DB settings만) | ✅ | ✅ | ✅ (호출자=agent-office) |
|
||||
|
||||
`telegram_payload`는 `status='success'`일 때 항상 빌드해 반환 (페이로드 1회 생성 비용 매우 작음). **실전송은 mode='auto' 시 호출자(agent-office) 책임**. `status='failed'`·`'skipped_holiday'`이면 `null`.
|
||||
|
||||
### 6.4 `asof` 처리
|
||||
|
||||
- 요청에 `asof` 없으면: stock-lab이 `holidays.json` 참조해 **직전 거래일**로 자동 설정
|
||||
- 요청한 `asof`가 공휴일·주말이거나 캐시에 없으면: 503 + message "no snapshot for {asof}"
|
||||
- `agent-office` 자동 잡이 공휴일에 호출하는 경우 stock-lab은 status='skipped_holiday'로 success 응답 (텔레그램 전송 안 함)
|
||||
|
||||
### 6.5 에러 응답
|
||||
|
||||
응답 body의 `status` 필드와 HTTP status 코드의 매핑:
|
||||
|
||||
| HTTP | body.status | 발생 |
|
||||
|------|-------------|------|
|
||||
| 200 | `success` | 정상 분석 완료 |
|
||||
| 200 | `skipped_holiday` | 공휴일·주말 asof로 자동 잡이 호출됨 |
|
||||
| 422 | `failed` | 가중치 합 0, 게이트 통과 0, 잘못된 asof 형식 |
|
||||
| 503 | `failed` | 캐시 미존재 (snapshot 미실행) |
|
||||
| 500 | `failed` | 예기치 못한 예외 (응답 body는 일반 메시지) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 프론트엔드 구조 (web-ui)
|
||||
|
||||
### 7.1 라우팅 & 내비게이션
|
||||
|
||||
- `src/routes.jsx`: `/stock/screener` 등록, 라벨 "스크리너"
|
||||
- `src/Router.jsx`: 라우트 추가
|
||||
- Stock·StockTrade 페이지 상단에 "스크리너" 링크
|
||||
- 홈(`/`) 허브 카드에 항목 추가
|
||||
|
||||
### 7.2 디렉토리
|
||||
|
||||
```
|
||||
src/pages/stock/screener/
|
||||
├─ Screener.jsx # 페이지 루트
|
||||
├─ Screener.css
|
||||
├─ components/
|
||||
│ ├─ NodePanel.jsx # 점수 노드 7개 카드
|
||||
│ ├─ NodeCard.jsx # param_schema 기반 자동 폼
|
||||
│ ├─ GatePanel.jsx # 위생 게이트 1개
|
||||
│ ├─ GlobalControls.jsx # Top N, ATR, RR, "지금 실행", "스냅샷 저장"
|
||||
│ ├─ ResultTable.jsx
|
||||
│ ├─ ScoreChips.jsx # 각 노드 점수 칩
|
||||
│ ├─ RunHistoryList.jsx
|
||||
│ └─ TelegramPreview.jsx
|
||||
└─ hooks/
|
||||
├─ useScreenerMeta.js
|
||||
├─ useScreenerSettings.js
|
||||
├─ useScreenerRun.js
|
||||
└─ useScreenerHistory.js
|
||||
```
|
||||
|
||||
### 7.3 `src/api.js` 신규 헬퍼
|
||||
|
||||
```js
|
||||
export const getScreenerNodes = () => apiGet ('/api/stock/screener/nodes');
|
||||
export const getScreenerSettings = () => apiGet ('/api/stock/screener/settings');
|
||||
export const saveScreenerSettings = (body) => apiPut ('/api/stock/screener/settings', body);
|
||||
export const runScreener = (body) => apiPost('/api/stock/screener/run', body);
|
||||
export const refreshScreenerSnap = () => apiPost('/api/stock/screener/snapshot/refresh');
|
||||
export const listScreenerRuns = (limit = 30) => apiGet (`/api/stock/screener/runs?limit=${limit}`);
|
||||
export const getScreenerRun = (id) => apiGet (`/api/stock/screener/runs/${id}`);
|
||||
```
|
||||
|
||||
### 7.4 레이아웃
|
||||
|
||||
```
|
||||
PC (≥1024px)
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 헤더 — 분석 기준일 · 직전 자동 잡 시각 · "스냅샷 저장" │
|
||||
├──────────────┬──────────────────────────────┬───────────────┤
|
||||
│ NodePanel │ ResultTable │ RunHistoryList │
|
||||
│ + GlobalControls │ TelegramPreview │ │
|
||||
│ [지금 실행] │ │ │
|
||||
└──────────────┴──────────────────────────────┴───────────────┘
|
||||
|
||||
모바일 (<768px) — 세로 적층
|
||||
[헤더] → [NodePanel 접기] → [GlobalControls+실행] → [ResultTable 가로 스크롤]
|
||||
→ [TelegramPreview 접기] → [RunHistoryList]
|
||||
```
|
||||
|
||||
### 7.5 상태 관리 패턴
|
||||
|
||||
- `useScreenerMeta`: 마운트 시 1회, 정적
|
||||
- `useScreenerSettings`: GET → 사용자 슬라이더 조작 시 로컬 dirty state. **명시적 "설정 저장" 버튼**에서만 PUT
|
||||
- "지금 실행" → `runScreener({mode:'preview', ...override})`. **DB는 건드리지 않음**
|
||||
- "스냅샷 저장" → 같은 override를 `mode:'manual_save'`로 재호출
|
||||
- 히스토리 클릭 → `getScreenerRun(id)`로 결과 표 교체
|
||||
|
||||
---
|
||||
|
||||
## 8. 텔레그램 메시지 포맷
|
||||
|
||||
자동 잡과 manual_save 모두 동일. **Top 20 중 본문 1-10**까지 표시, 11-20은 페이지 링크. MarkdownV2.
|
||||
|
||||
```
|
||||
🎯 *KRX 강세주 스크리너* — 2026-05-12 (자동)
|
||||
통과 612종 / Top 20 / 본문 1-10
|
||||
|
||||
1. *삼성전자* `005930` ⭐ 84.3
|
||||
👤외 ⚡거 🚀모 🆙고 💪RS 📈MA
|
||||
진입 74,872 손절 71,200 익절 82,216 (R 4.9%)
|
||||
|
||||
2. *NAVER* `035420` ⭐ 81.7
|
||||
👤외 ⚡거 🆙고 💪RS 📈MA
|
||||
진입 215,400 손절 207,800 익절 230,600 (R 3.5%)
|
||||
|
||||
⋯ (3-10)
|
||||
|
||||
🔗 전체 결과·11~20위:
|
||||
https://gahusb.synology.me/stock/screener?run_id=42
|
||||
```
|
||||
|
||||
### 노드 아이콘 (점수 ≥70인 노드만 표시)
|
||||
|
||||
| 노드 | 아이콘 |
|
||||
|------|--------|
|
||||
| foreign_buy | 👤외 |
|
||||
| volume_surge | ⚡거 |
|
||||
| momentum | 🚀모 |
|
||||
| high52w | 🆙고 |
|
||||
| rs_rating | 💪RS |
|
||||
| ma_alignment | 📈MA |
|
||||
| vcp_lite | 🌀VCP |
|
||||
|
||||
빌더(`screener/telegram.py`)는 payload만 반환:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"chat_target": "default",
|
||||
"parse_mode": "MarkdownV2",
|
||||
"text": "..." // 위 메시지
|
||||
}
|
||||
```
|
||||
|
||||
agent-office가 받아서 자체 텔레그램 채널로 발신. stock-lab은 텔레그램 SDK 의존성 없음.
|
||||
|
||||
---
|
||||
|
||||
## 9. agent-office 통합
|
||||
|
||||
agent-office 측에 새 잡(또는 stock_agent 액션) 추가:
|
||||
|
||||
```text
|
||||
Trigger: 평일 16:30 KST (Asia/Seoul)
|
||||
Steps:
|
||||
1. POST /api/stock/screener/snapshot/refresh
|
||||
실패해도 다음 단계 진행 (이전 캐시로 분석)
|
||||
2. POST /api/stock/screener/run { "mode": "auto" }
|
||||
3. 응답에서 status 확인:
|
||||
- status == 'skipped_holiday': 종료, 텔레그램 미발신
|
||||
- status == 'success': telegram_payload 추출 → 발신
|
||||
- status == 'failed': agent-office 자체 알림(기존 패턴)으로 운영자에게
|
||||
4. 텔레그램 발신은 agent-office의 기존 채널 사용
|
||||
```
|
||||
|
||||
**공휴일 판정은 stock-lab 책임** (`holidays.json`이 stock-lab에 있으므로). agent-office는 매 평일 16:30에 호출하고 응답 status로 분기. agent-office에 공휴일 데이터를 복제할 필요 없음.
|
||||
|
||||
stock-lab은 agent-office의 인증을 신뢰 (내부 Docker 네트워크). MVP에서 헤더 토큰 검증 없음. 후속에서 필요해지면 시크릿 헤더 추가.
|
||||
|
||||
---
|
||||
|
||||
## 10. 에러 처리
|
||||
|
||||
| 발생 지점 | 정책 |
|
||||
|----------|------|
|
||||
| pykrx 종목 단위 실패 | retry ×3 → 실패해도 다음 종목 계속. 전체 실패율 >20%면 snapshot 작업 자체 실패 |
|
||||
| 캐시 미존재 (`asof` 데이터 없음) | 503 + message "snapshot not available for {asof}" |
|
||||
| 노드 1개 compute 실패 | 해당 노드 점수 0 처리, 다른 노드 정상. 응답 `warnings`에 사유 |
|
||||
| 게이트 통과 종목 0 | 422 + message "no survivors after hygiene gate" |
|
||||
| 모든 가중치 0 | 422 + message "no active score nodes" |
|
||||
| 텔레그램 전송 실패 | `/run` 응답 status는 success. agent-office 측 로그·재시도 |
|
||||
| 예기치 못한 예외 | 500. 스택트레이스는 stock-lab stdout 로그에만. 응답은 일반 메시지 |
|
||||
|
||||
`/run`의 `warnings` 필드는 치명적이지 않은 이상을 모음. 프론트는 결과 표 위에 노란 배너로 노출.
|
||||
|
||||
---
|
||||
|
||||
## 11. 테스트 전략
|
||||
|
||||
stock-lab의 평탄 pytest 컨벤션을 따름. `app/test_screener_*.py`로 통합.
|
||||
|
||||
### 11.1 단위 테스트 (노드별)
|
||||
|
||||
```
|
||||
app/test_screener_nodes_foreign_buy.py
|
||||
app/test_screener_nodes_volume_surge.py
|
||||
app/test_screener_nodes_momentum.py
|
||||
app/test_screener_nodes_high52w.py
|
||||
app/test_screener_nodes_rs_rating.py
|
||||
app/test_screener_nodes_ma_alignment.py
|
||||
app/test_screener_nodes_vcp_lite.py
|
||||
app/test_screener_nodes_hygiene.py
|
||||
app/test_screener_position_sizer.py
|
||||
```
|
||||
|
||||
**공통 케이스**:
|
||||
|
||||
1. 알려진 입력 → 알려진 출력 (회귀 방지)
|
||||
2. 데이터 부족(상장 30일짜리) → 게이트 탈락 또는 NaN 안전
|
||||
3. 모든 종목 동일 값 → 백분위 정규화가 50점으로 평탄화
|
||||
4. 극값 1개 → 다른 종목 점수가 무너지지 않음 (특히 volume_surge의 log1p)
|
||||
|
||||
### 11.2 통합 테스트
|
||||
|
||||
```
|
||||
app/test_screener_engine.py # combine, Screener.run, ScreenContext.restrict
|
||||
app/test_screener_router.py # /run mode 매트릭스, /settings round-trip, /nodes, /runs
|
||||
app/test_screener_telegram.py # 메시지 텍스트 생성
|
||||
```
|
||||
|
||||
### 11.3 픽스쳐
|
||||
|
||||
`app/screener/_test_fixtures.py`:
|
||||
- 5종목 × 60거래일 합성 DataFrame 빌더
|
||||
- 시나리오: "강세주 1종", "위생 게이트 탈락 1종(시총 부족)", "데이터 부족 1종", "약세주 2종"
|
||||
- `StubScreenContext`: DB 거치지 않고 메모리 DataFrame 주입
|
||||
|
||||
### 11.4 수동 검증 (verification-before-completion)
|
||||
|
||||
- 실 KRX 데이터로 1회 돌려 Top 20이 합리적인 강세주 후보인지 사용자가 눈으로 확인
|
||||
- 자동 잡 1회 실행 후 텔레그램에 메시지 도착 확인
|
||||
- 모바일 화면에서 결과 표 가로 스크롤 OK 확인
|
||||
|
||||
---
|
||||
|
||||
## 12. 운영
|
||||
|
||||
- 로그: stock-lab stdout (Docker logs)
|
||||
- 알림: agent-office가 `/run` failed 응답을 받으면 텔레그램 자체 알림
|
||||
- 백업: stock.db는 NAS Synology 자체 백업 정책에 의존
|
||||
- 메트릭 대시보드: MVP 범위 밖 (후속 슬라이스)
|
||||
|
||||
---
|
||||
|
||||
## 13. 양쪽 동시 수정 체크리스트 (workspace CLAUDE.md 규약)
|
||||
|
||||
- [ ] 백엔드: `web-backend/stock-lab/app/screener/` 패키지 신규
|
||||
- [ ] 백엔드: `app/main.py`에 router include
|
||||
- [ ] 백엔드: stock.db에 신규 테이블 7개 `ensure_*_schema()` 함수
|
||||
- [ ] 백엔드: `requirements.txt`에 `pykrx` 추가
|
||||
- [ ] 프론트: `src/api.js`에 7개 헬퍼 추가
|
||||
- [ ] 프론트: `src/routes.jsx` + `src/Router.jsx`에 `/stock/screener` 등록
|
||||
- [ ] 프론트: `src/pages/stock/screener/` 디렉토리 신규
|
||||
- [ ] 프론트: `web-ui/CLAUDE.md` API 테이블에 7개 엔드포인트 추가
|
||||
- [ ] agent-office: 평일 16:30 KST `stock_agent screener` 잡 추가
|
||||
- [ ] 배포: `scripts/deploy.bat` 또는 개별
|
||||
|
||||
---
|
||||
|
||||
## 14. 후속 슬라이스 예약
|
||||
|
||||
| # | 슬라이스 | 의존 |
|
||||
|---|---------|------|
|
||||
| 2 | AI 뉴스 호재/악재 노드 | agent-office LLM 사용량 설계 |
|
||||
| 3 | 노드 캔버스 UI (react-flow) | MVP 노드 인터페이스 안정화 후 |
|
||||
| 4 | 주간 자가학습 (가중치 자동 조정 제안) | screener_runs 누적 4주 이상 |
|
||||
| 5 | DART 공시·재무제표 노드 | DART 수집 파이프라인 별도 spec |
|
||||
| 6 | 분봉 기반 노드 | 한투 API 분봉 캐싱 |
|
||||
| 7 | 진짜 미너비니 VCP | 베이스 카운트·피벗 포인트 정의 |
|
||||
| 8 | 멀티 프리셋 | settings 테이블 확장 |
|
||||
| 9 | 백테스트 화면 | screener_runs + krx_daily_prices join |
|
||||
| 10 | KRX 호가단위 적용 | 포지션 사이저 후처리 |
|
||||
|
||||
---
|
||||
|
||||
## 부록 A — 노드 메타데이터 응답 예시 (`GET /nodes`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"score_nodes": [
|
||||
{
|
||||
"name": "foreign_buy",
|
||||
"label": "외국인 누적 순매수",
|
||||
"default_params": { "window_days": 5 },
|
||||
"param_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"window_days": { "type": "integer", "minimum": 1, "maximum": 60, "default": 5 }
|
||||
}
|
||||
}
|
||||
}
|
||||
// … 7개
|
||||
],
|
||||
"gate_nodes": [
|
||||
{
|
||||
"name": "hygiene",
|
||||
"label": "위생 게이트",
|
||||
"default_params": {
|
||||
"min_market_cap_won": 50000000000,
|
||||
"min_avg_value_won": 500000000,
|
||||
"min_listed_days": 60,
|
||||
"skip_managed": true,
|
||||
"skip_preferred": true,
|
||||
"skip_spac": true,
|
||||
"skip_halted_days": 3
|
||||
},
|
||||
"param_schema": { ... }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
이 응답으로 프론트는 `NodeCard`를 자동 생성합니다. 새 노드 추가 시 백엔드 클래스 1개 + registry 등록 1줄만으로 UI에 자동 노출.
|
||||
@@ -1,58 +1,122 @@
|
||||
const { execSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
// Load .env.local from project root if present (persists NAS_SSH_TARGET etc.)
|
||||
const envLocalPath = path.join(__dirname, "..", ".env.local");
|
||||
if (fs.existsSync(envLocalPath)) {
|
||||
for (const line of fs.readFileSync(envLocalPath, "utf8").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const idx = trimmed.indexOf("=");
|
||||
if (idx < 0) continue;
|
||||
const k = trimmed.slice(0, idx).trim();
|
||||
const v = trimmed.slice(idx + 1).trim();
|
||||
if (!(k in process.env)) process.env[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
const isMac = process.platform === "darwin";
|
||||
const src = "dist";
|
||||
const dstWin = "Z:\\docker\\webpage\\frontend\\";
|
||||
const dstMac = "/Volumes/gahusb.synology.me/docker/webpage/frontend/";
|
||||
// Windows 배포 경로 — Z: 매핑이 NAS 루트(/volume1/)인 경우 docker\webpage\frontend,
|
||||
// /volume1/docker/만 매핑된 경우 webpage\frontend, /volume1/docker/webpage 매핑이면 frontend.
|
||||
// NAS_FRONTEND_DEST_WIN env로 override (예: "Z:\\webpage\\frontend\\")
|
||||
const dstWin = process.env.NAS_FRONTEND_DEST_WIN || "Z:\\docker\\webpage\\frontend\\";
|
||||
const dstMac = process.env.NAS_FRONTEND_DEST_MAC || "/Volumes/gahusb.synology.me/docker/webpage/frontend/";
|
||||
const dst = isWin ? dstWin : dstMac;
|
||||
|
||||
if (!fs.existsSync(src)) {
|
||||
console.error("dist not found. Run build first.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!fs.existsSync(dst)) {
|
||||
console.error("NAS path not found. Check mount: " + dst);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (isWin) {
|
||||
// dstWin을 PowerShell 문자열로 안전하게 escape
|
||||
const dstPs = dstWin.replace(/\\/g, "\\\\");
|
||||
const cmd =
|
||||
'powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=\\"Stop\\"; $src=\\"dist\\"; $dst=\\"Z:\\\\docker\\\\webpage\\\\frontend\\\\\\"; if(!(Test-Path $src)){ throw \\"dist not found. Run build first.\\" }; if(!(Test-Path $dst)){ throw \\"NAS drive not found. Check Z: mapping.\\" }; $log = Join-Path (Get-Location) \\"robocopy.log\\"; robocopy $src $dst /MIR /R:1 /W:1 /E /NFL /NDL /NP /V /TEE /LOG:$log; $rc = $LASTEXITCODE; if($rc -ge 8){ Write-Host \\"robocopy failed with code $rc. See $log\\"; exit $rc } else { exit 0 }"';
|
||||
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=\\"Stop\\"; $src=\\"dist\\"; $dst=\\"${dstPs}\\"; if(!(Test-Path $src)){ throw \\"dist not found. Run build first.\\" }; if(!(Test-Path $dst)){ throw \\"NAS 경로를 찾을 수 없음: $dst — Z: 매핑 또는 NAS_FRONTEND_DEST_WIN env 확인\\" }; $log = Join-Path (Get-Location) \\"robocopy.log\\"; robocopy $src $dst /MIR /R:1 /W:1 /E /NFL /NDL /NP /V /TEE /LOG:$log; $rc = $LASTEXITCODE; if($rc -ge 8){ Write-Host \\"robocopy failed with code $rc. See $log\\"; exit $rc } else { exit 0 }"`;
|
||||
execSync(cmd, { stdio: "inherit" });
|
||||
} else if (isMac) {
|
||||
const sshTarget = process.env.NAS_SSH_TARGET;
|
||||
const sshPath =
|
||||
process.env.NAS_SSH_PATH || "/volume1/docker/webpage/frontend/";
|
||||
const sshPort = process.env.NAS_SSH_PORT;
|
||||
|
||||
// SSH 경로: NAS_SSH_TARGET이 설정된 경우 항상 우선
|
||||
if (sshTarget) {
|
||||
const sshCmd = sshPort ? `ssh -p ${sshPort}` : "ssh";
|
||||
// 제어문자·줄바꿈 제거 (잘못된 export/copy-paste 대비)
|
||||
const cleanTarget = sshTarget.replace(/[\r\n\t]/g, "").trim();
|
||||
const cleanPath = sshPath.replace(/[\r\n\t]/g, "").trim();
|
||||
const cleanPort = sshPort ? sshPort.replace(/\D/g, "").trim() : "";
|
||||
|
||||
if (!cleanTarget) {
|
||||
console.error("NAS_SSH_TARGET 값이 비어있습니다. .env.local 또는 환경변수를 확인하세요.");
|
||||
printSshHint();
|
||||
process.exit(1);
|
||||
}
|
||||
if (cleanPort && !/^\d{1,5}$/.test(cleanPort)) {
|
||||
console.error(`NAS_SSH_PORT 값이 잘못됐습니다: "${sshPort}" → 숫자만 입력하세요.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// macOS Keychain은 서브프로세스(rsync)에서 SSH 키를 자동 로드하지 못함 → -i 명시
|
||||
const keyFile = (process.env.NAS_SSH_KEY || path.join(os.homedir(), ".ssh", "id_rsa"))
|
||||
.replace(/[\r\n]/g, "").trim();
|
||||
|
||||
if (!fs.existsSync(keyFile)) {
|
||||
console.error(`SSH 키 파일을 찾을 수 없습니다: ${keyFile}`);
|
||||
console.error("NAS_SSH_KEY 환경변수를 올바른 키 경로로 설정하거나, ~/.ssh/id_rsa 가 있는지 확인하세요.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const portOpt = cleanPort ? `-p ${cleanPort}` : "";
|
||||
// Synology는 rsync --server 모드를 별도 인증으로 막음 → tar | ssh 방식 사용
|
||||
const sshBase = `ssh ${portOpt} -i ${keyFile} -o StrictHostKeyChecking=accept-new -o PreferredAuthentications=publickey`
|
||||
.replace(/\s+/g, " ").trim();
|
||||
|
||||
console.log(`Deploying via tar|ssh → ${cleanTarget}:${cleanPath}`);
|
||||
|
||||
// 1단계: 원격 디렉토리 초기화
|
||||
execSync(
|
||||
`rsync -r --delete --delete-delay -e \"${sshCmd}\" ${src}/ ${sshTarget}:${sshPath}`,
|
||||
`${sshBase} ${cleanTarget} "rm -rf '${cleanPath}'/* 2>/dev/null; mkdir -p '${cleanPath}'"`,
|
||||
{ stdio: "inherit" }
|
||||
);
|
||||
// 2단계: 빌드 산출물 tar로 전송 → 원격에서 압축 해제
|
||||
execSync(
|
||||
`cd ${src} && tar czf - . | ${sshBase} ${cleanTarget} "cd '${cleanPath}' && tar xzf -"`,
|
||||
{ stdio: "inherit" }
|
||||
);
|
||||
console.log("Deploy complete.");
|
||||
process.exit(0);
|
||||
}
|
||||
// rsync on macOS + SMB/NAS can be flaky; use ditto after a safe clean.
|
||||
|
||||
// SMB 마운트 경로 fallback
|
||||
if (!fs.existsSync(dst)) {
|
||||
console.error("NAS path not found: " + dst);
|
||||
printSshHint();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!dst.includes("docker/webpage/frontend")) {
|
||||
console.error("Safety check failed: unexpected dst path: " + dst);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const testPath = `${dst}.deploy-write-test`;
|
||||
fs.writeFileSync(testPath, "ok");
|
||||
fs.unlinkSync(testPath);
|
||||
} catch (err) {
|
||||
console.error("NAS write test failed (EIO / permission error).");
|
||||
console.error(
|
||||
"NAS write test failed. Files may be locked or permissions are read-only."
|
||||
"macOS SMB → Synology 쓰기 실패는 흔한 이슈입니다. SSH 배포를 사용하세요.\n"
|
||||
);
|
||||
console.error(
|
||||
"Try stopping services using the folder, remounting the share with write access,",
|
||||
"or set NAS_SSH_TARGET to deploy over SSH instead."
|
||||
);
|
||||
throw err;
|
||||
printSshHint();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sleep = (ms) =>
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
const retry = (fn, attempts = 6) => {
|
||||
@@ -96,3 +160,15 @@ if (isWin) {
|
||||
const cmd = `${baseArgs.join(" ")} ${src}/ ${dst}`;
|
||||
execSync(cmd, { stdio: "inherit" });
|
||||
}
|
||||
|
||||
function printSshHint() {
|
||||
console.error("──────────────────────────────────────────────────");
|
||||
console.error("SSH 배포 설정 방법:");
|
||||
console.error(" 프로젝트 루트에 .env.local 파일을 만들고 아래 내용을 입력하세요:");
|
||||
console.error("");
|
||||
console.error(" NAS_SSH_TARGET=<NAS_유저명>@gahusb.synology.me");
|
||||
console.error(" NAS_SSH_PORT=<SSH_포트> # 기본 22, DSM에서 확인");
|
||||
console.error("");
|
||||
console.error(" 이후 npm run release:nas 를 다시 실행하면 rsync over SSH로 배포됩니다.");
|
||||
console.error("──────────────────────────────────────────────────");
|
||||
}
|
||||
|
||||
78
src/api.js
78
src/api.js
@@ -626,3 +626,81 @@ export async function triggerLottoCurate() {
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// ── Music Lab — Video Projects ────────────────────
|
||||
export const createVideoProject = (data) => apiPost('/api/music/video-project', data);
|
||||
export const getVideoProjects = () => apiGet('/api/music/video-projects');
|
||||
export const renderVideoProject = (id) => apiPost(`/api/music/video-project/${id}/render`);
|
||||
export const exportVideoProject = (id) => apiGet(`/api/music/video-project/${id}/export`);
|
||||
export const deleteVideoProject = (id) => apiDelete(`/api/music/video-project/${id}`);
|
||||
|
||||
// ── Music Lab — Revenue ───────────────────────────
|
||||
export const getRevenueDashboard = () => apiGet('/api/music/revenue/dashboard');
|
||||
export const getRevenueRecords = () => apiGet('/api/music/revenue');
|
||||
export const addRevenueRecord = (data) => apiPost('/api/music/revenue', data);
|
||||
export const updateRevenueRecord = (id, data) => apiPut(`/api/music/revenue/${id}`, data);
|
||||
export const deleteRevenueRecord = (id) => apiDelete(`/api/music/revenue/${id}`);
|
||||
|
||||
// ── Music Lab — Market Trends ─────────────────────
|
||||
export const getLatestTrendReport = () => apiGet('/api/music/market/report/latest');
|
||||
export const getTrendReports = () => apiGet('/api/music/market/report');
|
||||
export const getMarketSuggestions = () => apiGet('/api/music/market/suggest');
|
||||
export const triggerYoutubeResearch = () => apiPost('/api/agent-office/youtube/research', {});
|
||||
|
||||
// ── Music Lab — Compile ──────────────────────────────────
|
||||
export const createCompileJob = (data) => apiPost('/api/music/compile', data);
|
||||
export const getCompileJobs = () => apiGet('/api/music/compiles');
|
||||
export const getCompileJob = (id) => apiGet(`/api/music/compile/${id}`);
|
||||
export const deleteCompileJob = (id) => apiDelete(`/api/music/compile/${id}`);
|
||||
export const exportCompileJob = (id) => apiGet(`/api/music/compile/${id}/export`);
|
||||
|
||||
// --- Music Pipeline ---
|
||||
export const listPipelines = (status='all') => apiGet(`/api/music/pipeline?status=${status}`);
|
||||
export const getPipeline = (id) => apiGet(`/api/music/pipeline/${id}`);
|
||||
export const createPipeline = (payload) => {
|
||||
// 옛 호출 호환: createPipeline(13) → { track_id: 13 }
|
||||
if (typeof payload === 'number') payload = { track_id: payload };
|
||||
return apiPost('/api/music/pipeline', payload);
|
||||
};
|
||||
export const startPipeline = (id) => apiPost(`/api/music/pipeline/${id}/start`);
|
||||
export const cancelPipeline = (id) => apiPost(`/api/music/pipeline/${id}/cancel`);
|
||||
export const publishPipeline = (id) => apiPost(`/api/music/pipeline/${id}/publish`);
|
||||
|
||||
// --- Music Setup ---
|
||||
export const getMusicSetup = () => apiGet('/api/music/setup');
|
||||
export const updateMusicSetup = (payload) => apiPut('/api/music/setup', payload);
|
||||
|
||||
// --- YouTube OAuth ---
|
||||
export const getYoutubeAuthUrl = () => apiGet('/api/music/youtube/auth-url');
|
||||
export const getYoutubeStatus = () => apiGet('/api/music/youtube/status');
|
||||
export const disconnectYoutube = () => apiPost('/api/music/youtube/disconnect');
|
||||
|
||||
// === Batch generation ===
|
||||
export const startBatchGen = (payload) => apiPost('/api/music/generate-batch', payload);
|
||||
export const getBatchJob = (id) => apiGet(`/api/music/generate-batch/${id}`);
|
||||
export const listBatchJobs = (status='all') => apiGet(`/api/music/generate-batch?status=${status}`);
|
||||
export const listGenres = () => apiGet('/api/music/genres');
|
||||
|
||||
// === 주간 회고 (weekly_review) ===
|
||||
// apiGet은 비-2xx 응답에서 `HTTP <status> ...` 메시지로 Error를 throw 하므로
|
||||
// 404 케이스는 메시지를 파싱하여 null로 변환한다.
|
||||
export const getLatestReview = () => apiGet('/api/lotto/review/latest').catch(e => {
|
||||
if (e?.status === 404 || /^HTTP 404\b/.test(e?.message || '')) return null;
|
||||
throw e;
|
||||
});
|
||||
|
||||
export const getReviewHistory = (limit = 4) =>
|
||||
apiGet(`/api/lotto/review/history?limit=${limit}`).then(d => d.reviews || []);
|
||||
|
||||
// === 큐레이터 4계층 원클릭 구매 ===
|
||||
export const bulkPurchase = ({ draw_no, tier_mode, sets, amount }) =>
|
||||
apiPost('/api/lotto/purchase/bulk', { draw_no, tier_mode, sets, amount });
|
||||
|
||||
// ---- Stock Screener ----
|
||||
export const getScreenerNodes = () => apiGet ('/api/stock/screener/nodes');
|
||||
export const getScreenerSettings = () => apiGet ('/api/stock/screener/settings');
|
||||
export const saveScreenerSettings = (body) => apiPut ('/api/stock/screener/settings', body);
|
||||
export const runScreener = (body) => apiPost('/api/stock/screener/run', body);
|
||||
export const refreshScreenerSnap = () => apiPost('/api/stock/screener/snapshot/refresh');
|
||||
export const listScreenerRuns = (limit = 30) => apiGet (`/api/stock/screener/runs?limit=${limit}`);
|
||||
export const getScreenerRun = (id) => apiGet (`/api/stock/screener/runs/${id}`);
|
||||
|
||||
|
||||
@@ -102,6 +102,16 @@ export const IconBlogMarketing = () =>
|
||||
</>
|
||||
);
|
||||
|
||||
export const IconPortfolio = () =>
|
||||
svg(
|
||||
<>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</>
|
||||
);
|
||||
|
||||
export const IconBuilding = () =>
|
||||
svg(
|
||||
<>
|
||||
|
||||
147
src/components/LogoLoop.css
Normal file
147
src/components/LogoLoop.css
Normal file
@@ -0,0 +1,147 @@
|
||||
.logoloop {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logoloop:not(.logoloop--vertical) {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.logoloop--vertical {
|
||||
overflow-y: hidden;
|
||||
height: 100%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.logoloop__track {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: max-content;
|
||||
will-change: transform;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.logoloop__track--vertical {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: max-content;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.logoloop__track {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.logoloop__list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.logoloop__list--vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logoloop__item {
|
||||
flex: none;
|
||||
font-size: var(--logoloop-logoHeight, 36px);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.logoloop__list:not(.logoloop__list--vertical) .logoloop__item {
|
||||
margin-right: var(--logoloop-gap, 32px);
|
||||
}
|
||||
|
||||
.logoloop__list--vertical .logoloop__item {
|
||||
margin-bottom: var(--logoloop-gap, 32px);
|
||||
}
|
||||
|
||||
.logoloop__item--scalable {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.logoloop__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: opacity 0.2s linear;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.logoloop__link:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.logoloop__link:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.logoloop__node {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logoloop__node--scale,
|
||||
.logoloop__img--scale {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.logoloop__item--scalable:hover .logoloop__node--scale,
|
||||
.logoloop__item--scalable:hover .logoloop__img--scale {
|
||||
transform: scale(1.18);
|
||||
}
|
||||
|
||||
.logoloop__img {
|
||||
height: var(--logoloop-logoHeight, 36px);
|
||||
width: auto;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
-webkit-user-drag: none;
|
||||
pointer-events: none;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
|
||||
.logoloop__fade {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.logoloop__fade--left {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: clamp(24px, 8%, 120px);
|
||||
background: linear-gradient(to right, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.logoloop__fade--right {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: clamp(24px, 8%, 120px);
|
||||
background: linear-gradient(to left, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.logoloop__fade--top {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: clamp(24px, 8%, 120px);
|
||||
background: linear-gradient(to bottom, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.logoloop__fade--bottom {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: clamp(24px, 8%, 120px);
|
||||
background: linear-gradient(to top, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
|
||||
}
|
||||
322
src/components/LogoLoop.jsx
Normal file
322
src/components/LogoLoop.jsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import './LogoLoop.css';
|
||||
|
||||
const ANIMATION_CONFIG = {
|
||||
SMOOTH_TAU: 0.25,
|
||||
MIN_COPIES: 2,
|
||||
COPY_HEADROOM: 2,
|
||||
};
|
||||
|
||||
const toCssLength = (value) =>
|
||||
typeof value === 'number' ? `${value}px` : value ?? undefined;
|
||||
|
||||
const cx = (...parts) => parts.filter(Boolean).join(' ');
|
||||
|
||||
function useResizeObserver(callback, elements, deps) {
|
||||
useEffect(() => {
|
||||
if (!window.ResizeObserver) {
|
||||
const handler = () => callback();
|
||||
window.addEventListener('resize', handler);
|
||||
callback();
|
||||
return () => window.removeEventListener('resize', handler);
|
||||
}
|
||||
const observers = elements.map((ref) => {
|
||||
if (!ref.current) return null;
|
||||
const observer = new ResizeObserver(callback);
|
||||
observer.observe(ref.current);
|
||||
return observer;
|
||||
});
|
||||
callback();
|
||||
return () => {
|
||||
observers.forEach((o) => o?.disconnect());
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
}
|
||||
|
||||
function useImageLoader(seqRef, onLoad, deps) {
|
||||
useEffect(() => {
|
||||
const images = seqRef.current?.querySelectorAll('img') ?? [];
|
||||
if (images.length === 0) {
|
||||
onLoad();
|
||||
return;
|
||||
}
|
||||
let remaining = images.length;
|
||||
const handleLoad = () => {
|
||||
remaining -= 1;
|
||||
if (remaining === 0) onLoad();
|
||||
};
|
||||
images.forEach((img) => {
|
||||
if (img.complete) {
|
||||
handleLoad();
|
||||
} else {
|
||||
img.addEventListener('load', handleLoad, { once: true });
|
||||
img.addEventListener('error', handleLoad, { once: true });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
images.forEach((img) => {
|
||||
img.removeEventListener('load', handleLoad);
|
||||
img.removeEventListener('error', handleLoad);
|
||||
});
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
}
|
||||
|
||||
function useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical) {
|
||||
const rafRef = useRef(null);
|
||||
const lastTsRef = useRef(null);
|
||||
const offsetRef = useRef(0);
|
||||
const velocityRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
|
||||
const prefersReduced =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const seqSize = isVertical ? seqHeight : seqWidth;
|
||||
|
||||
if (seqSize > 0) {
|
||||
offsetRef.current = ((offsetRef.current % seqSize) + seqSize) % seqSize;
|
||||
track.style.transform = isVertical
|
||||
? `translate3d(0, ${-offsetRef.current}px, 0)`
|
||||
: `translate3d(${-offsetRef.current}px, 0, 0)`;
|
||||
}
|
||||
|
||||
if (prefersReduced) {
|
||||
track.style.transform = 'translate3d(0, 0, 0)';
|
||||
return () => {
|
||||
lastTsRef.current = null;
|
||||
};
|
||||
}
|
||||
|
||||
const animate = (ts) => {
|
||||
if (lastTsRef.current === null) lastTsRef.current = ts;
|
||||
const dt = Math.max(0, ts - lastTsRef.current) / 1000;
|
||||
lastTsRef.current = ts;
|
||||
|
||||
const target = isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity;
|
||||
const easing = 1 - Math.exp(-dt / ANIMATION_CONFIG.SMOOTH_TAU);
|
||||
velocityRef.current += (target - velocityRef.current) * easing;
|
||||
|
||||
if (seqSize > 0) {
|
||||
let next = offsetRef.current + velocityRef.current * dt;
|
||||
next = ((next % seqSize) + seqSize) % seqSize;
|
||||
offsetRef.current = next;
|
||||
track.style.transform = isVertical
|
||||
? `translate3d(0, ${-offsetRef.current}px, 0)`
|
||||
: `translate3d(${-offsetRef.current}px, 0, 0)`;
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
return () => {
|
||||
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
lastTsRef.current = null;
|
||||
};
|
||||
}, [trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical]);
|
||||
}
|
||||
|
||||
export default function LogoLoop({
|
||||
logos,
|
||||
speed = 60,
|
||||
direction = 'left',
|
||||
width = '100%',
|
||||
logoHeight = 36,
|
||||
gap = 32,
|
||||
pauseOnHover = true,
|
||||
hoverSpeed,
|
||||
fadeOut = true,
|
||||
fadeOutColor,
|
||||
scaleOnHover = true,
|
||||
ariaLabel = 'Skill logos',
|
||||
className,
|
||||
style,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const trackRef = useRef(null);
|
||||
const seqRef = useRef(null);
|
||||
|
||||
const [seqWidth, setSeqWidth] = useState(0);
|
||||
const [seqHeight, setSeqHeight] = useState(0);
|
||||
const [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const effectiveHoverSpeed = useMemo(() => {
|
||||
if (hoverSpeed !== undefined) return hoverSpeed;
|
||||
if (pauseOnHover === true) return 0;
|
||||
if (pauseOnHover === false) return undefined;
|
||||
return 0;
|
||||
}, [hoverSpeed, pauseOnHover]);
|
||||
|
||||
const isVertical = direction === 'up' || direction === 'down';
|
||||
|
||||
const targetVelocity = useMemo(() => {
|
||||
const magnitude = Math.abs(speed);
|
||||
const dirMul = isVertical
|
||||
? direction === 'up'
|
||||
? 1
|
||||
: -1
|
||||
: direction === 'left'
|
||||
? 1
|
||||
: -1;
|
||||
const speedMul = speed < 0 ? -1 : 1;
|
||||
return magnitude * dirMul * speedMul;
|
||||
}, [speed, direction, isVertical]);
|
||||
|
||||
const updateDimensions = useCallback(() => {
|
||||
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
||||
const seqRect = seqRef.current?.getBoundingClientRect?.();
|
||||
const sw = seqRect?.width ?? 0;
|
||||
const sh = seqRect?.height ?? 0;
|
||||
if (isVertical) {
|
||||
const parentH = containerRef.current?.parentElement?.clientHeight ?? 0;
|
||||
if (containerRef.current && parentH > 0) {
|
||||
const h = Math.ceil(parentH);
|
||||
if (containerRef.current.style.height !== `${h}px`)
|
||||
containerRef.current.style.height = `${h}px`;
|
||||
}
|
||||
if (sh > 0) {
|
||||
setSeqHeight(Math.ceil(sh));
|
||||
const viewport = containerRef.current?.clientHeight ?? parentH ?? sh;
|
||||
const copies = Math.ceil(viewport / sh) + ANIMATION_CONFIG.COPY_HEADROOM;
|
||||
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copies));
|
||||
}
|
||||
} else if (sw > 0) {
|
||||
setSeqWidth(Math.ceil(sw));
|
||||
const copies = Math.ceil(containerWidth / sw) + ANIMATION_CONFIG.COPY_HEADROOM;
|
||||
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copies));
|
||||
}
|
||||
}, [isVertical]);
|
||||
|
||||
useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight, isVertical]);
|
||||
useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight, isVertical]);
|
||||
useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, effectiveHoverSpeed, isVertical);
|
||||
|
||||
const cssVars = useMemo(() => ({
|
||||
'--logoloop-gap': `${gap}px`,
|
||||
'--logoloop-logoHeight': `${logoHeight}px`,
|
||||
...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor }),
|
||||
}), [gap, logoHeight, fadeOutColor]);
|
||||
|
||||
const containerStyle = useMemo(() => ({
|
||||
width: isVertical
|
||||
? toCssLength(width) === '100%'
|
||||
? undefined
|
||||
: toCssLength(width)
|
||||
: toCssLength(width) ?? '100%',
|
||||
...cssVars,
|
||||
...style,
|
||||
}), [width, cssVars, style, isVertical]);
|
||||
|
||||
const handleEnter = useCallback(() => {
|
||||
if (effectiveHoverSpeed !== undefined) setIsHovered(true);
|
||||
}, [effectiveHoverSpeed]);
|
||||
const handleLeave = useCallback(() => {
|
||||
if (effectiveHoverSpeed !== undefined) setIsHovered(false);
|
||||
}, [effectiveHoverSpeed]);
|
||||
|
||||
const renderItem = (item, key) => {
|
||||
const isNode = 'node' in item;
|
||||
const inner = isNode ? (
|
||||
<span
|
||||
className={cx('logoloop__node', scaleOnHover && 'logoloop__node--scale')}
|
||||
aria-hidden={!!item.href && !item.ariaLabel}
|
||||
>
|
||||
{item.node}
|
||||
</span>
|
||||
) : (
|
||||
<img
|
||||
className={cx('logoloop__img', scaleOnHover && 'logoloop__img--scale')}
|
||||
src={item.src}
|
||||
srcSet={item.srcSet}
|
||||
sizes={item.sizes}
|
||||
width={item.width}
|
||||
height={item.height}
|
||||
alt={item.alt ?? ''}
|
||||
title={item.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
const itemAriaLabel = isNode ? item.ariaLabel ?? item.title : item.alt ?? item.title;
|
||||
const wrapper = item.href ? (
|
||||
<a
|
||||
className="logoloop__link"
|
||||
href={item.href}
|
||||
aria-label={itemAriaLabel || 'logo link'}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
) : (
|
||||
inner
|
||||
);
|
||||
return (
|
||||
<li
|
||||
className={cx('logoloop__item', scaleOnHover && 'logoloop__item--scalable')}
|
||||
key={key}
|
||||
role="listitem"
|
||||
>
|
||||
{wrapper}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const lists = useMemo(
|
||||
() =>
|
||||
Array.from({ length: copyCount }, (_, i) => (
|
||||
<ul
|
||||
className={cx('logoloop__list', isVertical && 'logoloop__list--vertical')}
|
||||
key={`copy-${i}`}
|
||||
role="list"
|
||||
aria-hidden={i > 0}
|
||||
ref={i === 0 ? seqRef : undefined}
|
||||
>
|
||||
{logos.map((it, idx) => renderItem(it, `${i}-${idx}`))}
|
||||
</ul>
|
||||
)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[copyCount, logos, isVertical, scaleOnHover],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cx('logoloop', isVertical && 'logoloop--vertical', className)}
|
||||
style={containerStyle}
|
||||
role="region"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{fadeOut && (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cx('logoloop__fade', isVertical ? 'logoloop__fade--top' : 'logoloop__fade--left')}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cx('logoloop__fade', isVertical ? 'logoloop__fade--bottom' : 'logoloop__fade--right')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={cx('logoloop__track', isVertical && 'logoloop__track--vertical')}
|
||||
ref={trackRef}
|
||||
onMouseEnter={handleEnter}
|
||||
onMouseLeave={handleLeave}
|
||||
>
|
||||
{lists}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -108,7 +108,7 @@
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
padding-bottom: calc(16px + var(--safe-area-bottom));
|
||||
padding-bottom: calc(20px + var(--safe-area-bottom));
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,18 @@
|
||||
.swipeable-view__tab.is-active {
|
||||
background: var(--surface-raised);
|
||||
color: var(--neon-cyan);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.swipeable-view__tab.is-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
height: 2px;
|
||||
background: var(--neon-cyan);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* Sliding track */
|
||||
@@ -67,6 +79,15 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Mobile touch targets */
|
||||
@media (max-width: 768px) {
|
||||
.swipeable-view__tab {
|
||||
min-height: 44px;
|
||||
font-size: 14px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.swipeable-view__track {
|
||||
|
||||
@@ -1,400 +1,477 @@
|
||||
.ao-page {
|
||||
/* src/pages/agent-office/AgentOffice.css */
|
||||
|
||||
/* ===== Root Layout ===== */
|
||||
.ao-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #0d0d1a;
|
||||
color: #e0e0e0;
|
||||
color: #ffffff;
|
||||
font-family: 'Courier New', monospace;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ao-header {
|
||||
/* ===== Top Bar ===== */
|
||||
.ao-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 20px;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
background: #1a1a2e;
|
||||
border-bottom: 1px solid #2a2a4a;
|
||||
border-bottom: 1px solid #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ao-title {
|
||||
font-size: 1.2rem;
|
||||
color: #8b5cf6;
|
||||
margin: 0;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.ao-status {
|
||||
.ao-topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
gap: 12px;
|
||||
}
|
||||
.ao-topbar-title {
|
||||
font-weight: bold;
|
||||
font-size: 15px;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
.ao-topbar-status {
|
||||
font-size: 11px;
|
||||
}
|
||||
.ao-topbar-status.connected { color: #22c55e; }
|
||||
.ao-topbar-status.disconnected { color: #ef4444; }
|
||||
.ao-topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.ao-topbar-select {
|
||||
background: #2a2a3e;
|
||||
color: #aaa;
|
||||
border: 1px solid #444;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ao-topbar-zoom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.ao-topbar-zoom button {
|
||||
background: #2a2a3e;
|
||||
color: #aaa;
|
||||
border: 1px solid #444;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.ao-topbar-zoom button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
.ao-topbar-zoom span {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ao-dot {
|
||||
/* ===== Main Area ===== */
|
||||
.ao-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ao-canvas {
|
||||
flex: 1;
|
||||
cursor: grab;
|
||||
display: block;
|
||||
}
|
||||
.ao-canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* ===== Side Panel ===== */
|
||||
.ao-sidepanel {
|
||||
width: 320px;
|
||||
background: #111;
|
||||
border-left: 1px solid #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
.ao-sidepanel-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.ao-sidepanel-agent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.ao-sidepanel-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
.ao-sidepanel-name {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
.ao-sidepanel-state {
|
||||
font-size: 11px;
|
||||
color: #22c55e;
|
||||
}
|
||||
.ao-sidepanel-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.ao-sidepanel-close:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.ao-sidepanel-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
.ao-sidepanel-tab {
|
||||
flex: 1;
|
||||
padding: 8px 4px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ao-sidepanel-tab.active {
|
||||
color: #8b5cf6;
|
||||
border-bottom-color: #8b5cf6;
|
||||
font-weight: bold;
|
||||
}
|
||||
.ao-sidepanel-tab:hover {
|
||||
color: #aaa;
|
||||
}
|
||||
.ao-sidepanel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* ===== Command Tab ===== */
|
||||
.ao-command-tab { display: flex; flex-direction: column; gap: 12px; }
|
||||
.ao-section { margin-bottom: 4px; }
|
||||
.ao-section-label {
|
||||
color: #888;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.ao-quick-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.ao-btn-quick {
|
||||
background: #2a2a4e;
|
||||
color: #8b5cf6;
|
||||
border: 1px solid #4c1d95;
|
||||
padding: 5px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ao-btn-quick:hover { background: #3a3a5e; }
|
||||
.ao-btn-quick:disabled { opacity: 0.4; }
|
||||
|
||||
.ao-param-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.ao-input {
|
||||
flex: 1;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #333;
|
||||
color: #fff;
|
||||
padding: 7px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ao-input::placeholder { color: #555; }
|
||||
.ao-btn-send {
|
||||
background: #4c1d95;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 7px 14px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ao-btn-send:hover { background: #5b21b6; }
|
||||
.ao-btn-send:disabled { opacity: 0.4; }
|
||||
|
||||
/* Approval */
|
||||
.ao-approval-card {
|
||||
background: rgba(146,64,14,0.15);
|
||||
border: 1px solid #92400e;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
}
|
||||
.ao-approval-title {
|
||||
color: #fbbf24;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.ao-approval-desc {
|
||||
color: #ddd;
|
||||
font-size: 11px;
|
||||
margin-bottom: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.ao-approval-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.ao-btn-approve {
|
||||
flex: 1;
|
||||
background: #065f46;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ao-btn-reject {
|
||||
flex: 1;
|
||||
background: #7f1d1d;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ===== Task Tab ===== */
|
||||
.ao-task-tab { display: flex; flex-direction: column; gap: 4px; }
|
||||
.ao-task-item {
|
||||
background: #1a1a2e;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ao-task-item:hover { background: #222240; }
|
||||
.ao-task-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ao-task-type { color: #ccc; font-weight: bold; flex: 1; }
|
||||
.ao-task-badge {
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.ao-task-time { color: #666; font-size: 10px; }
|
||||
.ao-task-result {
|
||||
margin-top: 6px;
|
||||
background: #0d0d1a;
|
||||
padding: 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
color: #aaa;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ===== Token Tab ===== */
|
||||
.ao-token-tab { display: flex; flex-direction: column; gap: 12px; }
|
||||
.ao-token-period {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.ao-btn-period {
|
||||
flex: 1;
|
||||
background: #1a1a2e;
|
||||
color: #888;
|
||||
border: 1px solid #333;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ao-btn-period.active {
|
||||
background: #4c1d95;
|
||||
color: #fff;
|
||||
border-color: #4c1d95;
|
||||
}
|
||||
.ao-token-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.ao-token-card {
|
||||
background: #1a1a2e;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.ao-token-label {
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.ao-token-value {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
.ao-token-bar { margin-top: 4px; }
|
||||
.ao-token-bar-label { font-size: 10px; color: #888; margin-bottom: 4px; }
|
||||
.ao-token-bar-track {
|
||||
display: flex;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
.ao-token-bar-fill.input { background: #3b82f6; }
|
||||
.ao-token-bar-fill.output { background: #8b5cf6; }
|
||||
.ao-token-bar-legend {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.ao-token-bar-legend .dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.ao-dot--on { background: #34d399; }
|
||||
.ao-dot--off { background: #f87171; }
|
||||
|
||||
/* Dashboard */
|
||||
.ao-dashboard {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
background: #2a2a4a;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Agent Column */
|
||||
.ao-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0d0d1a;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ao-col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-top: 3px solid;
|
||||
background: #1a1a2e;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ao-col-chevron {
|
||||
display: none;
|
||||
color: #666;
|
||||
font-size: 0.8rem;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.ao-col-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ao-col-name {
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ao-col-state {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
text-transform: uppercase;
|
||||
margin-left: auto;
|
||||
}
|
||||
.ao-col-state--idle { background: #333; color: #888; }
|
||||
.ao-col-state--working { background: #3730a3; color: #a5b4fc; }
|
||||
.ao-col-state--waiting { background: #92400e; color: #fbbf24; }
|
||||
.ao-col-state--reporting { background: #065f46; color: #34d399; }
|
||||
.ao-col-state--break { background: #4c1d95; color: #c4b5fd; }
|
||||
.ao-col-state--offline { background: #1f1f1f; color: #555; }
|
||||
|
||||
.ao-col-tokens {
|
||||
font-size: 0.7rem;
|
||||
color: #8b5cf6;
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
margin-left: 6px;
|
||||
cursor: help;
|
||||
font-family: 'Courier New', monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ao-col-badge {
|
||||
background: #f43f5e;
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 5px;
|
||||
border-radius: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ao-col-detail {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
color: #a78bfa;
|
||||
background: rgba(139, 92, 246, 0.05);
|
||||
border-bottom: 1px solid #2a2a4a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ao-col-approval {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
border-bottom: 1px solid #2a2a4a;
|
||||
font-size: 0.8rem;
|
||||
color: #fbbf24;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ao-col-commands {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #1a1a2e;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ao-col-input {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid #1a1a2e;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ao-col-tasks {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.ao-col-tasks-title {
|
||||
padding: 4px 12px;
|
||||
font-size: 0.7rem;
|
||||
color: #555;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.ao-col-empty {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: #444;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.ao-col-task {
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
.ao-col-task-row {
|
||||
.ao-token-bar-legend .dot.input { background: #3b82f6; }
|
||||
.ao-token-bar-legend .dot.output { background: #8b5cf6; }
|
||||
.ao-token-detail {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.ao-col-task-type {
|
||||
font-size: 0.8rem;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.ao-col-task-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ao-col-task-time {
|
||||
font-size: 0.7rem;
|
||||
color: #555;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.ao-col-task-detail {
|
||||
margin-top: 4px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.ao-col-task-detail summary {
|
||||
cursor: pointer;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
.ao-col-task-detail pre {
|
||||
color: #888;
|
||||
white-space: pre-wrap;
|
||||
margin: 4px 0 0;
|
||||
max-height: 120px;
|
||||
/* ===== Log Tab ===== */
|
||||
.ao-log-tab {
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Command Column */
|
||||
.ao-cmd-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #1a1a2e;
|
||||
flex-shrink: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ao-cmd-row {
|
||||
.ao-log-item {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
padding: 3px 0;
|
||||
border-bottom: 1px solid #1a1a2e;
|
||||
}
|
||||
.ao-log-time { color: #555; min-width: 60px; }
|
||||
.ao-log-level { min-width: 48px; font-weight: bold; }
|
||||
.ao-log-msg { color: #ccc; word-break: break-all; }
|
||||
|
||||
/* ===== Common ===== */
|
||||
.ao-empty {
|
||||
color: #555;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ao-cmd-select {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
background: #111;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ao-cmd-select:focus { border-color: #8b5cf6; outline: none; }
|
||||
|
||||
.ao-cmd-send {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Office Section */
|
||||
.ao-office-section {
|
||||
height: 280px;
|
||||
flex-shrink: 0;
|
||||
border-top: 2px solid #2a2a4a;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ao-canvas-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Shared */
|
||||
.ao-btn {
|
||||
padding: 4px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ao-btn--approve { background: #065f46; color: #34d399; }
|
||||
.ao-btn--approve:hover { background: #047857; }
|
||||
.ao-btn--reject { background: #7f1d1d; color: #f87171; }
|
||||
.ao-btn--reject:hover { background: #991b1b; }
|
||||
.ao-btn--send { background: #4c1d95; color: #c4b5fd; }
|
||||
.ao-btn--send:hover { background: #5b21b6; }
|
||||
|
||||
.ao-cmd-btn {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #ccc;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ao-cmd-btn:hover { border-color: #8b5cf6; background: rgba(139, 92, 246, 0.1); }
|
||||
|
||||
.ao-chat-input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
background: #111;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
.ao-chat-input:focus { border-color: #8b5cf6; outline: none; }
|
||||
|
||||
.ao-doc-tg-status {
|
||||
font-size: 0.7rem;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Mobile: vertical stack + accordion */
|
||||
/* ===== Mobile (< 768px) ===== */
|
||||
@media (max-width: 768px) {
|
||||
.ao-page {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.ao-topbar-right { gap: 6px; }
|
||||
.ao-topbar-select { font-size: 11px; padding: 2px 6px; }
|
||||
|
||||
.ao-dashboard {
|
||||
.ao-main {
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
overflow: visible;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.ao-col {
|
||||
flex: none;
|
||||
overflow: visible;
|
||||
.ao-canvas {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ao-col-header {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.ao-col-chevron {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.ao-col--collapsed .ao-col-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ao-col--attention {
|
||||
box-shadow: inset 3px 0 0 #fbbf24;
|
||||
}
|
||||
|
||||
.ao-col-tasks {
|
||||
max-height: 260px;
|
||||
}
|
||||
|
||||
.ao-office-section {
|
||||
height: 140px;
|
||||
order: -1;
|
||||
border-top: none;
|
||||
border-bottom: 2px solid #2a2a4a;
|
||||
}
|
||||
|
||||
.ao-title {
|
||||
font-size: 1rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.ao-header {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.ao-col-commands {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ao-cmd-btn,
|
||||
.ao-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* 명령 입력 하단 고정 */
|
||||
.ao-cmd-form {
|
||||
/* Side panel → bottom sheet */
|
||||
.ao-sidepanel {
|
||||
position: fixed;
|
||||
bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px));
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-secondary, #12122a);
|
||||
border-top: 1px solid #2a2a4a;
|
||||
z-index: 200;
|
||||
width: 100%;
|
||||
max-height: 55vh;
|
||||
border-left: none;
|
||||
border-top: 1px solid #333;
|
||||
border-radius: 16px 16px 0 0;
|
||||
animation: slideUp 0.25s ease-out;
|
||||
z-index: 100;
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.ao-sidepanel-header {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.ao-sidepanel-header::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 32px;
|
||||
height: 4px;
|
||||
background: #555;
|
||||
border-radius: 2px;
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
.ao-sidepanel-tab {
|
||||
font-size: 11px;
|
||||
padding: 6px 2px;
|
||||
}
|
||||
|
||||
.ao-sidepanel-content {
|
||||
padding: 8px 12px;
|
||||
padding-bottom: env(safe-area-inset-bottom, 16px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,110 +1,101 @@
|
||||
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
||||
import { useAgentManager } from './hooks/useAgentManager';
|
||||
import { useOfficeCanvas } from './hooks/useOfficeCanvas';
|
||||
import AgentColumn from './components/AgentColumn';
|
||||
import CommandColumn from './components/CommandColumn';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import MobileSheet from '../../components/MobileSheet';
|
||||
// src/pages/agent-office/AgentOffice.jsx
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAgentManager } from './hooks/useAgentManager.js';
|
||||
import { useOfficeCanvas } from './hooks/useOfficeCanvas.js';
|
||||
import TopBar from './components/TopBar.jsx';
|
||||
import SidePanel from './components/SidePanel.jsx';
|
||||
import './AgentOffice.css';
|
||||
|
||||
const AGENT_META = {
|
||||
stock: { name: '주식 트레이더', color: '#4488cc' },
|
||||
music: { name: '음악 프로듀서', color: '#44aa88' },
|
||||
blog: { name: '블로그 마케터', color: '#d97706' },
|
||||
realestate: { name: '청약 애널리스트', color: '#c026d3' },
|
||||
};
|
||||
export default function AgentOffice() {
|
||||
const {
|
||||
agents, pendingTasks, notifications, connected,
|
||||
refreshTrigger, clearNotifications
|
||||
} = useAgentManager();
|
||||
|
||||
const AGENT_IDS = ['stock', 'music', 'blog', 'realestate'];
|
||||
const {
|
||||
canvasRef, updateAgentState, setAgentNotification,
|
||||
setTheme, setZoom, hitTest, getZoom, wasDragging
|
||||
} = useOfficeCanvas();
|
||||
|
||||
export function Component() {
|
||||
const canvasContainerRef = useRef(null);
|
||||
const isMobile = useIsMobile();
|
||||
const [agentDetailSheet, setAgentDetailSheet] = useState(null); // agentId or null
|
||||
|
||||
const { agents, pendingTasks, connected, notifications, sendCommand, sendApproval, clearNotifications } = useAgentManager();
|
||||
|
||||
const handleAgentClick = useCallback((agentId) => {
|
||||
clearNotifications(agentId);
|
||||
if (isMobile) {
|
||||
setAgentDetailSheet(agentId);
|
||||
}
|
||||
}, [clearNotifications, isMobile]);
|
||||
|
||||
const handleCeoClick = useCallback(() => {}, []);
|
||||
|
||||
const { updateAgentState, setAgentNotification, setCeoDocBadge } = useOfficeCanvas(canvasContainerRef, handleAgentClick, handleCeoClick);
|
||||
const [selectedAgent, setSelectedAgent] = useState(null);
|
||||
const [theme, setThemeState] = useState(localStorage.getItem('agent-office-theme') || 'modern');
|
||||
const [zoom, setZoomState] = useState(2);
|
||||
|
||||
// WebSocket 상태 → 캔버스 동기화
|
||||
useEffect(() => {
|
||||
for (const [id, info] of Object.entries(agents)) {
|
||||
updateAgentState(id, info.state, info.detail);
|
||||
for (const [id, agentState] of Object.entries(agents)) {
|
||||
updateAgentState(id, agentState.state, agentState.detail);
|
||||
}
|
||||
}, [agents, updateAgentState]);
|
||||
|
||||
// 알림 → 캔버스 동기화
|
||||
useEffect(() => {
|
||||
for (const [id, count] of Object.entries(notifications)) {
|
||||
setAgentNotification(id, count);
|
||||
}
|
||||
for (const id of Object.keys(agents)) {
|
||||
if (!notifications[id]) setAgentNotification(id, 0);
|
||||
}
|
||||
}, [notifications, agents, setAgentNotification]);
|
||||
}, [notifications, setAgentNotification]);
|
||||
|
||||
useEffect(() => {
|
||||
const total = Object.values(notifications).reduce((s, n) => s + n, 0);
|
||||
setCeoDocBadge(total);
|
||||
}, [notifications, setCeoDocBadge]);
|
||||
// 캔버스 클릭 핸들러
|
||||
const handleCanvasClick = useCallback((e) => {
|
||||
if (wasDragging()) return; // 드래그 후 발생하는 클릭 무시
|
||||
const result = hitTest(e.clientX, e.clientY);
|
||||
if (result.type === 'agent') {
|
||||
setSelectedAgent(result.id);
|
||||
clearNotifications(result.id);
|
||||
setAgentNotification(result.id, 0);
|
||||
} else {
|
||||
setSelectedAgent(null);
|
||||
}
|
||||
}, [hitTest, clearNotifications, setAgentNotification, wasDragging]);
|
||||
|
||||
// 테마 변경
|
||||
const handleThemeChange = useCallback((name) => {
|
||||
setThemeState(name);
|
||||
setTheme(name);
|
||||
}, [setTheme]);
|
||||
|
||||
// 줌 변경
|
||||
const handleZoomChange = useCallback((level) => {
|
||||
setZoomState(level);
|
||||
setZoom(level);
|
||||
}, [setZoom]);
|
||||
|
||||
// 선택된 에이전트의 pending task
|
||||
const pendingTask = selectedAgent
|
||||
? pendingTasks.find(t => t.agent_id === selectedAgent)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="ao-page">
|
||||
<div className="ao-header">
|
||||
<h1 className="ao-title">Agent Office</h1>
|
||||
<div className="ao-status">
|
||||
<span className={`ao-dot ${connected ? 'ao-dot--on' : 'ao-dot--off'}`} />
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ao-root">
|
||||
<TopBar
|
||||
connected={connected}
|
||||
theme={theme}
|
||||
onThemeChange={handleThemeChange}
|
||||
zoom={zoom}
|
||||
onZoomChange={handleZoomChange}
|
||||
/>
|
||||
|
||||
<div className="ao-dashboard">
|
||||
{AGENT_IDS.map(id => (
|
||||
<AgentColumn
|
||||
key={id}
|
||||
agentId={id}
|
||||
meta={AGENT_META[id]}
|
||||
agentState={agents[id]}
|
||||
notification={notifications[id] || 0}
|
||||
onCommand={sendCommand}
|
||||
onApproval={sendApproval}
|
||||
onClearNotification={() => clearNotifications(id)}
|
||||
/>
|
||||
))}
|
||||
<CommandColumn
|
||||
agents={agents}
|
||||
onCommand={sendCommand}
|
||||
<div className="ao-main">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="ao-canvas"
|
||||
onClick={handleCanvasClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ao-office-section">
|
||||
<div className="ao-canvas-container" ref={canvasContainerRef} />
|
||||
</div>
|
||||
|
||||
{/* 모바일: 에이전트 상세 바텀시트 */}
|
||||
<MobileSheet
|
||||
open={!!agentDetailSheet}
|
||||
onClose={() => setAgentDetailSheet(null)}
|
||||
title={agentDetailSheet ? (AGENT_META[agentDetailSheet]?.name ?? agentDetailSheet) : ''}
|
||||
>
|
||||
{agentDetailSheet && (
|
||||
<AgentColumn
|
||||
agentId={agentDetailSheet}
|
||||
meta={AGENT_META[agentDetailSheet]}
|
||||
agentState={agents[agentDetailSheet]}
|
||||
notification={notifications[agentDetailSheet] || 0}
|
||||
onCommand={sendCommand}
|
||||
onApproval={sendApproval}
|
||||
onClearNotification={() => clearNotifications(agentDetailSheet)}
|
||||
{selectedAgent && (
|
||||
<SidePanel
|
||||
agentId={selectedAgent}
|
||||
agentState={agents[selectedAgent]}
|
||||
pendingTask={pendingTask}
|
||||
onClose={() => setSelectedAgent(null)}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
)}
|
||||
</MobileSheet>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Component() {
|
||||
return <AgentOffice />;
|
||||
}
|
||||
|
||||
@@ -1,46 +1,72 @@
|
||||
{
|
||||
"cols": 32,
|
||||
"rows": 20,
|
||||
"tileSize": 32,
|
||||
"cols": 20,
|
||||
"rows": 14,
|
||||
"layers": {
|
||||
"floor": [
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
|
||||
]
|
||||
},
|
||||
"floor": [
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
|
||||
],
|
||||
"furniture": [
|
||||
{"type": "desk", "x": 2, "y": 1, "label": "Stock"},
|
||||
{"type": "desk", "x": 7, "y": 1, "label": "Music"},
|
||||
{"type": "desk", "x": 12, "y": 1, "label": "Blog"},
|
||||
{"type": "desk", "x": 17, "y": 1, "label": "Realestate"},
|
||||
{"type": "table", "x": 8, "y": 6, "w": 4, "h": 2, "label": "회의 테이블"},
|
||||
{"type": "sofa", "x": 1, "y": 10, "label": "휴게실"},
|
||||
{"type": "coffee", "x": 3, "y": 10, "label": "☕"},
|
||||
{"type": "desk", "x": 14, "y": 10, "w": 5, "h": 2, "label": "CEO"}
|
||||
{"type": "desk_monitor", "col": 3, "row": 3, "agent": "stock", "monitors": 3},
|
||||
{"type": "desk_monitor", "col": 10, "row": 3, "agent": "music", "monitors": 1, "accent": "instrument"},
|
||||
{"type": "desk_monitor", "col": 17, "row": 3, "agent": "blog", "monitors": 2, "accent": "papers"},
|
||||
{"type": "desk_monitor", "col": 24, "row": 3, "agent": "realestate", "monitors": 2, "accent": "briefcase"},
|
||||
{"type": "desk_monitor", "col": 14, "row": 7, "agent": "lotto", "monitors": 1, "accent": "dice"},
|
||||
{"type": "meeting_table","col": 13, "row": 11,"width": 6, "height": 2},
|
||||
{"type": "sofa", "col": 2, "row": 17},
|
||||
{"type": "coffee_machine","col": 5, "row": 16},
|
||||
{"type": "bookshelf", "col": 27, "row": 16, "height": 3},
|
||||
{"type": "plant", "col": 1, "row": 1},
|
||||
{"type": "plant", "col": 30, "row": 1},
|
||||
{"type": "plant", "col": 1, "row": 14},
|
||||
{"type": "plant", "col": 30, "row": 14},
|
||||
{"type": "water_cooler", "col": 8, "row": 17}
|
||||
],
|
||||
"waypoints": {
|
||||
"stock_desk": {"x": 2, "y": 2},
|
||||
"music_desk": {"x": 7, "y": 2},
|
||||
"blog_desk": {"x": 12, "y": 2},
|
||||
"realestate_desk": {"x": 17, "y": 2},
|
||||
"meeting_table": {"x": 9, "y": 7},
|
||||
"break_room": {"x": 2, "y": 11},
|
||||
"ceo_desk": {"x": 16, "y": 11}
|
||||
"desk_stock": {"col": 3, "row": 4},
|
||||
"desk_music": {"col": 10, "row": 4},
|
||||
"desk_blog": {"col": 17, "row": 4},
|
||||
"desk_realestate": {"col": 24, "row": 4},
|
||||
"desk_lotto": {"col": 14, "row": 8},
|
||||
"meeting": {"col": 16, "row": 13},
|
||||
"break_room": {"col": 4, "row": 17},
|
||||
"coffee": {"col": 6, "row": 17},
|
||||
"water_cooler": {"col": 8, "row": 18}
|
||||
},
|
||||
"colors": {
|
||||
"1": "#3a3a50",
|
||||
"2": "#4a3a2a"
|
||||
"blocked": [
|
||||
[3,3],[4,3],[5,3],
|
||||
[10,3],[11,3],
|
||||
[17,3],[18,3],[19,3],
|
||||
[24,3],[25,3],[26,3],
|
||||
[14,7],[15,7],
|
||||
[13,11],[14,11],[15,11],[16,11],[17,11],[18,11],
|
||||
[13,12],[14,12],[15,12],[16,12],[17,12],[18,12],
|
||||
[2,17],[3,17],
|
||||
[5,16],[6,16],
|
||||
[27,16],[27,17],[27,18],
|
||||
[8,17]
|
||||
],
|
||||
"tileTypes": {
|
||||
"0": "wall",
|
||||
"1": "floor",
|
||||
"2": "floor_break"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +1,261 @@
|
||||
import { drawAgent, getAnimSpeed } from './SpriteSheet';
|
||||
// src/pages/agent-office/canvas/AgentSprite.js
|
||||
|
||||
import { ProceduralSprite } from './ProceduralSprite.js';
|
||||
|
||||
const WALK_SPEED = 3; // tiles per second
|
||||
const WANDER_DELAY_MIN = 3;
|
||||
const WANDER_DELAY_MAX = 8;
|
||||
const WANDER_LIMIT_MIN = 3;
|
||||
const WANDER_LIMIT_MAX = 6;
|
||||
const REST_DELAY_MIN = 2;
|
||||
const REST_DELAY_MAX = 20;
|
||||
|
||||
export class AgentSprite {
|
||||
constructor(agentId, waypoints) {
|
||||
this.agentId = agentId;
|
||||
this.waypoints = waypoints;
|
||||
this.state = 'idle';
|
||||
constructor(id, meta, col, row, pathfinder) {
|
||||
this.id = id;
|
||||
this.meta = meta;
|
||||
this.pathfinder = pathfinder;
|
||||
|
||||
// 위치 (타일 좌표, 실수)
|
||||
this.x = col;
|
||||
this.y = row;
|
||||
this.deskCol = col;
|
||||
this.deskRow = row;
|
||||
|
||||
// 상태
|
||||
this.state = 'idle'; // FSM 상태 (from backend)
|
||||
this.detail = '';
|
||||
this.notificationCount = 0;
|
||||
|
||||
const deskKey = `${agentId}_desk`;
|
||||
const desk = waypoints[deskKey] || { x: 5, y: 3 };
|
||||
this.x = desk.x;
|
||||
this.y = desk.y;
|
||||
this.targetX = desk.x;
|
||||
this.targetY = desk.y;
|
||||
this.deskPos = { x: desk.x, y: desk.y };
|
||||
// 애니메이션
|
||||
this.animState = 'idle'; // 렌더링용 상태
|
||||
this.direction = 'down';
|
||||
this.animFrame = 0;
|
||||
this.animTimer = 0;
|
||||
|
||||
this.frameIndex = 0;
|
||||
this._lastFrameTime = 0;
|
||||
this._moveSpeed = 0.05;
|
||||
// 이동
|
||||
this.path = []; // BFS 경로 [{col, row}, ...]
|
||||
this.moveProgress = 0; // 0~1 현재 타일 → 다음 타일
|
||||
this.moveFrom = { col, row };
|
||||
this.moveTo_target = null;
|
||||
|
||||
// 배회
|
||||
this._wandering = false;
|
||||
this._wanderTimer = 0;
|
||||
this._wanderCount = 0;
|
||||
this._wanderLimit = 0;
|
||||
this._restTimer = 0;
|
||||
this._isResting = false;
|
||||
this._isAtDesk = true;
|
||||
}
|
||||
|
||||
setNotification(count) {
|
||||
this.notificationCount = count;
|
||||
}
|
||||
|
||||
setState(newState, detail = '') {
|
||||
this.state = newState;
|
||||
this.detail = detail;
|
||||
this.frameIndex = 0;
|
||||
}
|
||||
|
||||
moveTo(target) {
|
||||
const wp = this.waypoints[target];
|
||||
if (wp) {
|
||||
this.targetX = wp.x;
|
||||
this.targetY = wp.y;
|
||||
}
|
||||
}
|
||||
|
||||
moveToDesk() {
|
||||
this.targetX = this.deskPos.x;
|
||||
this.targetY = this.deskPos.y;
|
||||
}
|
||||
|
||||
update(now) {
|
||||
const speed = getAnimSpeed(this.state);
|
||||
if (now - this._lastFrameTime > speed) {
|
||||
this.frameIndex++;
|
||||
this._lastFrameTime = now;
|
||||
/** 매 프레임 호출 */
|
||||
update(dt) {
|
||||
// 이동 처리
|
||||
if (this.path.length > 0) {
|
||||
this._updateMovement(dt);
|
||||
} else if (this._wandering) {
|
||||
this._updateWander(dt);
|
||||
}
|
||||
|
||||
const dx = this.targetX - this.x;
|
||||
const dy = this.targetY - this.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
// 애니메이션 프레임 업데이트
|
||||
this._updateAnimation(dt);
|
||||
}
|
||||
|
||||
if (dist > 0.1) {
|
||||
const step = Math.min(this._moveSpeed, dist);
|
||||
this.x += (dx / dist) * step;
|
||||
this.y += (dy / dist) * step;
|
||||
_updateMovement(dt) {
|
||||
this.animState = 'walk';
|
||||
this.moveProgress += WALK_SPEED * dt;
|
||||
|
||||
if (this.moveProgress >= 1) {
|
||||
// 현재 구간 완료
|
||||
const arrived = this.path.shift();
|
||||
this.x = arrived.col;
|
||||
this.y = arrived.row;
|
||||
this.moveFrom = { col: arrived.col, row: arrived.row };
|
||||
this.moveProgress = 0;
|
||||
|
||||
if (this.path.length === 0) {
|
||||
// 최종 목적지 도착
|
||||
this._onArrival();
|
||||
} else {
|
||||
// 다음 구간의 방향 설정
|
||||
this._updateDirection(this.path[0]);
|
||||
}
|
||||
} else {
|
||||
this.x = this.targetX;
|
||||
this.y = this.targetY;
|
||||
// 보간
|
||||
const next = this.path[0];
|
||||
this.x = this.moveFrom.col + (next.col - this.moveFrom.col) * this.moveProgress;
|
||||
this.y = this.moveFrom.row + (next.row - this.moveFrom.row) * this.moveProgress;
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx, renderInfo) {
|
||||
const { scale, offsetX, offsetY, tileSize } = renderInfo;
|
||||
const canvasX = offsetX + this.x * tileSize * scale + (tileSize * scale) / 2;
|
||||
const canvasY = offsetY + this.y * tileSize * scale + (tileSize * scale) / 2;
|
||||
_onArrival() {
|
||||
const atDesk = Math.abs(this.x - this.deskCol) < 0.5 && Math.abs(this.y - this.deskRow) < 0.5;
|
||||
this._isAtDesk = atDesk;
|
||||
|
||||
const isMoving = Math.abs(this.targetX - this.x) > 0.1 || Math.abs(this.targetY - this.y) > 0.1;
|
||||
const drawState = isMoving ? 'walk' : this.state;
|
||||
|
||||
drawAgent(ctx, this.agentId, canvasX, canvasY, drawState, this.frameIndex, scale * 1.5);
|
||||
if (this.state === 'working' || this.state === 'reporting') {
|
||||
this.animState = 'type';
|
||||
this.direction = 'up'; // 모니터를 바라봄
|
||||
} else if (this.state === 'waiting') {
|
||||
this.animState = 'wait';
|
||||
} else if (this.state === 'break') {
|
||||
this.animState = 'break_anim';
|
||||
} else {
|
||||
// idle 도착 — 배회 계속 또는 자리에서 쉬기
|
||||
if (this._wandering && this._wanderCount < this._wanderLimit) {
|
||||
// 다음 배회 타이머 설정
|
||||
this._wanderTimer = WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN);
|
||||
} else if (this._wandering) {
|
||||
// 배회 끝, 휴식
|
||||
this._wandering = false;
|
||||
this._isResting = true;
|
||||
this._restTimer = REST_DELAY_MIN + Math.random() * (REST_DELAY_MAX - REST_DELAY_MIN);
|
||||
}
|
||||
this.animState = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
hitTest(canvasX, canvasY, renderInfo) {
|
||||
const { scale, offsetX, offsetY, tileSize } = renderInfo;
|
||||
const cx = offsetX + this.x * tileSize * scale + (tileSize * scale) / 2;
|
||||
const cy = offsetY + this.y * tileSize * scale + (tileSize * scale) / 2;
|
||||
const hitW = 20 * scale;
|
||||
const hitH = 30 * scale;
|
||||
_updateWander(dt) {
|
||||
if (this._isResting) {
|
||||
this._restTimer -= dt;
|
||||
if (this._restTimer <= 0) {
|
||||
this._isResting = false;
|
||||
this._startWandering();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
return canvasX >= cx - hitW && canvasX <= cx + hitW &&
|
||||
canvasY >= cy - hitH && canvasY <= cy + hitH;
|
||||
this._wanderTimer -= dt;
|
||||
if (this._wanderTimer <= 0) {
|
||||
// 랜덤 인접 타일로 이동
|
||||
const target = this.pathfinder.getRandomNearbyFloor(
|
||||
Math.round(this.x), Math.round(this.y), 4
|
||||
);
|
||||
if (target) {
|
||||
const path = this.pathfinder.findPath(
|
||||
Math.round(this.x), Math.round(this.y), target.col, target.row
|
||||
);
|
||||
if (path.length > 0 && path.length <= 6) {
|
||||
this.path = path;
|
||||
this.moveFrom = { col: Math.round(this.x), row: Math.round(this.y) };
|
||||
this.moveProgress = 0;
|
||||
this._updateDirection(path[0]);
|
||||
this._wanderCount++;
|
||||
}
|
||||
}
|
||||
// 실패해도 타이머 리셋
|
||||
this._wanderTimer = WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN);
|
||||
}
|
||||
}
|
||||
|
||||
_updateDirection(nextTile) {
|
||||
const dx = nextTile.col - Math.round(this.x);
|
||||
const dy = nextTile.row - Math.round(this.y);
|
||||
if (Math.abs(dx) > Math.abs(dy)) {
|
||||
this.direction = dx > 0 ? 'right' : 'left';
|
||||
} else {
|
||||
this.direction = dy > 0 ? 'down' : 'up';
|
||||
}
|
||||
}
|
||||
|
||||
_updateAnimation(dt) {
|
||||
const config = ProceduralSprite.getAnimConfig(
|
||||
this.animState === 'walk' ? 'walk' : this.state
|
||||
);
|
||||
this.animTimer += dt;
|
||||
if (this.animTimer >= config.speed) {
|
||||
this.animTimer = 0;
|
||||
this.animFrame = (this.animFrame + 1) % config.frames;
|
||||
}
|
||||
}
|
||||
|
||||
/** 백엔드 상태 변경 시 호출 */
|
||||
onStateChange(newState, detail, waypoints) {
|
||||
const prevState = this.state;
|
||||
this.state = newState;
|
||||
this.detail = detail || '';
|
||||
|
||||
// 배회 중단
|
||||
this._wandering = false;
|
||||
this._isResting = false;
|
||||
|
||||
switch (newState) {
|
||||
case 'working':
|
||||
case 'reporting':
|
||||
case 'waiting':
|
||||
// 자리에 없으면 자리로 이동
|
||||
if (!this._isAtDesk) {
|
||||
this._moveToDesk();
|
||||
} else {
|
||||
this.animState = newState === 'waiting' ? 'wait' : 'type';
|
||||
this.direction = 'up';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'break': {
|
||||
// 휴게실로 이동
|
||||
const breakWp = waypoints.break_room || waypoints.coffee;
|
||||
if (breakWp) {
|
||||
this._navigateTo(breakWp.col, breakWp.row);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'idle':
|
||||
if (prevState === 'break') {
|
||||
// 휴게실에서 자리로 복귀
|
||||
this._moveToDesk();
|
||||
}
|
||||
// 복귀 후 배회 시작 (도착 콜백에서 처리)
|
||||
this._startWanderingAfterDelay(3);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_moveToDesk() {
|
||||
this._navigateTo(this.deskCol, this.deskRow);
|
||||
}
|
||||
|
||||
_navigateTo(goalCol, goalRow) {
|
||||
const startCol = Math.round(this.x);
|
||||
const startRow = Math.round(this.y);
|
||||
const path = this.pathfinder.findPath(startCol, startRow, goalCol, goalRow);
|
||||
if (path.length > 0) {
|
||||
this.path = path;
|
||||
this.moveFrom = { col: startCol, row: startRow };
|
||||
this.moveProgress = 0;
|
||||
this._updateDirection(path[0]);
|
||||
}
|
||||
}
|
||||
|
||||
_startWanderingAfterDelay(delay) {
|
||||
this._wandering = true;
|
||||
this._wanderCount = 0;
|
||||
this._wanderLimit = WANDER_LIMIT_MIN + Math.floor(Math.random() * (WANDER_LIMIT_MAX - WANDER_LIMIT_MIN));
|
||||
this._wanderTimer = delay;
|
||||
this._isResting = false;
|
||||
}
|
||||
|
||||
_startWandering() {
|
||||
this._startWanderingAfterDelay(WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN));
|
||||
}
|
||||
|
||||
isAtDesk() {
|
||||
return this._isAtDesk;
|
||||
}
|
||||
|
||||
/** 렌더링 */
|
||||
draw(ctx, zoom, panX, panY, tileSize) {
|
||||
const ts = tileSize * zoom;
|
||||
const screenX = this.x * ts + panX + ts / 2;
|
||||
const screenY = this.y * ts + panY + ts;
|
||||
const spriteScale = zoom * 1.5; // 캐릭터 약간 크게
|
||||
|
||||
ProceduralSprite.draw(
|
||||
ctx, this.id,
|
||||
this.animState === 'walk' ? 'walk' : this.state,
|
||||
this.direction, this.animFrame,
|
||||
screenX, screenY, spriteScale
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
209
src/pages/agent-office/canvas/FurnitureRenderer.js
Normal file
209
src/pages/agent-office/canvas/FurnitureRenderer.js
Normal file
@@ -0,0 +1,209 @@
|
||||
// src/pages/agent-office/canvas/FurnitureRenderer.js
|
||||
|
||||
/**
|
||||
* 가구 프로시저럴 렌더러 — 테마 팔레트 기반
|
||||
* 각 가구 타입별 draw 함수, Y-sort를 위한 zY 반환
|
||||
*/
|
||||
export class FurnitureRenderer {
|
||||
constructor(furnitureList, tileSize) {
|
||||
this.furnitureList = furnitureList;
|
||||
this.tileSize = tileSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 가구를 Y-sort 순서로 반환 (에이전트와 함께 정렬하기 위함)
|
||||
* @returns {Array<{type, col, row, zY, draw: Function}>}
|
||||
*/
|
||||
getRenderables(theme, scale, offsetX, offsetY) {
|
||||
const ts = this.tileSize * scale;
|
||||
return this.furnitureList.map(f => ({
|
||||
...f,
|
||||
zY: f.row,
|
||||
draw: (ctx) => this._drawFurniture(ctx, f, theme, ts, offsetX, offsetY)
|
||||
}));
|
||||
}
|
||||
|
||||
_drawFurniture(ctx, f, theme, ts, ox, oy) {
|
||||
const x = f.col * ts + ox;
|
||||
const y = f.row * ts + oy;
|
||||
|
||||
switch (f.type) {
|
||||
case 'desk_monitor': this._drawDesk(ctx, f, theme, ts, x, y); break;
|
||||
case 'meeting_table': this._drawMeetingTable(ctx, f, theme, ts, x, y); break;
|
||||
case 'sofa': this._drawSofa(ctx, theme, ts, x, y); break;
|
||||
case 'coffee_machine':this._drawCoffeeMachine(ctx, theme, ts, x, y); break;
|
||||
case 'bookshelf': this._drawBookshelf(ctx, f, theme, ts, x, y); break;
|
||||
case 'plant': this._drawPlant(ctx, theme, ts, x, y); break;
|
||||
case 'water_cooler': this._drawWaterCooler(ctx, theme, ts, x, y); break;
|
||||
}
|
||||
}
|
||||
|
||||
_drawDesk(ctx, f, theme, ts, x, y) {
|
||||
// 책상 상판
|
||||
const dw = ts * 2;
|
||||
const dh = ts * 0.6;
|
||||
ctx.fillStyle = theme.furniture.desk;
|
||||
ctx.fillRect(x, y + ts * 0.2, dw, dh);
|
||||
// 책상 다리
|
||||
ctx.fillStyle = theme.wall.border;
|
||||
ctx.fillRect(x + ts * 0.1, y + dh + ts * 0.2, ts * 0.15, ts * 0.3);
|
||||
ctx.fillRect(x + dw - ts * 0.25, y + dh + ts * 0.2, ts * 0.15, ts * 0.3);
|
||||
|
||||
// 모니터들
|
||||
const monCount = f.monitors || 1;
|
||||
const monW = ts * 0.5;
|
||||
const monH = ts * 0.4;
|
||||
const totalW = monCount * monW + (monCount - 1) * ts * 0.1;
|
||||
let monX = x + (dw - totalW) / 2;
|
||||
|
||||
for (let i = 0; i < monCount; i++) {
|
||||
// 모니터 프레임
|
||||
ctx.fillStyle = theme.furniture.monitor;
|
||||
ctx.fillRect(monX, y - monH + ts * 0.2, monW, monH);
|
||||
// 화면
|
||||
ctx.fillStyle = theme.furniture.monitorScreen;
|
||||
ctx.fillRect(monX + ts * 0.05, y - monH + ts * 0.25, monW - ts * 0.1, monH - ts * 0.1);
|
||||
// 모니터 받침대
|
||||
ctx.fillStyle = theme.furniture.monitor;
|
||||
ctx.fillRect(monX + monW * 0.35, y + ts * 0.2 - ts * 0.05, monW * 0.3, ts * 0.08);
|
||||
monX += monW + ts * 0.1;
|
||||
}
|
||||
|
||||
// 의자 (책상 아래)
|
||||
ctx.fillStyle = theme.furniture.chair;
|
||||
ctx.fillRect(x + dw * 0.35, y + ts, dw * 0.3, ts * 0.5);
|
||||
ctx.fillRect(x + dw * 0.3, y + ts * 0.8, dw * 0.4, ts * 0.25);
|
||||
|
||||
// 에이전트별 악센트 소품
|
||||
if (f.accent === 'instrument') {
|
||||
// 음표 모양
|
||||
ctx.fillStyle = theme.ui.accent;
|
||||
ctx.fillRect(x + dw + ts * 0.2, y + ts * 0.3, ts * 0.1, ts * 0.5);
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + dw + ts * 0.2, y + ts * 0.8, ts * 0.15, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (f.accent === 'papers') {
|
||||
// 서류 더미
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(x + dw + ts * 0.1, y + ts * 0.3, ts * 0.35, ts * 0.45);
|
||||
ctx.fillStyle = theme.text.label;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
ctx.fillRect(x + dw + ts * 0.15, y + ts * 0.38 + i * ts * 0.1, ts * 0.25, ts * 0.02);
|
||||
}
|
||||
} else if (f.accent === 'briefcase') {
|
||||
ctx.fillStyle = '#8B4513';
|
||||
ctx.fillRect(x + dw + ts * 0.1, y + ts * 0.5, ts * 0.4, ts * 0.3);
|
||||
ctx.fillStyle = '#D4A06A';
|
||||
ctx.fillRect(x + dw + ts * 0.2, y + ts * 0.45, ts * 0.2, ts * 0.08);
|
||||
} else if (f.accent === 'dice') {
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.fillRect(x + dw + ts * 0.15, y + ts * 0.4, ts * 0.3, ts * 0.3);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + dw + ts * 0.3, y + ts * 0.55, ts * 0.05, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
_drawMeetingTable(ctx, f, theme, ts, x, y) {
|
||||
const w = (f.width || 4) * ts;
|
||||
const h = (f.height || 2) * ts;
|
||||
// 테이블 상판
|
||||
ctx.fillStyle = theme.furniture.table;
|
||||
ctx.fillRect(x + ts * 0.1, y + ts * 0.1, w - ts * 0.2, h - ts * 0.2);
|
||||
// 테이블 그림자
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.15)';
|
||||
ctx.fillRect(x + ts * 0.15, y + h - ts * 0.1, w - ts * 0.25, ts * 0.1);
|
||||
// 의자들 (상하 4개씩)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const cx = x + ts * 0.5 + i * (w - ts) / 3;
|
||||
ctx.fillStyle = theme.furniture.chair;
|
||||
ctx.fillRect(cx, y - ts * 0.3, ts * 0.4, ts * 0.35);
|
||||
ctx.fillRect(cx, y + h - ts * 0.05, ts * 0.4, ts * 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
_drawSofa(ctx, theme, ts, x, y) {
|
||||
ctx.fillStyle = theme.furniture.sofa;
|
||||
ctx.fillRect(x, y, ts * 2, ts * 0.8);
|
||||
// 등받이
|
||||
ctx.fillStyle = theme.furniture.sofa;
|
||||
ctx.fillRect(x, y - ts * 0.3, ts * 2, ts * 0.35);
|
||||
// 쿠션 구분선
|
||||
ctx.strokeStyle = theme.wall.border;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + ts, y);
|
||||
ctx.lineTo(x + ts, y + ts * 0.8);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
_drawCoffeeMachine(ctx, theme, ts, x, y) {
|
||||
ctx.fillStyle = theme.furniture.coffee;
|
||||
ctx.fillRect(x + ts * 0.15, y, ts * 0.7, ts * 0.8);
|
||||
// 디스펜서
|
||||
ctx.fillStyle = theme.furniture.monitor;
|
||||
ctx.fillRect(x + ts * 0.25, y + ts * 0.15, ts * 0.5, ts * 0.3);
|
||||
// 커피 잔
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(x + ts * 0.3, y + ts * 0.55, ts * 0.2, ts * 0.15);
|
||||
// 스팀
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + ts * 0.4, y + ts * 0.5);
|
||||
ctx.quadraticCurveTo(x + ts * 0.45, y + ts * 0.35, x + ts * 0.4, y + ts * 0.2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
_drawBookshelf(ctx, f, theme, ts, x, y) {
|
||||
const h = (f.height || 3) * ts;
|
||||
ctx.fillStyle = theme.furniture.shelf;
|
||||
ctx.fillRect(x, y, ts * 0.9, h);
|
||||
// 선반 및 책
|
||||
const bookColors = ['#aa4444', '#4444aa', '#44aa44', '#aaaa44', '#aa44aa', '#44aaaa'];
|
||||
const shelfCount = f.height || 3;
|
||||
for (let i = 0; i < shelfCount; i++) {
|
||||
const sy = y + i * ts + ts * 0.1;
|
||||
// 선반 판
|
||||
ctx.fillStyle = theme.furniture.shelf;
|
||||
ctx.fillRect(x, sy + ts * 0.7, ts * 0.9, ts * 0.05);
|
||||
// 책들
|
||||
for (let b = 0; b < 4; b++) {
|
||||
ctx.fillStyle = bookColors[(i * 4 + b) % bookColors.length];
|
||||
ctx.fillRect(x + ts * 0.05 + b * ts * 0.2, sy + ts * 0.1, ts * 0.15, ts * 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_drawPlant(ctx, theme, ts, x, y) {
|
||||
// 화분
|
||||
ctx.fillStyle = theme.decor.pot;
|
||||
ctx.fillRect(x + ts * 0.25, y + ts * 0.6, ts * 0.5, ts * 0.35);
|
||||
ctx.fillRect(x + ts * 0.2, y + ts * 0.55, ts * 0.6, ts * 0.1);
|
||||
// 잎
|
||||
ctx.fillStyle = theme.decor.plant;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x + ts * 0.5, y + ts * 0.35, ts * 0.3, ts * 0.25, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x + ts * 0.35, y + ts * 0.25, ts * 0.15, ts * 0.2, -0.3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x + ts * 0.65, y + ts * 0.25, ts * 0.15, ts * 0.2, 0.3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
_drawWaterCooler(ctx, theme, ts, x, y) {
|
||||
// 본체
|
||||
ctx.fillStyle = theme.furniture.shelf;
|
||||
ctx.fillRect(x + ts * 0.2, y + ts * 0.3, ts * 0.6, ts * 0.6);
|
||||
// 물통
|
||||
ctx.fillStyle = 'rgba(100,180,255,0.5)';
|
||||
ctx.fillRect(x + ts * 0.3, y, ts * 0.4, ts * 0.35);
|
||||
ctx.fillStyle = 'rgba(100,180,255,0.3)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + ts * 0.5, y, ts * 0.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,229 @@
|
||||
import { drawTileMap } from './TileMap';
|
||||
import { AgentSprite } from './AgentSprite';
|
||||
import { getCharLabel, drawNotificationBadge } from './SpriteSheet';
|
||||
// src/pages/agent-office/canvas/OfficeRenderer.js
|
||||
|
||||
const STATUS_ICONS = {
|
||||
idle: null,
|
||||
working: null,
|
||||
waiting: '❗',
|
||||
reporting: '📋',
|
||||
break: '☕',
|
||||
import mapData from '../assets/office-map.json';
|
||||
import { TileMap } from './TileMap.js';
|
||||
import { FurnitureRenderer } from './FurnitureRenderer.js';
|
||||
import { Pathfinder } from './Pathfinder.js';
|
||||
import { AgentSprite } from './AgentSprite.js';
|
||||
import { OverlayRenderer } from './OverlayRenderer.js';
|
||||
import { getTheme } from './themes.js';
|
||||
|
||||
const AGENT_META = {
|
||||
stock: { displayName: '주식 트레이더', emoji: '📈', color: '#4488cc' },
|
||||
music: { displayName: '음악 프로듀서', emoji: '🎵', color: '#44aa88' },
|
||||
blog: { displayName: '블로그 마케터', emoji: '✍️', color: '#d97706' },
|
||||
realestate: { displayName: '청약 애널리스트', emoji: '🏢', color: '#c026d3' },
|
||||
lotto: { displayName: '로또 큐레이터', emoji: '🎱', color: '#ef4444' }
|
||||
};
|
||||
|
||||
export class OfficeRenderer {
|
||||
constructor(canvas, mapData) {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
this.mapData = mapData;
|
||||
this.renderInfo = null;
|
||||
this.agents = {};
|
||||
this._animId = null;
|
||||
this._onClick = null;
|
||||
this._onCeoClick = null;
|
||||
this._ceoDocBadge = 0;
|
||||
|
||||
const agentIds = ['stock', 'music', 'blog', 'realestate'];
|
||||
for (const id of agentIds) {
|
||||
this.agents[id] = new AgentSprite(id, mapData.waypoints);
|
||||
// 맵 & 렌더러
|
||||
this.tileMap = new TileMap(mapData);
|
||||
this.furnitureRenderer = new FurnitureRenderer(mapData.furniture, mapData.tileSize);
|
||||
this.pathfinder = new Pathfinder(mapData.cols, mapData.rows);
|
||||
this.overlayRenderer = new OverlayRenderer();
|
||||
|
||||
// blocked 타일 설정
|
||||
this.pathfinder.setWalls(mapData.floor);
|
||||
this.pathfinder.setBlocked(mapData.blocked);
|
||||
|
||||
// 테마 & 뷰포트
|
||||
this.theme = getTheme(
|
||||
(typeof localStorage !== 'undefined' && localStorage.getItem('agent-office-theme')) || 'modern'
|
||||
);
|
||||
this.zoom = 2;
|
||||
this.panX = 0;
|
||||
this.panY = 0;
|
||||
this._isPanning = false;
|
||||
this._panStart = { x: 0, y: 0 };
|
||||
|
||||
// 에이전트
|
||||
this.agents = new Map();
|
||||
this._initAgents();
|
||||
|
||||
// 게임 루프
|
||||
this._lastTime = 0;
|
||||
this._animId = null;
|
||||
this._lastDpr = window.devicePixelRatio || 1;
|
||||
|
||||
// 드래그 감지
|
||||
this._mouseDownPos = { x: 0, y: 0 };
|
||||
this._wasDragging = false;
|
||||
|
||||
// 이벤트
|
||||
this._setupInputHandlers();
|
||||
}
|
||||
|
||||
_initAgents() {
|
||||
for (const [id, meta] of Object.entries(AGENT_META)) {
|
||||
const waypoint = mapData.waypoints[`desk_${id}`];
|
||||
if (!waypoint) continue;
|
||||
const sprite = new AgentSprite(id, meta, waypoint.col, waypoint.row, this.pathfinder);
|
||||
sprite.deskCol = waypoint.col;
|
||||
sprite.deskRow = waypoint.row;
|
||||
this.agents.set(id, sprite);
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
this._loop = this._loop.bind(this);
|
||||
this._animId = requestAnimationFrame(this._loop);
|
||||
/** 줌/팬/클릭 이벤트 핸들러 */
|
||||
_setupInputHandlers() {
|
||||
// 마우스 휠 줌
|
||||
this.canvas.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
const oldZoom = this.zoom;
|
||||
if (e.deltaY < 0) {
|
||||
this.zoom = Math.min(this.zoom + 0.5, 4);
|
||||
} else {
|
||||
this.zoom = Math.max(this.zoom - 0.5, 1);
|
||||
}
|
||||
// 마우스 위치 기준 줌
|
||||
if (this.zoom !== oldZoom) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
const ratio = this.zoom / oldZoom;
|
||||
this.panX = mx - (mx - this.panX) * ratio;
|
||||
this.panY = my - (my - this.panY) * ratio;
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
// 마우스 드래그 패닝
|
||||
this.canvas.addEventListener('mousedown', (e) => {
|
||||
if (e.button === 0) {
|
||||
this._isPanning = true;
|
||||
this._panStart = { x: e.clientX - this.panX, y: e.clientY - this.panY };
|
||||
this._mouseDownPos = { x: e.clientX, y: e.clientY };
|
||||
this._wasDragging = false;
|
||||
}
|
||||
});
|
||||
this._onMouseMove = (e) => {
|
||||
if (this._isPanning) {
|
||||
this.panX = e.clientX - this._panStart.x;
|
||||
this.panY = e.clientY - this._panStart.y;
|
||||
const dx = e.clientX - this._mouseDownPos.x;
|
||||
const dy = e.clientY - this._mouseDownPos.y;
|
||||
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) this._wasDragging = true;
|
||||
}
|
||||
};
|
||||
this._onMouseUp = () => {
|
||||
this._isPanning = false;
|
||||
};
|
||||
window.addEventListener('mousemove', this._onMouseMove);
|
||||
window.addEventListener('mouseup', this._onMouseUp);
|
||||
|
||||
// 터치 (모바일)
|
||||
let lastTouchDist = 0;
|
||||
let lastTouchCenter = { x: 0, y: 0 };
|
||||
this.canvas.addEventListener('touchstart', (e) => {
|
||||
if (e.touches.length === 1) {
|
||||
this._isPanning = true;
|
||||
this._panStart = { x: e.touches[0].clientX - this.panX, y: e.touches[0].clientY - this.panY };
|
||||
} else if (e.touches.length === 2) {
|
||||
this._isPanning = false;
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
lastTouchDist = Math.hypot(dx, dy);
|
||||
lastTouchCenter = {
|
||||
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
|
||||
y: (e.touches[0].clientY + e.touches[1].clientY) / 2
|
||||
};
|
||||
}
|
||||
}, { passive: false });
|
||||
this.canvas.addEventListener('touchmove', (e) => {
|
||||
e.preventDefault();
|
||||
if (e.touches.length === 1 && this._isPanning) {
|
||||
this.panX = e.touches[0].clientX - this._panStart.x;
|
||||
this.panY = e.touches[0].clientY - this._panStart.y;
|
||||
} else if (e.touches.length === 2) {
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
const oldZoom = this.zoom;
|
||||
this.zoom = Math.min(4, Math.max(1, this.zoom * (dist / lastTouchDist)));
|
||||
lastTouchDist = dist;
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const cx = lastTouchCenter.x - rect.left;
|
||||
const cy = lastTouchCenter.y - rect.top;
|
||||
const ratio = this.zoom / oldZoom;
|
||||
this.panX = cx - (cx - this.panX) * ratio;
|
||||
this.panY = cy - (cy - this.panY) * ratio;
|
||||
}
|
||||
}, { passive: false });
|
||||
this.canvas.addEventListener('touchend', () => {
|
||||
this._isPanning = false;
|
||||
});
|
||||
}
|
||||
|
||||
/** 클릭 히트 테스트 — AgentOffice에서 호출 */
|
||||
hitTest(clientX, clientY) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const screenX = clientX - rect.left;
|
||||
const screenY = clientY - rect.top;
|
||||
const { col, row } = this.tileMap.screenToTile(screenX, screenY, this.zoom, this.panX, this.panY);
|
||||
|
||||
// 에이전트 히트 (역순, 최상위 우선)
|
||||
for (const [id, sprite] of [...this.agents.entries()].reverse()) {
|
||||
const dx = Math.abs(sprite.x - col);
|
||||
const dy = Math.abs(sprite.y - row);
|
||||
if (dx < 1.2 && dy < 1.5) {
|
||||
return { type: 'agent', id };
|
||||
}
|
||||
}
|
||||
return { type: 'empty' };
|
||||
}
|
||||
|
||||
/** 에이전트 상태 업데이트 (WebSocket에서 호출) */
|
||||
updateAgentState(agentId, state, detail) {
|
||||
const sprite = this.agents.get(agentId);
|
||||
if (!sprite) return;
|
||||
sprite.onStateChange(state, detail, mapData.waypoints);
|
||||
}
|
||||
|
||||
/** 에이전트 알림 배지 설정 */
|
||||
setAgentNotification(agentId, count) {
|
||||
const sprite = this.agents.get(agentId);
|
||||
if (sprite) sprite.notificationCount = count;
|
||||
}
|
||||
|
||||
/** 테마 변경 */
|
||||
setTheme(themeName) {
|
||||
this.theme = getTheme(themeName);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('agent-office-theme', themeName);
|
||||
}
|
||||
}
|
||||
|
||||
/** 줌 레벨 설정 */
|
||||
setZoom(level) {
|
||||
const cx = this.canvas.width / 2;
|
||||
const cy = this.canvas.height / 2;
|
||||
const oldZoom = this.zoom;
|
||||
this.zoom = Math.min(4, Math.max(1, level));
|
||||
const ratio = this.zoom / oldZoom;
|
||||
this.panX = cx - (cx - this.panX) * ratio;
|
||||
this.panY = cy - (cy - this.panY) * ratio;
|
||||
}
|
||||
|
||||
/** 카메라를 맵 중앙에 맞추기 */
|
||||
centerCamera() {
|
||||
const mapW = mapData.cols * mapData.tileSize * this.zoom;
|
||||
const mapH = mapData.rows * mapData.tileSize * this.zoom;
|
||||
this.panX = (this.canvas.clientWidth - mapW) / 2;
|
||||
this.panY = (this.canvas.clientHeight - mapH) / 2;
|
||||
}
|
||||
|
||||
/** 게임 루프 시작 */
|
||||
start() {
|
||||
this.centerCamera();
|
||||
this._lastTime = performance.now();
|
||||
this._loop(this._lastTime);
|
||||
}
|
||||
|
||||
/** 게임 루프 중지 */
|
||||
stop() {
|
||||
if (this._animId) {
|
||||
cancelAnimationFrame(this._animId);
|
||||
@@ -40,172 +231,86 @@ export class OfficeRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
resize(width, height) {
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
}
|
||||
|
||||
setOnClick(handler) {
|
||||
this._onClick = handler;
|
||||
}
|
||||
|
||||
handleClick(canvasX, canvasY) {
|
||||
if (!this.renderInfo) return null;
|
||||
|
||||
for (const [id, sprite] of Object.entries(this.agents)) {
|
||||
if (sprite.hitTest(canvasX, canvasY, this.renderInfo)) {
|
||||
if (this._onClick) this._onClick(id);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
// CEO desk click detection
|
||||
const ceo = this.mapData.waypoints.ceo_desk;
|
||||
if (ceo) {
|
||||
const { scale, offsetX, offsetY, tileSize } = this.renderInfo;
|
||||
const cx = offsetX + ceo.x * tileSize * scale;
|
||||
const cy = offsetY + ceo.y * tileSize * scale;
|
||||
const hitW = 5 * tileSize * scale;
|
||||
const hitH = 2 * tileSize * scale;
|
||||
if (canvasX >= cx - tileSize * scale && canvasY >= cy - tileSize * scale &&
|
||||
canvasX <= cx + hitW && canvasY <= cy + hitH) {
|
||||
if (this._onCeoClick) this._onCeoClick();
|
||||
return 'ceo_desk';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
updateAgentState(agentId, state, detail) {
|
||||
const sprite = this.agents[agentId];
|
||||
if (sprite) {
|
||||
sprite.setState(state, detail);
|
||||
if (state === 'idle' || state === 'working' || state === 'waiting') {
|
||||
sprite.moveToDesk();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
moveAgent(agentId, target) {
|
||||
const sprite = this.agents[agentId];
|
||||
if (sprite) {
|
||||
sprite.moveTo(target);
|
||||
}
|
||||
}
|
||||
|
||||
setOnCeoClick(handler) {
|
||||
this._onCeoClick = handler;
|
||||
}
|
||||
|
||||
setCeoDocBadge(count) {
|
||||
this._ceoDocBadge = count;
|
||||
}
|
||||
|
||||
setAgentNotification(agentId, count) {
|
||||
const sprite = this.agents[agentId];
|
||||
if (sprite) sprite.setNotification(count);
|
||||
}
|
||||
|
||||
_loop(timestamp) {
|
||||
const { ctx, canvas, mapData } = this;
|
||||
const dt = Math.min((timestamp - this._lastTime) / 1000, 0.1); // cap dt to avoid spiral
|
||||
this._lastTime = timestamp;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = '#1a1a2e';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
this._update(dt);
|
||||
this._render();
|
||||
|
||||
this.renderInfo = drawTileMap(ctx, mapData, canvas.width, canvas.height);
|
||||
|
||||
const now = Date.now();
|
||||
for (const sprite of Object.values(this.agents)) {
|
||||
sprite.update(now);
|
||||
sprite.draw(ctx, this.renderInfo);
|
||||
}
|
||||
|
||||
for (const [id, sprite] of Object.entries(this.agents)) {
|
||||
this._drawOverlay(ctx, sprite, id);
|
||||
}
|
||||
|
||||
// CEO desk document icon
|
||||
this._drawCeoDoc(ctx);
|
||||
|
||||
this._animId = requestAnimationFrame(this._loop);
|
||||
this._animId = requestAnimationFrame((t) => this._loop(t));
|
||||
}
|
||||
|
||||
_drawOverlay(ctx, sprite, agentId) {
|
||||
if (!this.renderInfo) return;
|
||||
const { scale, offsetX, offsetY, tileSize } = this.renderInfo;
|
||||
const cx = offsetX + sprite.x * tileSize * scale + (tileSize * scale) / 2;
|
||||
const cy = offsetY + sprite.y * tileSize * scale - 10 * scale;
|
||||
|
||||
const icon = STATUS_ICONS[sprite.state];
|
||||
if (icon) {
|
||||
ctx.font = `${14 * scale}px serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(icon, cx, cy - 15 * scale);
|
||||
}
|
||||
|
||||
// Notification badge (separate from status icon)
|
||||
if (sprite.notificationCount > 0) {
|
||||
drawNotificationBadge(ctx, cx, cy - 15 * scale, sprite.notificationCount, scale * 1.5);
|
||||
}
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.7)';
|
||||
ctx.font = `${8 * scale}px monospace`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(getCharLabel(agentId), cx, cy + 30 * scale + 30);
|
||||
|
||||
if (sprite.detail && (sprite.state === 'working' || sprite.state === 'waiting')) {
|
||||
const bubbleY = cy - 25 * scale;
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||
const textW = ctx.measureText(sprite.detail).width;
|
||||
ctx.fillRect(cx - textW / 2 - 6, bubbleY - 10, textW + 12, 16);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = `${7 * scale}px monospace`;
|
||||
ctx.fillText(sprite.detail, cx, bubbleY);
|
||||
_update(dt) {
|
||||
for (const sprite of this.agents.values()) {
|
||||
sprite.update(dt);
|
||||
}
|
||||
}
|
||||
|
||||
_drawCeoDoc(ctx) {
|
||||
if (!this.renderInfo) return;
|
||||
const ceo = this.mapData.waypoints.ceo_desk;
|
||||
if (!ceo) return;
|
||||
_render() {
|
||||
const ctx = this.ctx;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
const { scale, offsetX, offsetY, tileSize } = this.renderInfo;
|
||||
const dx = offsetX + (ceo.x - 1) * tileSize * scale;
|
||||
const dy = offsetY + (ceo.y - 1) * tileSize * scale;
|
||||
const docW = 12 * scale;
|
||||
const docH = 16 * scale;
|
||||
|
||||
// Paper
|
||||
ctx.fillStyle = '#e8e0d0';
|
||||
ctx.fillRect(dx, dy, docW, docH);
|
||||
// Lines on paper
|
||||
ctx.fillStyle = '#bbb';
|
||||
for (let i = 0; i < 4; i++) {
|
||||
ctx.fillRect(dx + 2 * scale, dy + (3 + i * 3) * scale, 8 * scale, 1);
|
||||
// 캔버스 크기 조정
|
||||
const displayW = this.canvas.clientWidth;
|
||||
const displayH = this.canvas.clientHeight;
|
||||
if (this.canvas.width !== displayW * dpr || this.canvas.height !== displayH * dpr || this._lastDpr !== dpr) {
|
||||
this.canvas.width = displayW * dpr;
|
||||
this.canvas.height = displayH * dpr;
|
||||
this._lastDpr = dpr;
|
||||
}
|
||||
// Folded corner
|
||||
ctx.fillStyle = '#d0c8b8';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(dx + docW - 3 * scale, dy);
|
||||
ctx.lineTo(dx + docW, dy + 3 * scale);
|
||||
ctx.lineTo(dx + docW - 3 * scale, dy + 3 * scale);
|
||||
ctx.fill();
|
||||
// setTransform 방식으로 누적 없이 항상 올바른 변환 적용
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Badge on document
|
||||
if (this._ceoDocBadge > 0) {
|
||||
const bx = dx + docW;
|
||||
const by = dy;
|
||||
const r = 4 * scale;
|
||||
ctx.beginPath();
|
||||
ctx.arc(bx, by, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#f43f5e';
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = `bold ${5 * scale}px monospace`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(this._ceoDocBadge > 9 ? '9+' : String(this._ceoDocBadge), bx, by);
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.clearRect(0, 0, displayW, displayH);
|
||||
|
||||
// 배경
|
||||
ctx.fillStyle = this.theme.wall.color;
|
||||
ctx.fillRect(0, 0, displayW, displayH);
|
||||
|
||||
// 1. 타일맵 (바닥 + 벽)
|
||||
this.tileMap.render(ctx, this.theme, this.zoom, this.panX, this.panY);
|
||||
|
||||
// 2. Y-sorted: 가구 + 에이전트
|
||||
const renderables = [];
|
||||
|
||||
// 가구
|
||||
const furnitureItems = this.furnitureRenderer.getRenderables(this.theme, this.zoom, this.panX, this.panY);
|
||||
renderables.push(...furnitureItems);
|
||||
|
||||
// 에이전트
|
||||
for (const sprite of this.agents.values()) {
|
||||
renderables.push({
|
||||
zY: sprite.y,
|
||||
draw: (ctx2) => sprite.draw(ctx2, this.zoom, this.panX, this.panY, mapData.tileSize)
|
||||
});
|
||||
}
|
||||
|
||||
// Y좌표 정렬
|
||||
renderables.sort((a, b) => a.zY - b.zY);
|
||||
for (const item of renderables) {
|
||||
item.draw(ctx);
|
||||
}
|
||||
|
||||
// 3. 오버레이 (항상 최상위)
|
||||
for (const sprite of this.agents.values()) {
|
||||
this.overlayRenderer.draw(ctx, sprite, this.theme, this.zoom, this.panX, this.panY, mapData.tileSize);
|
||||
}
|
||||
}
|
||||
|
||||
/** 드래그 여부 반환 (클릭 이벤트 필터링용) */
|
||||
wasDragging() { return this._wasDragging; }
|
||||
|
||||
/** 리사이즈 처리 */
|
||||
resize() {
|
||||
// 다음 프레임에서 자동 조정됨 (_render에서 크기 체크)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.stop();
|
||||
// window 이벤트 리스너 정리
|
||||
if (this._onMouseMove) window.removeEventListener('mousemove', this._onMouseMove);
|
||||
if (this._onMouseUp) window.removeEventListener('mouseup', this._onMouseUp);
|
||||
}
|
||||
}
|
||||
|
||||
122
src/pages/agent-office/canvas/OverlayRenderer.js
Normal file
122
src/pages/agent-office/canvas/OverlayRenderer.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// src/pages/agent-office/canvas/OverlayRenderer.js
|
||||
|
||||
/**
|
||||
* 캔버스 위 오버레이 렌더링:
|
||||
* - 이름 라벨 (항상)
|
||||
* - 상태 배지 (항상)
|
||||
* - 말풍선 (waiting 상태에서만)
|
||||
* - 알림 배지 (notification > 0 일 때)
|
||||
*/
|
||||
|
||||
const STATE_BADGE = {
|
||||
idle: { text: 'idle', bg: '#374151', fg: '#9ca3af' },
|
||||
working: { text: 'working', bg: '#1e3a5f', fg: '#60a5fa' },
|
||||
waiting: { text: 'waiting', bg: '#92400e', fg: '#fbbf24' },
|
||||
reporting: { text: 'reporting', bg: '#1e3a5f', fg: '#60a5fa' },
|
||||
break: { text: 'break', bg: '#065f46', fg: '#34d399' }
|
||||
};
|
||||
|
||||
export class OverlayRenderer {
|
||||
constructor() {
|
||||
this._bubbleAlpha = new Map(); // agentId → alpha (fade in/out)
|
||||
}
|
||||
|
||||
draw(ctx, sprite, theme, zoom, panX, panY, tileSize) {
|
||||
const ts = tileSize * zoom;
|
||||
const centerX = sprite.x * ts + panX + ts / 2;
|
||||
const topY = sprite.y * ts + panY - ts * 0.3;
|
||||
|
||||
const fontSize = Math.max(10, 11 * zoom / 2);
|
||||
const smallFontSize = Math.max(8, 9 * zoom / 2);
|
||||
|
||||
// 1. 이름 라벨
|
||||
ctx.font = `bold ${fontSize}px 'Courier New', monospace`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = sprite.meta.color;
|
||||
ctx.fillText(sprite.meta.displayName, centerX, topY + ts * 1.85);
|
||||
|
||||
// 2. 상태 배지
|
||||
const badge = STATE_BADGE[sprite.state] || STATE_BADGE.idle;
|
||||
const badgeText = badge.text;
|
||||
ctx.font = `${smallFontSize}px 'Courier New', monospace`;
|
||||
const badgeW = ctx.measureText(badgeText).width + 8;
|
||||
const badgeH = smallFontSize + 4;
|
||||
const badgeX = centerX - badgeW / 2;
|
||||
const badgeY = topY + ts * 1.9;
|
||||
|
||||
ctx.fillStyle = badge.bg;
|
||||
this._roundRect(ctx, badgeX, badgeY, badgeW, badgeH, 3);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = badge.fg;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(badgeText, centerX, badgeY + badgeH - 3);
|
||||
|
||||
// 3. 말풍선 (waiting 상태에서만)
|
||||
if (sprite.state === 'waiting') {
|
||||
this._drawBubble(ctx, sprite, centerX, topY - ts * 0.2, zoom);
|
||||
}
|
||||
|
||||
// 4. 알림 배지
|
||||
if (sprite.notificationCount > 0) {
|
||||
this._drawNotificationBadge(ctx, centerX + ts * 0.5, topY + ts * 0.2, sprite.notificationCount, zoom);
|
||||
}
|
||||
}
|
||||
|
||||
_drawBubble(ctx, sprite, x, y, zoom) {
|
||||
const text = '승인 대기!';
|
||||
const fontSize = Math.max(10, 11 * zoom / 2);
|
||||
ctx.font = `bold ${fontSize}px 'Courier New', monospace`;
|
||||
const tw = ctx.measureText(text).width;
|
||||
const pw = tw + 16;
|
||||
const ph = fontSize + 12;
|
||||
const px = x - pw / 2;
|
||||
const py = y - ph;
|
||||
|
||||
// 말풍선 배경
|
||||
ctx.fillStyle = '#fbbf24';
|
||||
this._roundRect(ctx, px, py, pw, ph, 6);
|
||||
ctx.fill();
|
||||
|
||||
// 꼬리 삼각형
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - 5, py + ph);
|
||||
ctx.lineTo(x + 5, py + ph);
|
||||
ctx.lineTo(x, py + ph + 6);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// 텍스트
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(text, x, py + ph - 5);
|
||||
}
|
||||
|
||||
_drawNotificationBadge(ctx, x, y, count, zoom) {
|
||||
const r = Math.max(7, 8 * zoom / 2);
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = `bold ${r}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(count > 9 ? '9+' : String(count), x, y);
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
}
|
||||
|
||||
_roundRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||
ctx.lineTo(x + w, y + h - r);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||
ctx.lineTo(x + r, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
}
|
||||
112
src/pages/agent-office/canvas/Pathfinder.js
Normal file
112
src/pages/agent-office/canvas/Pathfinder.js
Normal file
@@ -0,0 +1,112 @@
|
||||
// src/pages/agent-office/canvas/Pathfinder.js
|
||||
|
||||
/**
|
||||
* BFS 4방향 경로 탐색 (대각선 없음)
|
||||
* blocked 타일과 벽 타일을 회피하여 최단 경로 반환
|
||||
*/
|
||||
export class Pathfinder {
|
||||
constructor(cols, rows) {
|
||||
this.cols = cols;
|
||||
this.rows = rows;
|
||||
this.blocked = new Set();
|
||||
}
|
||||
|
||||
/** blocked 타일 세팅 (wall + furniture footprint) */
|
||||
setBlocked(blockedList) {
|
||||
// Do NOT clear — setWalls already added wall tiles
|
||||
for (const [col, row] of blockedList) {
|
||||
this.blocked.add(`${col},${row}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** wall 타일도 blocked로 추가 (floor 배열에서 0인 셀) */
|
||||
setWalls(floorGrid) {
|
||||
for (let r = 0; r < this.rows; r++) {
|
||||
for (let c = 0; c < this.cols; c++) {
|
||||
if (floorGrid[r][c] === 0) {
|
||||
this.blocked.add(`${c},${r}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isBlocked(col, row) {
|
||||
if (col < 0 || col >= this.cols || row < 0 || row >= this.rows) return true;
|
||||
return this.blocked.has(`${col},${row}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* BFS 최단 경로
|
||||
* @returns {Array<{col, row}>} 시작점 제외, 도착점 포함 경로. 경로 없으면 빈 배열.
|
||||
*/
|
||||
findPath(startCol, startRow, goalCol, goalRow) {
|
||||
if (startCol === goalCol && startRow === goalRow) return [];
|
||||
|
||||
const key = (c, r) => `${c},${r}`;
|
||||
const startKey = key(startCol, startRow);
|
||||
const goalKey = key(goalCol, goalRow);
|
||||
|
||||
const queue = [{ col: startCol, row: startRow }];
|
||||
const visited = new Set([startKey]);
|
||||
const parent = new Map();
|
||||
|
||||
const dirs = [
|
||||
{ dc: 0, dr: -1 }, // up
|
||||
{ dc: 0, dr: 1 }, // down
|
||||
{ dc: -1, dr: 0 }, // left
|
||||
{ dc: 1, dr: 0 } // right
|
||||
];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
|
||||
for (const { dc, dr } of dirs) {
|
||||
const nc = current.col + dc;
|
||||
const nr = current.row + dr;
|
||||
const nk = key(nc, nr);
|
||||
|
||||
if (visited.has(nk)) continue;
|
||||
// 골 지점은 blocked여도 이동 가능 (에이전트가 자기 자리에 앉으려면)
|
||||
if (nk !== goalKey && this.isBlocked(nc, nr)) continue;
|
||||
|
||||
visited.add(nk);
|
||||
parent.set(nk, key(current.col, current.row));
|
||||
queue.push({ col: nc, row: nr });
|
||||
|
||||
if (nc === goalCol && nr === goalRow) {
|
||||
return this._reconstructPath(parent, startKey, goalKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return []; // 경로 없음
|
||||
}
|
||||
|
||||
_reconstructPath(parent, startKey, goalKey) {
|
||||
const path = [];
|
||||
let current = goalKey;
|
||||
while (current !== startKey) {
|
||||
const [c, r] = current.split(',').map(Number);
|
||||
path.unshift({ col: c, row: r });
|
||||
current = parent.get(current);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/** idle 배회용: start 주변 반경 내 랜덤 walkable 타일 */
|
||||
getRandomNearbyFloor(col, row, radius = 4) {
|
||||
const candidates = [];
|
||||
for (let dr = -radius; dr <= radius; dr++) {
|
||||
for (let dc = -radius; dc <= radius; dc++) {
|
||||
const nc = col + dc;
|
||||
const nr = row + dr;
|
||||
if (nc === col && nr === row) continue;
|
||||
if (!this.isBlocked(nc, nr)) {
|
||||
candidates.push({ col: nc, row: nr });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return null;
|
||||
return candidates[Math.floor(Math.random() * candidates.length)];
|
||||
}
|
||||
}
|
||||
164
src/pages/agent-office/canvas/ProceduralSprite.js
Normal file
164
src/pages/agent-office/canvas/ProceduralSprite.js
Normal file
@@ -0,0 +1,164 @@
|
||||
// src/pages/agent-office/canvas/ProceduralSprite.js
|
||||
|
||||
/**
|
||||
* 프로시저럴 픽셀 캐릭터 렌더러 (16×32px 기본 해상도)
|
||||
* Phase 1: 코드로 캐릭터를 그림
|
||||
* Phase 2: SpriteLoader가 PNG 스프라이트로 대체
|
||||
*/
|
||||
|
||||
const AGENT_COLORS = {
|
||||
stock: { body: '#4488cc', hair: '#2255aa', accent: '#66aaee' },
|
||||
music: { body: '#44aa88', hair: '#228866', accent: '#66ccaa' },
|
||||
blog: { body: '#d97706', hair: '#b45e04', accent: '#f59e0b' },
|
||||
realestate: { body: '#c026d3', hair: '#9b1dab', accent: '#e044f0' },
|
||||
lotto: { body: '#ef4444', hair: '#cc2222', accent: '#ff6666' }
|
||||
};
|
||||
|
||||
/** 애니메이션 프레임 설정 */
|
||||
const ANIM_CONFIG = {
|
||||
idle: { frames: 2, speed: 0.8 },
|
||||
walk: { frames: 4, speed: 0.15, cycle: [0, 1, 2, 1] },
|
||||
type: { frames: 2, speed: 0.3 },
|
||||
wait: { frames: 2, speed: 0.5 },
|
||||
break_anim:{ frames: 2, speed: 1.0 }
|
||||
};
|
||||
|
||||
export class ProceduralSprite {
|
||||
/**
|
||||
* 캐릭터 1프레임 렌더링
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {string} agentId
|
||||
* @param {string} state - idle|walk|type|wait|break_anim
|
||||
* @param {string} direction - down|up|right|left
|
||||
* @param {number} frame - 현재 애니메이션 프레임 인덱스
|
||||
* @param {number} x - 캔버스 X 좌표 (캐릭터 중앙 하단)
|
||||
* @param {number} y - 캔버스 Y 좌표 (캐릭터 중앙 하단)
|
||||
* @param {number} scale - 렌더링 스케일
|
||||
*/
|
||||
static draw(ctx, agentId, state, direction, frame, x, y, scale) {
|
||||
const colors = AGENT_COLORS[agentId] || AGENT_COLORS.stock;
|
||||
const px = scale; // 1 pixel = scale 크기
|
||||
const w = 16 * px;
|
||||
const h = 32 * px;
|
||||
const bx = x - w / 2; // 좌상단 기준
|
||||
const by = y - h;
|
||||
|
||||
ctx.save();
|
||||
|
||||
// 좌우 반전 (left = right 플립)
|
||||
if (direction === 'left') {
|
||||
ctx.translate(x, 0);
|
||||
ctx.scale(-1, 1);
|
||||
ctx.translate(-x, 0);
|
||||
}
|
||||
|
||||
// 그림자
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.2)';
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x, y, w * 0.35, px * 2, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 상태별 오프셋
|
||||
let bodyOffsetY = 0;
|
||||
let legSpread = 0;
|
||||
let armAngle = 0;
|
||||
|
||||
if (state === 'walk') {
|
||||
const walkFrame = ANIM_CONFIG.walk.cycle[frame % 4];
|
||||
legSpread = (walkFrame - 1) * px * 2;
|
||||
bodyOffsetY = walkFrame === 1 ? -px : 0;
|
||||
} else if (state === 'type') {
|
||||
armAngle = frame % 2 === 0 ? 1 : -1;
|
||||
bodyOffsetY = frame % 2 === 0 ? 0 : -px * 0.5;
|
||||
} else if (state === 'wait') {
|
||||
bodyOffsetY = Math.sin(frame * Math.PI) * px;
|
||||
} else if (state === 'idle') {
|
||||
bodyOffsetY = frame % 2 === 0 ? 0 : -px * 0.5;
|
||||
} else if (state === 'break_anim') {
|
||||
bodyOffsetY = frame % 2 === 0 ? 0 : px * 0.5; // 졸기
|
||||
}
|
||||
|
||||
const by2 = by + bodyOffsetY;
|
||||
|
||||
// 다리
|
||||
ctx.fillStyle = '#2a2a3e';
|
||||
// 왼쪽 다리
|
||||
ctx.fillRect(bx + px * 4 - legSpread, by2 + px * 24, px * 3, px * 8);
|
||||
// 오른쪽 다리
|
||||
ctx.fillRect(bx + px * 9 + legSpread, by2 + px * 24, px * 3, px * 8);
|
||||
// 신발
|
||||
ctx.fillStyle = '#333';
|
||||
ctx.fillRect(bx + px * 3 - legSpread, by2 + px * 30, px * 5, px * 2);
|
||||
ctx.fillRect(bx + px * 8 + legSpread, by2 + px * 30, px * 5, px * 2);
|
||||
|
||||
// 몸통
|
||||
ctx.fillStyle = colors.body;
|
||||
ctx.fillRect(bx + px * 3, by2 + px * 12, px * 10, px * 13);
|
||||
|
||||
// 팔
|
||||
if (state === 'type') {
|
||||
// 타이핑: 팔 앞으로 뻗음
|
||||
ctx.fillStyle = colors.body;
|
||||
ctx.fillRect(bx + px * 1, by2 + px * 13, px * 3, px * 8 + armAngle * px);
|
||||
ctx.fillRect(bx + px * 12, by2 + px * 13, px * 3, px * 8 - armAngle * px);
|
||||
// 손
|
||||
ctx.fillStyle = '#ffcc99';
|
||||
ctx.fillRect(bx + px * 1, by2 + px * 20 + armAngle * px, px * 3, px * 3);
|
||||
ctx.fillRect(bx + px * 12, by2 + px * 20 - armAngle * px, px * 3, px * 3);
|
||||
} else {
|
||||
// 기본 팔
|
||||
ctx.fillStyle = colors.body;
|
||||
ctx.fillRect(bx + px * 1, by2 + px * 13, px * 3, px * 10);
|
||||
ctx.fillRect(bx + px * 12, by2 + px * 13, px * 3, px * 10);
|
||||
// 손
|
||||
ctx.fillStyle = '#ffcc99';
|
||||
ctx.fillRect(bx + px * 1, by2 + px * 22, px * 3, px * 3);
|
||||
ctx.fillRect(bx + px * 12, by2 + px * 22, px * 3, px * 3);
|
||||
}
|
||||
|
||||
// 머리
|
||||
ctx.fillStyle = '#ffcc99'; // 피부색
|
||||
ctx.fillRect(bx + px * 4, by2 + px * 2, px * 8, px * 10);
|
||||
|
||||
// 머리카락
|
||||
ctx.fillStyle = colors.hair;
|
||||
ctx.fillRect(bx + px * 3, by2 + px * 1, px * 10, px * 4);
|
||||
if (direction === 'down' || direction === 'left' || direction === 'right') {
|
||||
// 앞머리
|
||||
ctx.fillRect(bx + px * 4, by2 + px * 3, px * 2, px * 2);
|
||||
}
|
||||
|
||||
// 눈
|
||||
if (direction !== 'up') {
|
||||
ctx.fillStyle = '#222';
|
||||
if (state === 'break_anim' && frame % 2 === 1) {
|
||||
// 졸기: 눈 감음
|
||||
ctx.fillRect(bx + px * 5, by2 + px * 6, px * 2, px);
|
||||
ctx.fillRect(bx + px * 9, by2 + px * 6, px * 2, px);
|
||||
} else {
|
||||
ctx.fillRect(bx + px * 5, by2 + px * 5, px * 2, px * 2);
|
||||
ctx.fillRect(bx + px * 9, by2 + px * 5, px * 2, px * 2);
|
||||
}
|
||||
}
|
||||
|
||||
// break 소품: 커피잔
|
||||
if (state === 'break_anim') {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(bx + px * 14, by2 + px * 16, px * 3, px * 4);
|
||||
ctx.fillStyle = '#8B4513';
|
||||
ctx.fillRect(bx + px * 14.5, by2 + px * 16.5, px * 2, px * 2);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
static getAnimConfig(state) {
|
||||
const mapped = state === 'working' ? 'type'
|
||||
: state === 'waiting' ? 'wait'
|
||||
: state === 'reporting' ? 'type'
|
||||
: state === 'break' ? 'break_anim'
|
||||
: state === 'walk' ? 'walk'
|
||||
: 'idle';
|
||||
return { ...(ANIM_CONFIG[mapped] || ANIM_CONFIG.idle), mapped };
|
||||
}
|
||||
}
|
||||
77
src/pages/agent-office/canvas/SpriteLoader.js
Normal file
77
src/pages/agent-office/canvas/SpriteLoader.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// src/pages/agent-office/canvas/SpriteLoader.js
|
||||
|
||||
import { ProceduralSprite } from './ProceduralSprite.js';
|
||||
|
||||
/**
|
||||
* 스프라이트 로더 — PNG 스프라이트시트가 있으면 사용, 없으면 프로시저럴 폴백
|
||||
*
|
||||
* 스프라이트시트 규격 (Phase 2):
|
||||
* - 프레임 크기: 16×32px
|
||||
* - 행: 방향 (0=down, 1=up, 2=right)
|
||||
* - 열: 상태별 프레임 (idle 2 | walk 4 | type 2 | wait 2 | break 2 = 12열)
|
||||
*/
|
||||
export class SpriteLoader {
|
||||
constructor() {
|
||||
this.sprites = new Map(); // agentId → { image: Image, loaded: boolean }
|
||||
}
|
||||
|
||||
/** PNG 스프라이트시트 로드 시도 */
|
||||
async tryLoad(agentId, url) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
this.sprites.set(agentId, { image: img, loaded: true });
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
resolve(false); // 폴백 사용
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
hasSprite(agentId) {
|
||||
return this.sprites.has(agentId) && this.sprites.get(agentId).loaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* 에이전트 1프레임 그리기 (스프라이트 또는 프로시저럴)
|
||||
*/
|
||||
draw(ctx, agentId, state, direction, frame, x, y, scale) {
|
||||
if (this.hasSprite(agentId)) {
|
||||
this._drawFromSheet(ctx, agentId, state, direction, frame, x, y, scale);
|
||||
} else {
|
||||
ProceduralSprite.draw(ctx, agentId, state, direction, frame, x, y, scale);
|
||||
}
|
||||
}
|
||||
|
||||
_drawFromSheet(ctx, agentId, state, direction, frame, x, y, scale) {
|
||||
const { image } = this.sprites.get(agentId);
|
||||
const frameW = 16;
|
||||
const frameH = 32;
|
||||
|
||||
// 방향 → 행
|
||||
const dirRow = direction === 'up' ? 1 : direction === 'right' || direction === 'left' ? 2 : 0;
|
||||
|
||||
// 상태 → 열 오프셋
|
||||
const stateOffsets = { idle: 0, walk: 2, type: 6, wait: 8, break_anim: 10 };
|
||||
const mappedState = state === 'working' ? 'type' : state === 'waiting' ? 'wait'
|
||||
: state === 'reporting' ? 'type' : state === 'break' ? 'break_anim'
|
||||
: state === 'walk' ? 'walk' : 'idle';
|
||||
const colOffset = stateOffsets[mappedState] || 0;
|
||||
|
||||
const srcX = (colOffset + frame) * frameW;
|
||||
const srcY = dirRow * frameH;
|
||||
const destW = frameW * scale;
|
||||
const destH = frameH * scale;
|
||||
|
||||
ctx.save();
|
||||
if (direction === 'left') {
|
||||
ctx.translate(x, 0);
|
||||
ctx.scale(-1, 1);
|
||||
ctx.translate(-x, 0);
|
||||
}
|
||||
ctx.drawImage(image, srcX, srcY, frameW, frameH, x - destW / 2, y - destH, destW, destH);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
const PIXEL_CHARS = {
|
||||
stock: { body: '#4488cc', accent: '#cc4444', label: '주식', hair: '#332222' },
|
||||
music: { body: '#44aa88', accent: '#ffaa00', label: '음악', hair: '#443322' },
|
||||
blog: { body: '#d97706', accent: '#fde68a', label: '블로그', hair: '#3b2a1a' },
|
||||
realestate: { body: '#c026d3', accent: '#86efac', label: '청약', hair: '#2a2a3a' },
|
||||
claude: { body: '#8855cc', accent: '#cc88ff', label: 'Claude', hair: '#554466' },
|
||||
};
|
||||
|
||||
const ANIM_FRAMES = {
|
||||
idle: { frames: 2, speed: 800 },
|
||||
working: { frames: 4, speed: 200 },
|
||||
waiting: { frames: 2, speed: 400 },
|
||||
break: { frames: 2, speed: 1000 },
|
||||
walk: { frames: 4, speed: 150 },
|
||||
};
|
||||
|
||||
export function drawAgent(ctx, agentId, x, y, state, frameIndex, scale = 2) {
|
||||
const char = PIXEL_CHARS[agentId] || PIXEL_CHARS.claude;
|
||||
const s = scale;
|
||||
const anim = ANIM_FRAMES[state] || ANIM_FRAMES.idle;
|
||||
const frame = frameIndex % anim.frames;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
|
||||
// Shadow
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.2)';
|
||||
ctx.fillRect(-4 * s, 14 * s, 8 * s, 2 * s);
|
||||
|
||||
// Body
|
||||
ctx.fillStyle = char.body;
|
||||
ctx.fillRect(-3 * s, 2 * s, 6 * s, 8 * s);
|
||||
|
||||
// Head
|
||||
ctx.fillStyle = '#ffcc99';
|
||||
ctx.fillRect(-3 * s, -4 * s, 6 * s, 6 * s);
|
||||
|
||||
// Hair
|
||||
ctx.fillStyle = char.hair;
|
||||
ctx.fillRect(-3 * s, -5 * s, 6 * s, 2 * s);
|
||||
|
||||
// Eyes
|
||||
ctx.fillStyle = '#222';
|
||||
const eyeOffset = state === 'break' && frame === 1 ? 0 : 1;
|
||||
ctx.fillRect(-2 * s, -1 * s, 1 * s, eyeOffset * s);
|
||||
ctx.fillRect(1 * s, -1 * s, 1 * s, eyeOffset * s);
|
||||
|
||||
// Legs
|
||||
ctx.fillStyle = '#335';
|
||||
const legSpread = state === 'walk' ? (frame % 2 === 0 ? 1 : -1) : 0;
|
||||
ctx.fillRect(-2 * s, 10 * s, 2 * s, 4 * s);
|
||||
ctx.fillRect(0 + legSpread * s, 10 * s, 2 * s, 4 * s);
|
||||
|
||||
// Accent
|
||||
ctx.fillStyle = char.accent;
|
||||
if (agentId === 'stock') {
|
||||
ctx.fillRect(0, 2 * s, 1 * s, 5 * s);
|
||||
} else if (agentId === 'music') {
|
||||
ctx.fillRect(-4 * s, -4 * s, 1 * s, 4 * s);
|
||||
ctx.fillRect(3 * s, -4 * s, 1 * s, 4 * s);
|
||||
ctx.fillRect(-4 * s, -5 * s, 8 * s, 1 * s);
|
||||
} else if (agentId === 'blog') {
|
||||
// 노트북 액센트 (무릎 위)
|
||||
ctx.fillRect(-3 * s, 6 * s, 6 * s, 1 * s);
|
||||
ctx.fillRect(-3 * s, 7 * s, 6 * s, 2 * s);
|
||||
} else if (agentId === 'realestate') {
|
||||
// 서류 가방 액센트 (손 옆)
|
||||
ctx.fillRect(3 * s, 4 * s, 2 * s, 3 * s);
|
||||
ctx.fillRect(3 * s, 3 * s, 2 * s, 1 * s);
|
||||
} else if (agentId === 'claude') {
|
||||
ctx.globalAlpha = 0.3 + 0.2 * Math.sin(Date.now() / 500);
|
||||
ctx.fillRect(-4 * s, -6 * s, 8 * s, 1 * s);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// Working: typing hands
|
||||
if (state === 'working') {
|
||||
ctx.fillStyle = '#ffcc99';
|
||||
const handY = 6 * s + (frame % 2) * s;
|
||||
ctx.fillRect(-4 * s, handY, 1 * s, 2 * s);
|
||||
ctx.fillRect(3 * s, handY, 1 * s, 2 * s);
|
||||
}
|
||||
|
||||
// Waiting wobble
|
||||
if (state === 'waiting') {
|
||||
const wobble = Math.sin(Date.now() / 200) * s;
|
||||
ctx.translate(wobble, 0);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function getAnimSpeed(state) {
|
||||
return (ANIM_FRAMES[state] || ANIM_FRAMES.idle).speed;
|
||||
}
|
||||
|
||||
export function getCharLabel(agentId) {
|
||||
return (PIXEL_CHARS[agentId] || {}).label || agentId;
|
||||
}
|
||||
|
||||
export function drawNotificationBadge(ctx, x, y, count, scale = 2) {
|
||||
const s = scale;
|
||||
const badgeX = x + 5 * s;
|
||||
const badgeY = y - 8 * s;
|
||||
const radius = 5 * s;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(badgeX, badgeY, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#f43f5e';
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = `bold ${7 * s}px monospace`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('!', badgeX, badgeY);
|
||||
}
|
||||
@@ -1,90 +1,80 @@
|
||||
const WALL_COLOR = '#2a2a3a';
|
||||
const DESK_COLOR = '#6b5b3a';
|
||||
const DESK_TOP = '#8b7b5a';
|
||||
const TABLE_COLOR = '#5a4a2a';
|
||||
const SOFA_COLOR = '#884444';
|
||||
const MONITOR_COLOR = '#224466';
|
||||
const MONITOR_SCREEN = '#44aacc';
|
||||
// src/pages/agent-office/canvas/TileMap.js
|
||||
|
||||
export function drawTileMap(ctx, mapData, width, height) {
|
||||
const { tileSize, cols, rows, layers, furniture, colors } = mapData;
|
||||
const scaleX = width / (cols * tileSize);
|
||||
const scaleY = height / (rows * tileSize);
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
const offsetX = (width - cols * tileSize * scale) / 2;
|
||||
const offsetY = (height - rows * tileSize * scale) / 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(offsetX, offsetY);
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
const floor = layers.floor;
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const tile = floor[r][c];
|
||||
ctx.fillStyle = colors[String(tile)] || '#3a3a50';
|
||||
ctx.fillRect(c * tileSize, r * tileSize, tileSize, tileSize);
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
|
||||
ctx.strokeRect(c * tileSize, r * tileSize, tileSize, tileSize);
|
||||
}
|
||||
/**
|
||||
* 타일맵 렌더러 — 바닥, 벽, 그리드를 테마 팔레트로 렌더링
|
||||
* 가구는 FurnitureRenderer가 별도 처리
|
||||
*/
|
||||
export class TileMap {
|
||||
constructor(mapData) {
|
||||
this.cols = mapData.cols;
|
||||
this.rows = mapData.rows;
|
||||
this.tileSize = mapData.tileSize;
|
||||
this.floor = mapData.floor;
|
||||
this.tileTypes = mapData.tileTypes;
|
||||
}
|
||||
|
||||
ctx.fillStyle = WALL_COLOR;
|
||||
ctx.fillRect(0, 0, cols * tileSize, 4);
|
||||
/**
|
||||
* 바닥 + 벽 렌더링
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {object} theme - themes.js 에서 가져온 테마 객체
|
||||
* @param {number} scale - 줌 레벨
|
||||
* @param {number} offsetX - 패닝 X 오프셋
|
||||
* @param {number} offsetY - 패닝 Y 오프셋
|
||||
*/
|
||||
render(ctx, theme, scale, offsetX, offsetY) {
|
||||
const ts = this.tileSize * scale;
|
||||
|
||||
for (const f of furniture) {
|
||||
const fx = f.x * tileSize;
|
||||
const fy = f.y * tileSize;
|
||||
const fw = (f.w || 2) * tileSize;
|
||||
const fh = (f.h || 2) * tileSize;
|
||||
for (let r = 0; r < this.rows; r++) {
|
||||
for (let c = 0; c < this.cols; c++) {
|
||||
const tileType = this.floor[r][c];
|
||||
const x = c * ts + offsetX;
|
||||
const y = r * ts + offsetY;
|
||||
|
||||
if (f.type === 'desk') {
|
||||
ctx.fillStyle = DESK_COLOR;
|
||||
ctx.fillRect(fx, fy, fw, fh);
|
||||
ctx.fillStyle = DESK_TOP;
|
||||
ctx.fillRect(fx + 2, fy + 2, fw - 4, 6);
|
||||
const mx = fx + fw / 2 - 8;
|
||||
ctx.fillStyle = MONITOR_COLOR;
|
||||
ctx.fillRect(mx, fy + 4, 16, 12);
|
||||
ctx.fillStyle = MONITOR_SCREEN;
|
||||
ctx.fillRect(mx + 2, fy + 6, 12, 8);
|
||||
if (f.label) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||
ctx.font = '8px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(f.label, fx + fw / 2, fy + fh + 12);
|
||||
// 화면 밖이면 스킵 (CSS 공간 기준 — DPR 변환 적용된 좌표계)
|
||||
if (x + ts < 0 || y + ts < 0 || x > ctx.canvas.clientWidth || y > ctx.canvas.clientHeight) continue;
|
||||
|
||||
if (tileType === 0) {
|
||||
// 벽
|
||||
ctx.fillStyle = theme.wall.color;
|
||||
ctx.fillRect(x, y, ts, ts);
|
||||
// 벽 하단 경계선
|
||||
ctx.fillStyle = theme.wall.border;
|
||||
ctx.fillRect(x, y + ts - scale, ts, scale);
|
||||
} else {
|
||||
// 바닥
|
||||
const isBreak = this.tileTypes[String(tileType)] === 'floor_break';
|
||||
ctx.fillStyle = isBreak ? theme.floor.color2 : theme.floor.color1;
|
||||
ctx.fillRect(x, y, ts, ts);
|
||||
|
||||
// 체커보드 패턴
|
||||
if ((r + c) % 2 === 0) {
|
||||
ctx.fillStyle = theme.floor.grid;
|
||||
ctx.fillRect(x, y, ts, ts);
|
||||
}
|
||||
|
||||
// 그리드 선
|
||||
ctx.strokeStyle = theme.floor.grid;
|
||||
ctx.lineWidth = scale * 0.5;
|
||||
ctx.strokeRect(x, y, ts, ts);
|
||||
}
|
||||
}
|
||||
} else if (f.type === 'table') {
|
||||
ctx.fillStyle = TABLE_COLOR;
|
||||
ctx.fillRect(fx, fy, fw, fh);
|
||||
ctx.fillStyle = '#7a6a4a';
|
||||
ctx.fillRect(fx + 4, fy + 4, fw - 8, fh - 8);
|
||||
} else if (f.type === 'sofa') {
|
||||
ctx.fillStyle = SOFA_COLOR;
|
||||
ctx.fillRect(fx, fy, 48, 32);
|
||||
ctx.fillStyle = '#aa5555';
|
||||
ctx.fillRect(fx + 4, fy + 4, 40, 24);
|
||||
} else if (f.type === 'coffee') {
|
||||
ctx.fillStyle = '#664422';
|
||||
ctx.fillRect(fx + 8, fy + 8, 16, 20);
|
||||
ctx.fillStyle = '#886644';
|
||||
ctx.fillRect(fx + 6, fy + 6, 20, 4);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
return { scale, offsetX, offsetY, tileSize };
|
||||
}
|
||||
/** 화면 좌표 → 타일 좌표 변환 */
|
||||
screenToTile(screenX, screenY, scale, offsetX, offsetY) {
|
||||
const ts = this.tileSize * scale;
|
||||
const col = Math.floor((screenX - offsetX) / ts);
|
||||
const row = Math.floor((screenY - offsetY) / ts);
|
||||
return { col, row };
|
||||
}
|
||||
|
||||
export function worldToTile(mapData, renderInfo, canvasX, canvasY) {
|
||||
const { scale, offsetX, offsetY, tileSize } = renderInfo;
|
||||
const wx = (canvasX - offsetX) / scale;
|
||||
const wy = (canvasY - offsetY) / scale;
|
||||
return { col: Math.floor(wx / tileSize), row: Math.floor(wy / tileSize), worldX: wx, worldY: wy };
|
||||
}
|
||||
|
||||
export function tileToCanvas(mapData, renderInfo, col, row) {
|
||||
const { scale, offsetX, offsetY, tileSize } = renderInfo;
|
||||
return { x: offsetX + col * tileSize * scale + (tileSize * scale) / 2, y: offsetY + row * tileSize * scale + (tileSize * scale) / 2 };
|
||||
/** 타일 좌표 → 화면 좌표 (타일 중앙) */
|
||||
tileToScreen(col, row, scale, offsetX, offsetY) {
|
||||
const ts = this.tileSize * scale;
|
||||
return {
|
||||
x: col * ts + offsetX + ts / 2,
|
||||
y: row * ts + offsetY + ts / 2
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
42
src/pages/agent-office/canvas/themes.js
Normal file
42
src/pages/agent-office/canvas/themes.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// src/pages/agent-office/canvas/themes.js
|
||||
|
||||
export const THEMES = {
|
||||
modern: {
|
||||
name: 'Modern',
|
||||
wall: { color: '#1a1a2e', border: '#333', accent: '#8b5cf6' },
|
||||
floor: { color1: '#2a2a3e', color2: '#323248', grid: 'rgba(255,255,255,0.03)' },
|
||||
furniture: { desk: '#3a3a5a', chair: '#4c1d95', monitor: '#5555aa', monitorScreen: '#1a3a5a', shelf: '#2a2a4e', table: '#3a3a5a', sofa: '#2a2a4e', coffee: '#3a3a2a' },
|
||||
decor: { plant: '#22c55e', pot: '#4a3a2a', lamp: '#fbbf24', ledStrip: '#8b5cf6' },
|
||||
lighting: { ambient: 'rgba(139,92,246,0.05)', glow: 'rgba(139,92,246,0.15)' },
|
||||
text: { primary: '#ffffff', secondary: '#aaaaaa', label: '#888888' },
|
||||
ui: { panelBg: '#111111', headerBg: '#1a1a2e', border: '#333333', accent: '#8b5cf6' }
|
||||
},
|
||||
retro: {
|
||||
name: 'Retro',
|
||||
wall: { color: '#2a1a0a', border: '#6a4a2a', accent: '#cc8844' },
|
||||
floor: { color1: '#4a3a1a', color2: '#3a2a10', grid: 'rgba(255,255,255,0.02)' },
|
||||
furniture: { desk: '#6a4a1a', chair: '#8a5a2a', monitor: '#555555', monitorScreen: '#1a3a1a', shelf: '#5a3a1a', table: '#5a4a2a', sofa: '#5a3a2a', coffee: '#4a3a1a' },
|
||||
decor: { plant: '#44aa44', pot: '#6a4a2a', lamp: '#ffcc66', brick: '#8a5a2a' },
|
||||
lighting: { ambient: 'rgba(255,200,100,0.05)', glow: 'rgba(255,200,100,0.2)' },
|
||||
text: { primary: '#ffe0b0', secondary: '#aa8866', label: '#887766' },
|
||||
ui: { panelBg: '#1a1008', headerBg: '#2a1a0a', border: '#4a3a2a', accent: '#cc8844' }
|
||||
},
|
||||
minimal: {
|
||||
name: 'Minimal',
|
||||
wall: { color: '#fafafa', border: '#dddddd', accent: '#3b82f6' },
|
||||
floor: { color1: '#e8e8e8', color2: '#f0f0f0', grid: 'rgba(0,0,0,0.04)' },
|
||||
furniture: { desk: '#ffffff', chair: '#e0e0e0', monitor: '#333333', monitorScreen: '#e0e8f0', shelf: '#f5f5f5', table: '#ffffff', sofa: '#e8e8e8', coffee: '#f0f0f0' },
|
||||
decor: { plant: '#86efac', pot: '#ffffff', lamp: '#fbbf24', window: '#e0f0ff' },
|
||||
lighting: { ambient: 'rgba(59,130,246,0.03)', glow: 'rgba(255,255,255,0.1)' },
|
||||
text: { primary: '#1a1a1a', secondary: '#666666', label: '#999999' },
|
||||
ui: { panelBg: '#ffffff', headerBg: '#fafafa', border: '#e0e0e0', accent: '#3b82f6' }
|
||||
}
|
||||
};
|
||||
|
||||
export function getTheme(name) {
|
||||
return THEMES[name] || THEMES.modern;
|
||||
}
|
||||
|
||||
export function getThemeNames() {
|
||||
return Object.entries(THEMES).map(([key, val]) => ({ key, name: val.name }));
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getAgentTasks, getAgentTokenUsage } from '../../../api';
|
||||
|
||||
const STATUS_BADGE = {
|
||||
pending: { label: '대기', bg: '#92400e' },
|
||||
approved: { label: '승인됨', bg: '#1e40af' },
|
||||
working: { label: '진행중', bg: '#3730a3' },
|
||||
succeeded: { label: '완료', bg: '#065f46' },
|
||||
failed: { label: '실패', bg: '#7f1d1d' },
|
||||
rejected: { label: '거절됨', bg: '#9a3412' },
|
||||
};
|
||||
|
||||
const AGENT_COMMANDS = {
|
||||
stock: [
|
||||
{ action: 'fetch_news', label: '뉴스 수집', icon: '📰' },
|
||||
{ action: 'list_alerts', label: '알람 목록', icon: '🔔' },
|
||||
{ action: 'test_telegram', label: 'TG 테스트', icon: '📨' },
|
||||
],
|
||||
music: [
|
||||
{ action: 'compose', label: '작곡 시작', icon: '🎵', needsInput: true },
|
||||
{ action: 'credits', label: '크레딧', icon: '💳' },
|
||||
],
|
||||
blog: [
|
||||
{ action: 'research', label: '키워드 리서치', icon: '🔍', needsInput: true },
|
||||
{ action: 'list_trend_keywords', label: '트렌드 목록', icon: '📋' },
|
||||
],
|
||||
realestate: [
|
||||
{ action: 'fetch_matches', label: '매칭 리포트', icon: '🏢' },
|
||||
{ action: 'dashboard', label: '대시보드', icon: '📊' },
|
||||
],
|
||||
};
|
||||
|
||||
const AgentColumn = ({ agentId, meta, agentState, notification, onCommand, onApproval, onClearNotification }) => {
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [activeCommand, setActiveCommand] = useState(null);
|
||||
const [tokenUsage, setTokenUsage] = useState(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const state = agentState || { state: 'offline' };
|
||||
const commands = AGENT_COMMANDS[agentId] || [];
|
||||
const needsAttention = state.state === 'waiting' || notification > 0;
|
||||
const isOpen = expanded || needsAttention;
|
||||
|
||||
useEffect(() => {
|
||||
getAgentTasks(agentId, 10)
|
||||
.then(d => setTasks(d.tasks || []))
|
||||
.catch(() => setTasks([]));
|
||||
}, [agentId]);
|
||||
|
||||
// Refresh tasks when state changes to idle (task likely completed)
|
||||
useEffect(() => {
|
||||
if (state.state === 'idle' && state.detail) {
|
||||
getAgentTasks(agentId, 10)
|
||||
.then(d => setTasks(d.tasks || []))
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [agentId, state.state, state.detail]);
|
||||
|
||||
// 오늘자 AI 토큰 사용량 폴링 (30초 간격 + 작업 완료 시 즉시 갱신)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchUsage = () => {
|
||||
getAgentTokenUsage(agentId, 1)
|
||||
.then(d => { if (!cancelled) setTokenUsage(d); })
|
||||
.catch(() => {});
|
||||
};
|
||||
fetchUsage();
|
||||
const interval = setInterval(fetchUsage, 30000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [agentId, state.state, state.detail]);
|
||||
|
||||
const handleQuickAction = (cmd) => {
|
||||
if (cmd.needsInput) {
|
||||
setActiveCommand(cmd.action);
|
||||
} else {
|
||||
onCommand(agentId, cmd.action, {});
|
||||
}
|
||||
onClearNotification();
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || !activeCommand) return;
|
||||
const params = activeCommand === 'compose' ? { prompt: input }
|
||||
: activeCommand === 'research' ? { keyword: input }
|
||||
: { message: input };
|
||||
onCommand(agentId, activeCommand, params);
|
||||
setInput('');
|
||||
setActiveCommand(null);
|
||||
};
|
||||
|
||||
const formatTaskTime = (task) => {
|
||||
const iso = task.completed_at || task.created_at;
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const now = new Date();
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
const sameDay = d.toDateString() === now.toDateString();
|
||||
const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1);
|
||||
const isYesterday = d.toDateString() === yesterday.toDateString();
|
||||
if (sameDay) return `오늘 ${hm}`;
|
||||
if (isYesterday) return `어제 ${hm}`;
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hm}`;
|
||||
};
|
||||
|
||||
const handleHeaderClick = (e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(v => !v);
|
||||
onClearNotification();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`ao-col ${isOpen ? 'ao-col--open' : 'ao-col--collapsed'} ${needsAttention ? 'ao-col--attention' : ''}`} onClick={onClearNotification}>
|
||||
<div className="ao-col-header" style={{ borderColor: meta.color }} onClick={handleHeaderClick}>
|
||||
<span className="ao-col-name" style={{ color: meta.color }}>{meta.name}</span>
|
||||
{tokenUsage && tokenUsage.total_tokens > 0 && (
|
||||
<span
|
||||
className="ao-col-tokens"
|
||||
title={`오늘 ${tokenUsage.task_count}건 작업 · ${tokenUsage.total_tokens.toLocaleString()} 토큰`}
|
||||
>
|
||||
🧮 {tokenUsage.total_tokens.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
<span className={`ao-col-state ao-col-state--${state.state}`}>{state.state}</span>
|
||||
{notification > 0 && <span className="ao-col-badge">{notification}</span>}
|
||||
<span className="ao-col-chevron" aria-hidden="true">{isOpen ? '▾' : '▸'}</span>
|
||||
</div>
|
||||
|
||||
<div className="ao-col-body">
|
||||
|
||||
|
||||
{state.detail && (
|
||||
<div className="ao-col-detail">{state.detail}</div>
|
||||
)}
|
||||
|
||||
{state.state === 'waiting' && state.taskId && (
|
||||
<div className="ao-col-approval">
|
||||
<span>승인 대기</span>
|
||||
<button className="ao-btn ao-btn--approve" onClick={() => onApproval(agentId, state.taskId, true)}>승인</button>
|
||||
<button className="ao-btn ao-btn--reject" onClick={() => onApproval(agentId, state.taskId, false)}>거절</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ao-col-commands">
|
||||
{commands.map(cmd => (
|
||||
<button key={cmd.action} className="ao-cmd-btn" onClick={() => handleQuickAction(cmd)}>
|
||||
{cmd.icon} {cmd.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeCommand && (
|
||||
<div className="ao-col-input">
|
||||
<input
|
||||
className="ao-chat-input"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSend()}
|
||||
placeholder="입력..."
|
||||
autoFocus
|
||||
/>
|
||||
<button className="ao-btn ao-btn--send" onClick={handleSend}>전송</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ao-col-tasks">
|
||||
<div className="ao-col-tasks-title">최근 작업</div>
|
||||
{tasks.length === 0 && <div className="ao-col-empty">이력 없음</div>}
|
||||
{tasks.map(task => {
|
||||
const badge = STATUS_BADGE[task.status] || STATUS_BADGE.pending;
|
||||
return (
|
||||
<div key={task.id} className="ao-col-task">
|
||||
<div className="ao-col-task-row">
|
||||
<span className="ao-col-task-type">{task.task_type}</span>
|
||||
<span className="ao-col-task-badge" style={{ background: badge.bg }}>{badge.label}</span>
|
||||
</div>
|
||||
<div className="ao-col-task-time">
|
||||
{formatTaskTime(task)}
|
||||
{task.result_data?.telegram_sent !== undefined && (
|
||||
<span className="ao-doc-tg-status">{task.result_data.telegram_sent ? ' TG OK' : ' TG Fail'}</span>
|
||||
)}
|
||||
</div>
|
||||
{task.result_data && (
|
||||
<details className="ao-col-task-detail">
|
||||
<summary>결과</summary>
|
||||
<pre>{JSON.stringify(task.result_data, null, 2)}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentColumn;
|
||||
@@ -1,125 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const AGENT_COMMANDS = {
|
||||
stock: [
|
||||
{ action: 'fetch_news', label: '뉴스 수집', icon: '📰' },
|
||||
{ action: 'list_alerts', label: '알람 목록', icon: '🔔' },
|
||||
{ action: 'test_telegram', label: '텔레그램 테스트', icon: '📨' },
|
||||
],
|
||||
music: [
|
||||
{ action: 'compose', label: '작곡 시작', icon: '🎵', needsInput: true },
|
||||
{ action: 'credits', label: '크레딧 확인', icon: '💳' },
|
||||
],
|
||||
blog: [
|
||||
{ action: 'research', label: '키워드 리서치', icon: '🔍', needsInput: true },
|
||||
{ action: 'list_trend_keywords', label: '트렌드 목록', icon: '📋' },
|
||||
],
|
||||
realestate: [
|
||||
{ action: 'fetch_matches', label: '매칭 리포트', icon: '🏢' },
|
||||
{ action: 'dashboard', label: '대시보드', icon: '📊' },
|
||||
],
|
||||
};
|
||||
|
||||
const AGENT_NAMES = {
|
||||
stock: '주식 트레이더',
|
||||
music: '음악 프로듀서',
|
||||
blog: '블로그 마케터',
|
||||
realestate: '청약 애널리스트',
|
||||
};
|
||||
|
||||
const ChatPanel = ({ agentId, agentState, onCommand, onApproval, onClose }) => {
|
||||
const [input, setInput] = useState('');
|
||||
const [activeCommand, setActiveCommand] = useState(null);
|
||||
|
||||
const commands = AGENT_COMMANDS[agentId] || [];
|
||||
const state = agentState || {};
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || !activeCommand) return;
|
||||
const params = activeCommand === 'compose' ? { prompt: input }
|
||||
: activeCommand === 'research' ? { keyword: input }
|
||||
: { message: input };
|
||||
onCommand(agentId, activeCommand, params);
|
||||
setInput('');
|
||||
setActiveCommand(null);
|
||||
};
|
||||
|
||||
const handleQuickAction = (cmd) => {
|
||||
if (cmd.needsInput) {
|
||||
setActiveCommand(cmd.action);
|
||||
} else {
|
||||
onCommand(agentId, cmd.action, {});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ao-chat-panel">
|
||||
<div className="ao-chat-header">
|
||||
<span className="ao-chat-title">
|
||||
{AGENT_NAMES[agentId] || agentId}
|
||||
</span>
|
||||
<span className={`ao-chat-state ao-chat-state--${state.state || 'idle'}`}>
|
||||
{state.state || 'idle'}
|
||||
</span>
|
||||
<button className="ao-chat-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
{state.detail && (
|
||||
<div className="ao-chat-detail">{state.detail}</div>
|
||||
)}
|
||||
|
||||
{state.state === 'waiting' && state.taskId && (
|
||||
<div className="ao-chat-approval">
|
||||
<p>승인 대기 중인 작업이 있습니다</p>
|
||||
<div className="ao-chat-approval-btns">
|
||||
<button className="ao-btn ao-btn--approve"
|
||||
onClick={() => onApproval(agentId, state.taskId, true)}>
|
||||
✅ 승인
|
||||
</button>
|
||||
<button className="ao-btn ao-btn--reject"
|
||||
onClick={() => onApproval(agentId, state.taskId, false)}>
|
||||
❌ 거절
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ao-chat-commands">
|
||||
{commands.map(cmd => (
|
||||
<button key={cmd.action} className="ao-cmd-btn"
|
||||
onClick={() => handleQuickAction(cmd)}>
|
||||
<span>{cmd.icon}</span> {cmd.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeCommand && (
|
||||
<div className="ao-chat-input-area">
|
||||
<input
|
||||
type="text"
|
||||
className="ao-chat-input"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSend()}
|
||||
placeholder={
|
||||
activeCommand === 'compose' ? '프롬프트 입력...'
|
||||
: activeCommand === 'research' ? '키워드 입력...'
|
||||
: '메시지 입력...'
|
||||
}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="ao-btn ao-btn--send" onClick={handleSend}>전송</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.lastResult && (
|
||||
<div className="ao-chat-result">
|
||||
<h4>최근 결과</h4>
|
||||
<pre>{JSON.stringify(state.lastResult, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatPanel;
|
||||
@@ -1,115 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const TARGETS = [
|
||||
{ id: 'stock', name: '주식 트레이더' },
|
||||
{ id: 'music', name: '음악 프로듀서' },
|
||||
{ id: 'blog', name: '블로그 마케터' },
|
||||
{ id: 'realestate', name: '청약 애널리스트' },
|
||||
];
|
||||
|
||||
const TARGET_ICONS = {
|
||||
stock: '📈',
|
||||
music: '🎵',
|
||||
blog: '✍️',
|
||||
realestate: '🏢',
|
||||
};
|
||||
|
||||
const QUICK_COMMANDS = [
|
||||
{ target: 'stock', action: 'fetch_news', label: '뉴스 수집' },
|
||||
{ target: 'stock', action: 'test_telegram', label: 'TG 테스트' },
|
||||
{ target: 'music', action: 'credits', label: '크레딧 확인' },
|
||||
{ target: 'blog', action: 'list_trend_keywords', label: '트렌드 목록' },
|
||||
{ target: 'realestate', action: 'fetch_matches', label: '매칭 리포트' },
|
||||
{ target: 'realestate', action: 'dashboard', label: '청약 대시보드' },
|
||||
];
|
||||
|
||||
const CommandColumn = ({ agents, onCommand }) => {
|
||||
const [target, setTarget] = useState('stock');
|
||||
const [action, setAction] = useState('');
|
||||
const [params, setParams] = useState('');
|
||||
const [history, setHistory] = useState([]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!action.trim()) return;
|
||||
let parsedParams = {};
|
||||
if (params.trim()) {
|
||||
try { parsedParams = JSON.parse(params); }
|
||||
catch { parsedParams = { message: params }; }
|
||||
}
|
||||
onCommand(target, action, parsedParams);
|
||||
setHistory(prev => [{
|
||||
time: new Date().toLocaleTimeString(),
|
||||
target,
|
||||
action,
|
||||
params: parsedParams,
|
||||
}, ...prev].slice(0, 20));
|
||||
setAction('');
|
||||
setParams('');
|
||||
};
|
||||
|
||||
const handleQuick = (cmd) => {
|
||||
onCommand(cmd.target, cmd.action, {});
|
||||
setHistory(prev => [{
|
||||
time: new Date().toLocaleTimeString(),
|
||||
target: cmd.target,
|
||||
action: cmd.action,
|
||||
params: {},
|
||||
}, ...prev].slice(0, 20));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ao-col ao-col--command">
|
||||
<div className="ao-col-header" style={{ borderColor: '#8b5cf6' }}>
|
||||
<span className="ao-col-name" style={{ color: '#8b5cf6' }}>CEO 명령</span>
|
||||
</div>
|
||||
|
||||
<div className="ao-cmd-form">
|
||||
<div className="ao-cmd-row">
|
||||
<select className="ao-cmd-select" value={target} onChange={e => setTarget(e.target.value)}>
|
||||
{TARGETS.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
className="ao-chat-input"
|
||||
value={action}
|
||||
onChange={e => setAction(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSend()}
|
||||
placeholder="명령어 (fetch_news, compose...)"
|
||||
/>
|
||||
<input
|
||||
className="ao-chat-input"
|
||||
value={params}
|
||||
onChange={e => setParams(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSend()}
|
||||
placeholder="파라미터 (JSON 또는 텍스트)"
|
||||
/>
|
||||
<button className="ao-btn ao-btn--send ao-cmd-send" onClick={handleSend}>전송</button>
|
||||
</div>
|
||||
|
||||
<div className="ao-col-commands">
|
||||
{QUICK_COMMANDS.map((cmd, i) => (
|
||||
<button key={i} className="ao-cmd-btn" onClick={() => handleQuick(cmd)}>
|
||||
{TARGET_ICONS[cmd.target] || '🤖'} {cmd.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ao-col-tasks">
|
||||
<div className="ao-col-tasks-title">명령 이력</div>
|
||||
{history.length === 0 && <div className="ao-col-empty">이력 없음</div>}
|
||||
{history.map((h, i) => (
|
||||
<div key={i} className="ao-col-task">
|
||||
<div className="ao-col-task-row">
|
||||
<span className="ao-col-task-type">{h.target}.{h.action}</span>
|
||||
<span className="ao-col-task-time">{h.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommandColumn;
|
||||
164
src/pages/agent-office/components/CommandTab.jsx
Normal file
164
src/pages/agent-office/components/CommandTab.jsx
Normal file
@@ -0,0 +1,164 @@
|
||||
// src/pages/agent-office/components/CommandTab.jsx
|
||||
import { useState } from 'react';
|
||||
import { sendAgentCommand, approveAgentTask } from '../../../api';
|
||||
|
||||
const QUICK_ACTIONS = {
|
||||
stock: [{ action: 'fetch_news', label: 'Fetch News' }, { action: 'test_telegram', label: 'Test Telegram' }],
|
||||
music: [{ action: 'credits', label: 'Check Credits' }],
|
||||
blog: [{ action: 'list_trend_keywords', label: 'List Keywords' }],
|
||||
realestate: [{ action: 'dashboard', label: 'Dashboard' }],
|
||||
lotto: [{ action: 'status', label: 'Status' }, { action: 'curate_now', label: 'Curate Now' }]
|
||||
};
|
||||
|
||||
const PARAM_ACTIONS = {
|
||||
stock: { action: 'add_alert', label: 'Add Alert', placeholder: '{"symbol":"005930","target_price":70000,"direction":"above"}' },
|
||||
music: { action: 'compose', label: 'Compose', placeholder: 'jazzy lo-fi piano beat' },
|
||||
blog: { action: 'research', label: 'Research', placeholder: 'keyword to research' },
|
||||
realestate: { action: 'fetch_matches', label: 'Fetch Matches', placeholder: '' },
|
||||
lotto: null
|
||||
};
|
||||
|
||||
export default function CommandTab({ agentId, agentState, pendingTask, onCommandResult }) {
|
||||
const [customAction, setCustomAction] = useState('');
|
||||
const [customParams, setCustomParams] = useState('');
|
||||
const [paramInput, setParamInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const quickActions = QUICK_ACTIONS[agentId] || [];
|
||||
const paramAction = PARAM_ACTIONS[agentId];
|
||||
|
||||
const handleQuickAction = async (action) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await sendAgentCommand(agentId, action, {});
|
||||
onCommandResult?.(result);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleParamAction = async () => {
|
||||
if (!paramAction || !paramInput.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
let params = {};
|
||||
if (paramAction.action === 'compose') {
|
||||
params = { prompt: paramInput };
|
||||
} else if (paramAction.action === 'research') {
|
||||
params = { keyword: paramInput };
|
||||
} else {
|
||||
try { params = JSON.parse(paramInput); } catch { params = { value: paramInput }; }
|
||||
}
|
||||
const result = await sendAgentCommand(agentId, paramAction.action, params);
|
||||
onCommandResult?.(result);
|
||||
setParamInput('');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomCommand = async () => {
|
||||
if (!customAction.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
let params = {};
|
||||
if (customParams.trim()) {
|
||||
try { params = JSON.parse(customParams); } catch { params = { value: customParams }; }
|
||||
}
|
||||
const result = await sendAgentCommand(agentId, customAction, params);
|
||||
onCommandResult?.(result);
|
||||
setCustomAction('');
|
||||
setCustomParams('');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproval = async (approved) => {
|
||||
if (!pendingTask) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await approveAgentTask(agentId, pendingTask.id, approved);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ao-command-tab">
|
||||
{/* 승인 대기 UI */}
|
||||
{agentState === 'waiting' && pendingTask && (
|
||||
<div className="ao-approval-card">
|
||||
<div className="ao-approval-title">Awaiting Approval</div>
|
||||
<div className="ao-approval-desc">{pendingTask.task_type}: {pendingTask.detail || JSON.stringify(pendingTask.input_data)}</div>
|
||||
<div className="ao-approval-actions">
|
||||
<button className="ao-btn-approve" onClick={() => handleApproval(true)} disabled={loading}>Approve</button>
|
||||
<button className="ao-btn-reject" onClick={() => handleApproval(false)} disabled={loading}>Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="ao-section">
|
||||
<div className="ao-section-label">Quick Actions</div>
|
||||
<div className="ao-quick-actions">
|
||||
{quickActions.map(qa => (
|
||||
<button
|
||||
key={qa.action}
|
||||
className="ao-btn-quick"
|
||||
onClick={() => handleQuickAction(qa.action)}
|
||||
disabled={loading}
|
||||
>
|
||||
{qa.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parameterized Action */}
|
||||
{paramAction && (
|
||||
<div className="ao-section">
|
||||
<div className="ao-section-label">{paramAction.label}</div>
|
||||
<div className="ao-param-row">
|
||||
<input
|
||||
className="ao-input"
|
||||
value={paramInput}
|
||||
onChange={e => setParamInput(e.target.value)}
|
||||
placeholder={paramAction.placeholder}
|
||||
onKeyDown={e => e.key === 'Enter' && handleParamAction()}
|
||||
/>
|
||||
<button className="ao-btn-send" onClick={handleParamAction} disabled={loading || !paramInput.trim()}>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Command */}
|
||||
<div className="ao-section">
|
||||
<div className="ao-section-label">Custom Command</div>
|
||||
<input
|
||||
className="ao-input"
|
||||
value={customAction}
|
||||
onChange={e => setCustomAction(e.target.value)}
|
||||
placeholder="Action name"
|
||||
/>
|
||||
<input
|
||||
className="ao-input"
|
||||
value={customParams}
|
||||
onChange={e => setCustomParams(e.target.value)}
|
||||
placeholder='Parameters (JSON)'
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
<button
|
||||
className="ao-btn-send"
|
||||
onClick={handleCustomCommand}
|
||||
disabled={loading || !customAction.trim()}
|
||||
style={{ marginTop: 4, width: '100%' }}
|
||||
>
|
||||
Send Command
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { getActivityFeed, getAgentTasks, getAgentLogs } from '../../../api';
|
||||
|
||||
const STATUS_BADGE = {
|
||||
pending: { label: '대기', color: '#fbbf24' },
|
||||
approved: { label: '승인됨', color: '#60a5fa' },
|
||||
working: { label: '진행중', color: '#818cf8' },
|
||||
succeeded: { label: '완료', color: '#34d399' },
|
||||
failed: { label: '실패', color: '#f87171' },
|
||||
rejected: { label: '거절됨', color: '#fb923c' },
|
||||
};
|
||||
|
||||
const LOG_LEVEL_COLOR = {
|
||||
info: '#60a5fa',
|
||||
warning: '#fbbf24',
|
||||
error: '#f87171',
|
||||
};
|
||||
|
||||
const DocumentPanel = ({ onClose }) => {
|
||||
const [tab, setTab] = useState('feed');
|
||||
const [feed, setFeed] = useState([]);
|
||||
const [feedLoading, setFeedLoading] = useState(false);
|
||||
|
||||
const [selectedAgent, setSelectedAgent] = useState('stock');
|
||||
const [detailTab, setDetailTab] = useState('tasks');
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const loadFeed = useCallback(() => {
|
||||
setFeedLoading(true);
|
||||
getActivityFeed(80)
|
||||
.then(data => setFeed(data.items || []))
|
||||
.catch(() => setFeed([]))
|
||||
.finally(() => setFeedLoading(false));
|
||||
}, []);
|
||||
|
||||
const loadDetail = useCallback(() => {
|
||||
setDetailLoading(true);
|
||||
Promise.all([
|
||||
getAgentTasks(selectedAgent, 30).then(d => d.tasks || []).catch(() => []),
|
||||
getAgentLogs(selectedAgent, 50).then(d => d.logs || []).catch(() => []),
|
||||
]).then(([t, l]) => {
|
||||
setTasks(t);
|
||||
setLogs(l);
|
||||
}).finally(() => setDetailLoading(false));
|
||||
}, [selectedAgent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'feed') loadFeed();
|
||||
else loadDetail();
|
||||
}, [tab, loadFeed, loadDetail]);
|
||||
|
||||
const formatTime = (t) => t ? t.replace('T', ' ').slice(0, 19) : '';
|
||||
|
||||
return (
|
||||
<div className="ao-doc-panel">
|
||||
<div className="ao-doc-header">
|
||||
<span className="ao-doc-title">CEO 보고서</span>
|
||||
<button className="ao-chat-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="ao-doc-tabs">
|
||||
<button
|
||||
className={`ao-doc-tab ${tab === 'feed' ? 'ao-doc-tab--active' : ''}`}
|
||||
onClick={() => setTab('feed')}
|
||||
>활동 피드</button>
|
||||
<button
|
||||
className={`ao-doc-tab ${tab === 'detail' ? 'ao-doc-tab--active' : ''}`}
|
||||
onClick={() => setTab('detail')}
|
||||
>에이전트별</button>
|
||||
</div>
|
||||
|
||||
{tab === 'feed' && (
|
||||
<div className="ao-doc-feed">
|
||||
<div className="ao-doc-feed-toolbar">
|
||||
<button className="ao-cmd-btn" onClick={loadFeed}>새로고침</button>
|
||||
</div>
|
||||
{feedLoading && <p className="ao-history-empty">로딩 중...</p>}
|
||||
{!feedLoading && feed.length === 0 && <p className="ao-history-empty">활동 없음</p>}
|
||||
{feed.map((item, i) => (
|
||||
<div key={i} className="ao-doc-feed-item">
|
||||
<div className="ao-doc-feed-row">
|
||||
<span className={`ao-doc-agent-tag ao-doc-agent-tag--${item.agent_id}`}>
|
||||
{item.agent_id}
|
||||
</span>
|
||||
{item.type === 'task' ? (
|
||||
<span className="ao-history-badge" style={{ background: (STATUS_BADGE[item.status] || STATUS_BADGE.pending).color }}>
|
||||
{(STATUS_BADGE[item.status] || STATUS_BADGE.pending).label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="ao-doc-log-level" style={{ color: LOG_LEVEL_COLOR[item.level] || '#888' }}>
|
||||
[{item.level}]
|
||||
</span>
|
||||
)}
|
||||
{item.telegram_sent !== undefined && (
|
||||
<span className="ao-doc-tg-status">{item.telegram_sent ? 'TG OK' : 'TG Fail'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ao-doc-feed-msg">{item.message}</div>
|
||||
<div className="ao-doc-feed-time">
|
||||
{formatTime(item.created_at)}
|
||||
{item.duration_seconds != null && ` · ${item.duration_seconds}s`}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'detail' && (
|
||||
<div className="ao-doc-detail">
|
||||
<div className="ao-doc-agent-select">
|
||||
{[
|
||||
{ id: 'stock', name: '주식 트레이더' },
|
||||
{ id: 'music', name: '음악 프로듀서' },
|
||||
{ id: 'blog', name: '블로그 마케터' },
|
||||
{ id: 'realestate', name: '청약 애널리스트' },
|
||||
].map(a => (
|
||||
<button key={a.id}
|
||||
className={`ao-doc-tab ${selectedAgent === a.id ? 'ao-doc-tab--active' : ''}`}
|
||||
onClick={() => setSelectedAgent(a.id)}
|
||||
>{a.name}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ao-doc-detail-tabs">
|
||||
<button
|
||||
className={`ao-doc-tab ${detailTab === 'tasks' ? 'ao-doc-tab--active' : ''}`}
|
||||
onClick={() => setDetailTab('tasks')}
|
||||
>작업 ({tasks.length})</button>
|
||||
<button
|
||||
className={`ao-doc-tab ${detailTab === 'logs' ? 'ao-doc-tab--active' : ''}`}
|
||||
onClick={() => setDetailTab('logs')}
|
||||
>로그 ({logs.length})</button>
|
||||
<button className="ao-cmd-btn" onClick={loadDetail} style={{marginLeft:'auto'}}>새로고침</button>
|
||||
</div>
|
||||
|
||||
{detailLoading && <p className="ao-history-empty">로딩 중...</p>}
|
||||
|
||||
{!detailLoading && detailTab === 'tasks' && (
|
||||
<div className="ao-history-list">
|
||||
{tasks.length === 0 && <p className="ao-history-empty">이력 없음</p>}
|
||||
{tasks.map(task => {
|
||||
const badge = STATUS_BADGE[task.status] || STATUS_BADGE.pending;
|
||||
return (
|
||||
<div key={task.id} className="ao-history-item">
|
||||
<div className="ao-history-item-header">
|
||||
<span className="ao-history-type">{task.task_type}</span>
|
||||
<span className="ao-history-badge" style={{ background: badge.color }}>
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ao-history-time">
|
||||
{formatTime(task.created_at)}
|
||||
{task.completed_at && ` → ${formatTime(task.completed_at)}`}
|
||||
</div>
|
||||
{task.result_data && (
|
||||
<details className="ao-history-detail">
|
||||
<summary>
|
||||
결과 보기
|
||||
{task.result_data.telegram_sent !== undefined && (
|
||||
<span className="ao-doc-tg-status">
|
||||
{task.result_data.telegram_sent ? ' TG OK' : ' TG Fail'}
|
||||
</span>
|
||||
)}
|
||||
</summary>
|
||||
<pre>{JSON.stringify(task.result_data, null, 2)}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!detailLoading && detailTab === 'logs' && (
|
||||
<div className="ao-history-list">
|
||||
{logs.length === 0 && <p className="ao-history-empty">로그 없음</p>}
|
||||
{logs.map(log => (
|
||||
<div key={log.id} className="ao-doc-log-item">
|
||||
<span className="ao-doc-log-level" style={{ color: LOG_LEVEL_COLOR[log.level] || '#888' }}>
|
||||
[{log.level}]
|
||||
</span>
|
||||
<span className="ao-doc-log-msg">{log.message}</span>
|
||||
<span className="ao-doc-feed-time">{formatTime(log.created_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentPanel;
|
||||
45
src/pages/agent-office/components/LogTab.jsx
Normal file
45
src/pages/agent-office/components/LogTab.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
// src/pages/agent-office/components/LogTab.jsx
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { getAgentLogs } from '../../../api';
|
||||
|
||||
const LEVEL_STYLE = {
|
||||
info: { color: '#60a5fa' },
|
||||
warning: { color: '#fbbf24' },
|
||||
error: { color: '#ef4444' }
|
||||
};
|
||||
|
||||
export default function LogTab({ agentId, refreshTrigger }) {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAgentLogs(agentId, 50).then(data => {
|
||||
if (!cancelled) setLogs(data || []);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [agentId, refreshTrigger]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
return (
|
||||
<div className="ao-log-tab" ref={scrollRef}>
|
||||
{logs.length === 0 && <div className="ao-empty">No logs yet</div>}
|
||||
{logs.map((log, i) => {
|
||||
const style = LEVEL_STYLE[log.level] || LEVEL_STYLE.info;
|
||||
const time = new Date(log.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
return (
|
||||
<div key={log.id || i} className="ao-log-item">
|
||||
<span className="ao-log-time">{time}</span>
|
||||
<span className="ao-log-level" style={style}>[{log.level}]</span>
|
||||
<span className="ao-log-msg">{log.message}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/pages/agent-office/components/SidePanel.jsx
Normal file
73
src/pages/agent-office/components/SidePanel.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
// src/pages/agent-office/components/SidePanel.jsx
|
||||
import { useState } from 'react';
|
||||
import CommandTab from './CommandTab.jsx';
|
||||
import TaskTab from './TaskTab.jsx';
|
||||
import TokenTab from './TokenTab.jsx';
|
||||
import LogTab from './LogTab.jsx';
|
||||
|
||||
const AGENT_META = {
|
||||
stock: { displayName: '주식 트레이더', emoji: '📈', color: '#4488cc' },
|
||||
music: { displayName: '음악 프로듀서', emoji: '🎵', color: '#44aa88' },
|
||||
blog: { displayName: '블로그 마케터', emoji: '✍️', color: '#d97706' },
|
||||
realestate: { displayName: '청약 애널리스트', emoji: '🏢', color: '#c026d3' },
|
||||
lotto: { displayName: '로또 큐레이터', emoji: '🎱', color: '#ef4444' }
|
||||
};
|
||||
|
||||
const TABS = ['Commands', 'Tasks', 'Tokens', 'Logs'];
|
||||
|
||||
export default function SidePanel({ agentId, agentState, pendingTask, onClose, refreshTrigger }) {
|
||||
const [activeTab, setActiveTab] = useState('Commands');
|
||||
const meta = AGENT_META[agentId];
|
||||
if (!meta) return null;
|
||||
|
||||
const stateText = agentState?.detail
|
||||
? `${agentState.state} - ${agentState.detail}`
|
||||
: agentState?.state || 'unknown';
|
||||
|
||||
return (
|
||||
<div className="ao-sidepanel">
|
||||
{/* Header */}
|
||||
<div className="ao-sidepanel-header">
|
||||
<div className="ao-sidepanel-agent">
|
||||
<div className="ao-sidepanel-icon" style={{ background: meta.color }}>
|
||||
{meta.emoji}
|
||||
</div>
|
||||
<div className="ao-sidepanel-info">
|
||||
<div className="ao-sidepanel-name">{meta.displayName}</div>
|
||||
<div className="ao-sidepanel-state">● {stateText}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="ao-sidepanel-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="ao-sidepanel-tabs">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`ao-sidepanel-tab ${activeTab === tab ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="ao-sidepanel-content">
|
||||
{activeTab === 'Commands' && (
|
||||
<CommandTab agentId={agentId} agentState={agentState?.state} pendingTask={pendingTask} />
|
||||
)}
|
||||
{activeTab === 'Tasks' && (
|
||||
<TaskTab agentId={agentId} refreshTrigger={refreshTrigger} />
|
||||
)}
|
||||
{activeTab === 'Tokens' && (
|
||||
<TokenTab agentId={agentId} />
|
||||
)}
|
||||
{activeTab === 'Logs' && (
|
||||
<LogTab agentId={agentId} refreshTrigger={refreshTrigger} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/pages/agent-office/components/TaskTab.jsx
Normal file
60
src/pages/agent-office/components/TaskTab.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
// src/pages/agent-office/components/TaskTab.jsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getAgentTasks } from '../../../api';
|
||||
|
||||
const STATUS_STYLE = {
|
||||
succeeded: { bg: '#065f46', fg: '#34d399' },
|
||||
failed: { bg: '#7f1d1d', fg: '#fca5a5' },
|
||||
working: { bg: '#1e3a5f', fg: '#60a5fa' },
|
||||
pending: { bg: '#92400e', fg: '#fbbf24' },
|
||||
approved: { bg: '#065f46', fg: '#34d399' },
|
||||
rejected: { bg: '#7f1d1d', fg: '#fca5a5' }
|
||||
};
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
const time = d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
|
||||
return isToday ? time : `${d.getMonth() + 1}/${d.getDate()} ${time}`;
|
||||
}
|
||||
|
||||
export default function TaskTab({ agentId, refreshTrigger }) {
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [expanded, setExpanded] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAgentTasks(agentId, 20).then(data => {
|
||||
if (!cancelled) setTasks(data || []);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [agentId, refreshTrigger]);
|
||||
|
||||
return (
|
||||
<div className="ao-task-tab">
|
||||
{tasks.length === 0 && <div className="ao-empty">No tasks yet</div>}
|
||||
{tasks.map(task => {
|
||||
const style = STATUS_STYLE[task.status] || STATUS_STYLE.pending;
|
||||
return (
|
||||
<div key={task.id} className="ao-task-item" onClick={() => setExpanded(expanded === task.id ? null : task.id)}>
|
||||
<div className="ao-task-header">
|
||||
<span className="ao-task-type">{task.task_type}</span>
|
||||
<span className="ao-task-badge" style={{ background: style.bg, color: style.fg }}>{task.status}</span>
|
||||
<span className="ao-task-time">{formatTime(task.created_at)}</span>
|
||||
</div>
|
||||
{expanded === task.id && task.result_data && (
|
||||
<pre className="ao-task-result">
|
||||
{(() => {
|
||||
try { return JSON.stringify(JSON.parse(task.result_data), null, 2); }
|
||||
catch { return task.result_data; }
|
||||
})()}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
src/pages/agent-office/components/TokenTab.jsx
Normal file
86
src/pages/agent-office/components/TokenTab.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
// src/pages/agent-office/components/TokenTab.jsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getAgentTokenUsage } from '../../../api';
|
||||
|
||||
export default function TokenTab({ agentId }) {
|
||||
const [usage, setUsage] = useState(null);
|
||||
const [days, setDays] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAgentTokenUsage(agentId, days).then(data => {
|
||||
if (!cancelled) setUsage(data);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [agentId, days]);
|
||||
|
||||
if (!usage) return <div className="ao-empty">Loading...</div>;
|
||||
|
||||
const inputTokens = usage.input_tokens || 0;
|
||||
const outputTokens = usage.output_tokens || 0;
|
||||
const cacheRead = usage.cache_read || 0;
|
||||
const cacheWrite = usage.cache_write || 0;
|
||||
const total = inputTokens + outputTokens;
|
||||
const cacheHitRate = inputTokens > 0 ? Math.round((cacheRead / inputTokens) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="ao-token-tab">
|
||||
<div className="ao-token-period">
|
||||
{[1, 7, 30].map(d => (
|
||||
<button
|
||||
key={d}
|
||||
className={`ao-btn-period ${days === d ? 'active' : ''}`}
|
||||
onClick={() => setDays(d)}
|
||||
>
|
||||
{d === 1 ? 'Today' : d === 7 ? '7 Days' : '30 Days'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ao-token-grid">
|
||||
<div className="ao-token-card">
|
||||
<div className="ao-token-label">Input Tokens</div>
|
||||
<div className="ao-token-value">{inputTokens.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="ao-token-card">
|
||||
<div className="ao-token-label">Output Tokens</div>
|
||||
<div className="ao-token-value">{outputTokens.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="ao-token-card">
|
||||
<div className="ao-token-label">Total</div>
|
||||
<div className="ao-token-value">{total.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="ao-token-card">
|
||||
<div className="ao-token-label">Cache Hit Rate</div>
|
||||
<div className="ao-token-value">{cacheHitRate}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Simple bar chart */}
|
||||
<div className="ao-token-bar">
|
||||
<div className="ao-token-bar-label">Input vs Output</div>
|
||||
<div className="ao-token-bar-track">
|
||||
<div
|
||||
className="ao-token-bar-fill input"
|
||||
style={{ width: total > 0 ? `${(inputTokens / total) * 100}%` : '0%' }}
|
||||
/>
|
||||
<div
|
||||
className="ao-token-bar-fill output"
|
||||
style={{ width: total > 0 ? `${(outputTokens / total) * 100}%` : '0%' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="ao-token-bar-legend">
|
||||
<span><span className="dot input" />Input</span>
|
||||
<span><span className="dot output" />Output</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cacheRead > 0 && (
|
||||
<div className="ao-token-detail">
|
||||
<span>Cache Read: {cacheRead.toLocaleString()}</span>
|
||||
<span>Cache Write: {cacheWrite.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/pages/agent-office/components/TopBar.jsx
Normal file
33
src/pages/agent-office/components/TopBar.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
// src/pages/agent-office/components/TopBar.jsx
|
||||
import { getThemeNames } from '../canvas/themes.js';
|
||||
|
||||
export default function TopBar({ connected, theme, onThemeChange, zoom, onZoomChange }) {
|
||||
const themes = getThemeNames();
|
||||
|
||||
return (
|
||||
<div className="ao-topbar">
|
||||
<div className="ao-topbar-left">
|
||||
<span className="ao-topbar-title">Agent Office</span>
|
||||
<span className={`ao-topbar-status ${connected ? 'connected' : 'disconnected'}`}>
|
||||
● {connected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ao-topbar-right">
|
||||
<select
|
||||
className="ao-topbar-select"
|
||||
value={theme}
|
||||
onChange={(e) => onThemeChange(e.target.value)}
|
||||
>
|
||||
{themes.map(t => (
|
||||
<option key={t.key} value={t.key}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="ao-topbar-zoom">
|
||||
<button onClick={() => onZoomChange(Math.max(1, zoom - 0.5))} disabled={zoom <= 1}>-</button>
|
||||
<span>{zoom}x</span>
|
||||
<button onClick={() => onZoomChange(Math.min(4, zoom + 0.5))} disabled={zoom >= 4}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +1,84 @@
|
||||
// src/pages/agent-office/hooks/useAgentManager.js
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
const WS_RECONNECT_DELAY = 3000;
|
||||
|
||||
export function useAgentManager() {
|
||||
const [agents, setAgents] = useState({});
|
||||
const [pendingTasks, setPendingTasks] = useState([]);
|
||||
const [agents, setAgents] = useState({}); // { agentId: { state, detail, task_id } }
|
||||
const [pendingTasks, setPendingTasks] = useState([]); // [{id, agent_id, task_type, input_data}]
|
||||
const [notifications, setNotifications] = useState({}); // { agentId: count }
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [notifications, setNotifications] = useState({});
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0); // 탭 데이터 리프레시용
|
||||
|
||||
const wsRef = useRef(null);
|
||||
const reconnectTimer = useRef(null);
|
||||
const reconnectRef = useRef(null);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/agent-office/ws`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${protocol}://${window.location.host}/api/agent-office/ws`);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnected(true);
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
};
|
||||
ws.onopen = () => setConnected(true);
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
reconnectTimer.current = setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = () => { ws.close(); };
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'init': {
|
||||
// 에이전트 초기 상태 세팅
|
||||
const agentMap = {};
|
||||
for (const a of msg.agents) {
|
||||
agentMap[a.agent_id] = { state: a.state, detail: a.detail };
|
||||
agentMap[a.agent_id] = { state: a.state, detail: a.detail || '', task_id: a.task_id };
|
||||
}
|
||||
setAgents(agentMap);
|
||||
setPendingTasks(msg.pending || []);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'agent_state':
|
||||
setAgents(prev => ({
|
||||
...prev,
|
||||
[msg.agent]: { state: msg.state, detail: msg.detail, taskId: msg.task_id },
|
||||
[msg.agent]: { state: msg.state, detail: msg.detail || '', task_id: msg.task_id }
|
||||
}));
|
||||
// idle 전환 시 데이터 리프레시
|
||||
if (msg.state === 'idle') {
|
||||
setRefreshTrigger(n => n + 1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'task_complete':
|
||||
setAgents(prev => ({
|
||||
...prev,
|
||||
[msg.agent]: { ...prev[msg.agent], lastResult: msg.result },
|
||||
}));
|
||||
setPendingTasks(prev => prev.filter(id => id !== msg.task_id));
|
||||
break;
|
||||
case 'command_result':
|
||||
setAgents(prev => ({
|
||||
...prev,
|
||||
[msg.agent]: { ...prev[msg.agent], lastCommand: msg.result },
|
||||
}));
|
||||
setRefreshTrigger(n => n + 1);
|
||||
break;
|
||||
|
||||
case 'notification':
|
||||
setNotifications(prev => ({
|
||||
...prev,
|
||||
[msg.agent]: (prev[msg.agent] || 0) + 1,
|
||||
[msg.agent]: (prev[msg.agent] || 0) + 1
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'command_result':
|
||||
// 사이드 패널에서 처리
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
reconnectRef.current = setTimeout(connect, WS_RECONNECT_DELAY);
|
||||
};
|
||||
|
||||
ws.onerror = () => ws.close();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
if (wsRef.current) wsRef.current.close();
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
if (reconnectRef.current) clearTimeout(reconnectRef.current);
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
@@ -92,12 +95,17 @@ export function useAgentManager() {
|
||||
}, []);
|
||||
|
||||
const clearNotifications = useCallback((agentId) => {
|
||||
setNotifications(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[agentId];
|
||||
return next;
|
||||
});
|
||||
setNotifications(prev => ({ ...prev, [agentId]: 0 }));
|
||||
}, []);
|
||||
|
||||
return { agents, pendingTasks, connected, notifications, sendCommand, sendApproval, clearNotifications };
|
||||
return {
|
||||
agents,
|
||||
pendingTasks,
|
||||
notifications,
|
||||
connected,
|
||||
refreshTrigger,
|
||||
sendCommand,
|
||||
sendApproval,
|
||||
clearNotifications
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,74 +1,64 @@
|
||||
// src/pages/agent-office/hooks/useOfficeCanvas.js
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import { OfficeRenderer } from '../canvas/OfficeRenderer';
|
||||
import officeMap from '../assets/office-map.json';
|
||||
import { OfficeRenderer } from '../canvas/OfficeRenderer.js';
|
||||
|
||||
export function useOfficeCanvas(containerRef, onAgentClick, onCeoClick) {
|
||||
export function useOfficeCanvas() {
|
||||
const canvasRef = useRef(null);
|
||||
const rendererRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.imageRendering = 'pixelated';
|
||||
containerRef.current.appendChild(canvas);
|
||||
|
||||
const renderer = new OfficeRenderer(canvas, officeMap);
|
||||
const renderer = new OfficeRenderer(canvasRef.current);
|
||||
rendererRef.current = renderer;
|
||||
|
||||
const resize = () => {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
renderer.resize(rect.width, rect.height);
|
||||
};
|
||||
|
||||
resize();
|
||||
renderer.start();
|
||||
|
||||
renderer.setOnClick((agentId) => {
|
||||
if (onAgentClick) onAgentClick(agentId);
|
||||
});
|
||||
|
||||
renderer.setOnCeoClick(() => {
|
||||
if (onCeoClick) onCeoClick();
|
||||
});
|
||||
|
||||
const handleClick = (e) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
renderer.handleClick(x, y);
|
||||
};
|
||||
|
||||
canvas.addEventListener('click', handleClick);
|
||||
window.addEventListener('resize', resize);
|
||||
const handleResize = () => renderer.resize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
renderer.stop();
|
||||
canvas.removeEventListener('click', handleClick);
|
||||
window.removeEventListener('resize', resize);
|
||||
if (containerRef.current && canvas.parentNode === containerRef.current) {
|
||||
containerRef.current.removeChild(canvas);
|
||||
}
|
||||
window.removeEventListener('resize', handleResize);
|
||||
renderer.destroy();
|
||||
rendererRef.current = null;
|
||||
};
|
||||
}, [containerRef, onAgentClick, onCeoClick]);
|
||||
}, []);
|
||||
|
||||
const updateAgentState = useCallback((agentId, state, detail) => {
|
||||
rendererRef.current?.updateAgentState(agentId, state, detail);
|
||||
}, []);
|
||||
|
||||
const moveAgent = useCallback((agentId, target) => {
|
||||
rendererRef.current?.moveAgent(agentId, target);
|
||||
}, []);
|
||||
|
||||
const setAgentNotification = useCallback((agentId, count) => {
|
||||
rendererRef.current?.setAgentNotification(agentId, count);
|
||||
}, []);
|
||||
|
||||
const setCeoDocBadge = useCallback((count) => {
|
||||
rendererRef.current?.setCeoDocBadge(count);
|
||||
const setTheme = useCallback((themeName) => {
|
||||
rendererRef.current?.setTheme(themeName);
|
||||
}, []);
|
||||
|
||||
return { updateAgentState, moveAgent, setAgentNotification, setCeoDocBadge };
|
||||
const setZoom = useCallback((level) => {
|
||||
rendererRef.current?.setZoom(level);
|
||||
}, []);
|
||||
|
||||
const hitTest = useCallback((clientX, clientY) => {
|
||||
return rendererRef.current?.hitTest(clientX, clientY) || { type: 'empty' };
|
||||
}, []);
|
||||
|
||||
const getZoom = useCallback(() => {
|
||||
return rendererRef.current?.zoom || 2;
|
||||
}, []);
|
||||
|
||||
const wasDragging = useCallback(() => {
|
||||
return rendererRef.current?.wasDragging?.() || false;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
canvasRef,
|
||||
updateAgentState,
|
||||
setAgentNotification,
|
||||
setTheme,
|
||||
setZoom,
|
||||
hitTest,
|
||||
getZoom,
|
||||
wasDragging
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ const Home = () => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [todosByStatus, setTodosByStatus] = useState({ todo: [], in_progress: [], done: [] });
|
||||
const [portfolio, setPortfolio] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/profile/public')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.catch(() => null)
|
||||
.then(d => setPortfolio(d));
|
||||
}, []);
|
||||
|
||||
const loadTodos = useCallback(async () => {
|
||||
const data = await getTodos();
|
||||
@@ -222,47 +230,30 @@ const Home = () => {
|
||||
<div className="home-profile__identity">
|
||||
<img
|
||||
className="home-profile__avatar"
|
||||
src={myPhoto}
|
||||
src={portfolio?.profile?.photo_url || myPhoto}
|
||||
alt="Profile"
|
||||
/>
|
||||
<div>
|
||||
<p className="home-profile__role">Server Developer</p>
|
||||
<p className="home-profile__name">박 재 오</p>
|
||||
<p className="home-profile__role">{portfolio?.profile?.role || 'Server Developer'}</p>
|
||||
<p className="home-profile__name">{portfolio?.profile?.name || '박 재 오'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="home-profile__bio">
|
||||
주변 동료와 함께 소통하며 성장하는걸 좋아합니다. <br />
|
||||
성능 최적화, 인프라 자동화를 중요하게 생각합니다. <br />
|
||||
여행과 사진, 새로운 기술 탐구를 좋아합니다.
|
||||
{portfolio?.profile?.bio || '주변 동료와 함께 소통하며 성장하는걸 좋아합니다.'}
|
||||
</p>
|
||||
<div className="home-profile__timeline">
|
||||
<p className="home-profile__section-title">연혁</p>
|
||||
<ul>
|
||||
<li>
|
||||
<span className="timeline-period">2023.02 - 현재</span>
|
||||
<strong>Server Developer</strong>
|
||||
<span>내비 TIS 교통 서버 / 현대오토에버</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="timeline-period">2020.01 - 2023.02</span>
|
||||
<strong>Embedded Device SW Developer</strong>
|
||||
<span>캐시비 단말기 개발 / 롯데정보통신</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="timeline-period">2019.07 - 2019.12</span>
|
||||
<strong>SSAFY - 삼성 SW Academy</strong>
|
||||
<span>SSAFY 1기 수료</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="home-profile__tags">
|
||||
{['C++', 'Git', 'AWS', 'Jira', 'MySQL', 'Docker', 'Kubernetes', 'Linux'].map((tag) => (
|
||||
{(portfolio?.skills || []).slice(0, 8).map((s) => (
|
||||
<span key={s.id || s.name}>{s.name}</span>
|
||||
))}
|
||||
{!portfolio && ['C++', 'Git', 'AWS', 'Jira', 'MySQL', 'Docker', 'Kubernetes', 'Linux'].map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="home-profile__actions">
|
||||
<button className="button ghost">프로필 수정</button>
|
||||
<a className="button primary" href="mailto:bgg8988@gmail.com">
|
||||
<Link className="button ghost" to="/portfolio">
|
||||
포트폴리오 보기
|
||||
</Link>
|
||||
<a className="button primary" href={`mailto:${portfolio?.profile?.email || 'bgg8988@gmail.com'}`}>
|
||||
연락하기
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import SwipeableView from '../../components/SwipeableView';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'briefing', label: '🗓 이번 주 브리핑' },
|
||||
{ id: 'analysis', label: '📊 분석·통계' },
|
||||
{ id: 'analysis', label: '📚 자료실 / Deep Dive' },
|
||||
{ id: 'purchase', label: '💰 구매·성과' },
|
||||
];
|
||||
|
||||
|
||||
@@ -1020,7 +1020,7 @@
|
||||
|
||||
.lotto-purchase-list__head {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 100px 100px 100px minmax(0, 1fr) 120px;
|
||||
grid-template-columns: 60px 100px 100px 100px minmax(0, 160px) minmax(0, 1fr) 120px;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 11px;
|
||||
@@ -1033,7 +1033,7 @@
|
||||
|
||||
.lotto-purchase-row {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 100px 100px 100px minmax(0, 1fr) 120px;
|
||||
grid-template-columns: 60px 100px 100px 100px minmax(0, 160px) minmax(0, 1fr) 120px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
@@ -1068,6 +1068,21 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.lotto-purchase-row__hits {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hit-badge { display: inline-block; min-width: 16px; padding: 1px 4px; margin-right: 2px;
|
||||
font-size: 10px; border-radius: 4px; background: rgba(255,255,255,0.06); text-align: center; }
|
||||
.hit-badge.hit-3 { background: rgba(80, 200, 120, 0.2); color: #76e09a; }
|
||||
.hit-badge.hit-4 { background: rgba(255, 200, 80, 0.25); color: #ffce6e; font-weight: 700; }
|
||||
.hit-badge.hit-5, .hit-badge.hit-6 { background: rgba(255, 100, 130, 0.3); color: #ff8aa0; font-weight: 700; }
|
||||
.prize-flag { font-size: 10px; color: #ff8aa0; margin-left: 6px; }
|
||||
|
||||
.is-pos { color: #97c9aa; }
|
||||
.is-neg { color: #f7a8a5; }
|
||||
.is-prize { color: #fdd4b1; }
|
||||
@@ -1098,8 +1113,8 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lotto-purchase-list__head span:nth-child(n+3):nth-child(-n+5),
|
||||
.lotto-purchase-row span:nth-child(n+3):nth-child(-n+5) {
|
||||
.lotto-purchase-list__head span:nth-child(n+3):nth-child(-n+6),
|
||||
.lotto-purchase-row span:nth-child(n+3):nth-child(-n+6) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1143,7 +1158,7 @@
|
||||
|
||||
.lotto-purchase-list__head,
|
||||
.lotto-purchase-row {
|
||||
grid-template-columns: 56px 90px 90px minmax(0, 1fr) 100px;
|
||||
grid-template-columns: 56px 90px 90px minmax(0, 120px) minmax(0, 1fr) 100px;
|
||||
}
|
||||
|
||||
.lotto-purchase-list__head span:nth-child(4),
|
||||
@@ -1526,3 +1541,14 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.lotto-section-fold { margin-bottom: 14px; }
|
||||
.lotto-section-fold > summary { cursor: pointer; padding: 12px 16px; background: rgba(255,255,255,0.03);
|
||||
border-radius: 10px; font-weight: 600; font-size: 14px; opacity: 0.85; }
|
||||
.lotto-section-fold[open] > summary { margin-bottom: 12px; opacity: 1; }
|
||||
|
||||
.trend-chart { display: block; margin: 0 auto; }
|
||||
.trend-legend { display: flex; gap: 16px; justify-content: center; font-size: 11px; opacity: 0.7; margin-top: 8px; }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
|
||||
.dot--curator { background: #b8a8ff; }
|
||||
.dot--user { background: #76e09a; }
|
||||
|
||||
@@ -137,6 +137,7 @@ const PurchasePanel = ({
|
||||
<span>투자금</span>
|
||||
<span>당첨금</span>
|
||||
<span>손익</span>
|
||||
<span>채점</span>
|
||||
<span>메모</span>
|
||||
<span />
|
||||
</div>
|
||||
@@ -152,6 +153,14 @@ const PurchasePanel = ({
|
||||
<span className={net >= 0 ? 'is-pos' : 'is-neg'}>
|
||||
{net >= 0 ? '+' : ''}{fmtWon(net)}
|
||||
</span>
|
||||
<span className="lotto-purchase-row__hits">
|
||||
{(rec.results || []).map((r, i) => (
|
||||
<span key={i} className={`hit-badge hit-${r.correct}`}>{r.correct}</span>
|
||||
))}
|
||||
{(rec.results || []).some((r) => r.correct >= 4) && (
|
||||
<span className="prize-flag">🚨 4등↑ 확인 필요</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="lotto-purchase-row__note">{rec.note || '-'}</span>
|
||||
<div className="lotto-purchase-row__actions">
|
||||
<button className="button ghost small" onClick={() => onEditStart(rec)}>
|
||||
|
||||
44
src/pages/lotto/components/PurchaseTrendChart.jsx
Normal file
44
src/pages/lotto/components/PurchaseTrendChart.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getReviewHistory } from '../../../api';
|
||||
|
||||
export default function PurchaseTrendChart() {
|
||||
const [reviews, setReviews] = useState([]);
|
||||
useEffect(() => {
|
||||
getReviewHistory(4).then(rs => setReviews(rs.reverse())); // asc
|
||||
}, []);
|
||||
|
||||
if (reviews.length === 0) return null;
|
||||
|
||||
const maxAvg = Math.max(
|
||||
...reviews.flatMap(r => [r.curator_avg_match || 0, r.user_avg_match || 0]),
|
||||
2.5
|
||||
);
|
||||
const w = 320, h = 80, pad = 16;
|
||||
const xs = (i) => pad + (i / Math.max(reviews.length - 1, 1)) * (w - 2 * pad);
|
||||
const ys = (v) => v == null ? null : h - pad - (v / maxAvg) * (h - 2 * pad);
|
||||
|
||||
const line = (key) => reviews
|
||||
.map((r, i) => ({ x: xs(i), y: ys(r[key]) }))
|
||||
.filter(p => p.y != null)
|
||||
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<section className="lotto-panel">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">Trend (last 4 weeks)</p>
|
||||
<h3>너 vs 큐레이터 평균 일치 수</h3>
|
||||
</div>
|
||||
</div>
|
||||
<svg width={w} height={h} className="trend-chart">
|
||||
<path d={line('curator_avg_match')} stroke="#b8a8ff" strokeWidth="2" fill="none" />
|
||||
<path d={line('user_avg_match')} stroke="#76e09a" strokeWidth="2" fill="none" />
|
||||
</svg>
|
||||
<div className="trend-legend">
|
||||
<span><span className="dot dot--curator" /> 큐레이터</span>
|
||||
<span><span className="dot dot--user" /> 너</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
33
src/pages/lotto/components/decision/BulkPurchaseButton.jsx
Normal file
33
src/pages/lotto/components/decision/BulkPurchaseButton.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useState } from 'react';
|
||||
import { bulkPurchase } from '../../../../api';
|
||||
import { MODES } from './TierModeToggle';
|
||||
|
||||
export default function BulkPurchaseButton({ drawNo, tierMode, onSuccess }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const mode = MODES.find(m => m.key === tierMode) || MODES[0];
|
||||
|
||||
const onClick = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await bulkPurchase({
|
||||
draw_no: drawNo,
|
||||
tier_mode: tierMode,
|
||||
sets: mode.sets,
|
||||
amount: mode.amount,
|
||||
});
|
||||
onSuccess?.();
|
||||
alert(`${mode.sets}세트 구매 기록 완료!`);
|
||||
} catch (e) {
|
||||
alert(`구매 기록 실패: ${e?.message || e}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button className="lc-btn lc-btn--prim" onClick={onClick} disabled={busy || !drawNo}>
|
||||
{busy ? '저장 중...' : `이대로 ${mode.sets}세트 구매했음`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
102
src/pages/lotto/components/decision/DecisionCard.jsx
Normal file
102
src/pages/lotto/components/decision/DecisionCard.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import RetrospectiveBox from './RetrospectiveBox';
|
||||
import TierModeToggle, { MODES } from './TierModeToggle';
|
||||
import TierSection from './TierSection';
|
||||
import BulkPurchaseButton from './BulkPurchaseButton';
|
||||
import './decision.css';
|
||||
|
||||
const TIER_CHAIN = {
|
||||
core: ['core'],
|
||||
core_bonus: ['core', 'bonus'],
|
||||
core_bonus_extended: ['core', 'bonus', 'extended'],
|
||||
full: ['core', 'bonus', 'extended', 'pool'],
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'lotto.tier_mode';
|
||||
|
||||
export default function DecisionCard({ briefing, review, onPurchaseSuccess }) {
|
||||
const [tierMode, setTierMode] = useState(() =>
|
||||
localStorage.getItem(STORAGE_KEY) || 'core'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, tierMode);
|
||||
}, [tierMode]);
|
||||
|
||||
const visibleTiers = TIER_CHAIN[tierMode];
|
||||
|
||||
const totalSets = useMemo(
|
||||
() => visibleTiers.reduce((sum, t) => sum + (briefing?.picks?.[t]?.length || 0), 0),
|
||||
[briefing, visibleTiers]
|
||||
);
|
||||
|
||||
// 분배 칩 — 보이는 계층의 risk_tag 합산
|
||||
const balance = useMemo(() => {
|
||||
const acc = { '안정': 0, '균형': 0, '공격': 0 };
|
||||
for (const t of visibleTiers) {
|
||||
for (const p of (briefing?.picks?.[t] || [])) {
|
||||
if (acc[p.risk_tag] !== undefined) acc[p.risk_tag]++;
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, [briefing, visibleTiers]);
|
||||
|
||||
if (!briefing) return null;
|
||||
|
||||
let cursor = 0;
|
||||
|
||||
return (
|
||||
<div className="lc-card">
|
||||
<header className="lc-head">
|
||||
<div>
|
||||
<p className="lc-eyebrow">Curator Briefing · {briefing.draw_no}회</p>
|
||||
<h3 className="lc-title">{briefing.narrative.headline}</h3>
|
||||
</div>
|
||||
<div className="lc-conf">
|
||||
<div className="lc-conf__num">{briefing.confidence}</div>
|
||||
<div className="lc-conf__lbl">CONFIDENCE</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<RetrospectiveBox briefing={briefing} review={review} />
|
||||
|
||||
<p className="lc-headline-3">
|
||||
{(briefing.narrative.summary_3lines || []).join(' · ')}
|
||||
</p>
|
||||
|
||||
<div className="lc-balance">
|
||||
<div className="lc-balance__chips">
|
||||
{balance['안정'] > 0 && <span className="lc-chip lc-chip--stable">안정 ×{balance['안정']}</span>}
|
||||
{balance['균형'] > 0 && <span className="lc-chip lc-chip--balance">균형 ×{balance['균형']}</span>}
|
||||
{balance['공격'] > 0 && <span className="lc-chip lc-chip--aggro">공격 ×{balance['공격']}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TierModeToggle value={tierMode} onChange={setTierMode} />
|
||||
|
||||
{visibleTiers.map(tier => {
|
||||
const picks = briefing.picks?.[tier] || [];
|
||||
const idxBase = cursor;
|
||||
cursor += picks.length;
|
||||
return (
|
||||
<TierSection
|
||||
key={tier}
|
||||
tier={tier}
|
||||
picks={picks}
|
||||
rationale={briefing.tier_rationale?.[tier]}
|
||||
indexBase={idxBase}
|
||||
totalSets={totalSets}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="lc-actions">
|
||||
<BulkPurchaseButton
|
||||
drawNo={briefing.draw_no}
|
||||
tierMode={tierMode}
|
||||
onSuccess={onPurchaseSuccess}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
src/pages/lotto/components/decision/PickCard.jsx
Normal file
19
src/pages/lotto/components/decision/PickCard.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
const ROLE_COLOR = { '안정': 'stable', '균형': 'balance', '공격': 'aggro' };
|
||||
|
||||
export default function PickCard({ pick, index, total }) {
|
||||
const role = pick.risk_tag;
|
||||
return (
|
||||
<div className="lc-set">
|
||||
<div className="lc-set__head">
|
||||
<span className={`lc-set__role lc-set__role--${ROLE_COLOR[role]}`}>● {role}</span>
|
||||
<span className="lc-set__idx">Set {index + 1} / {total}</span>
|
||||
</div>
|
||||
<div className="lc-balls">
|
||||
{pick.numbers.map(n => (
|
||||
<span key={n} className={`ball ball--${Math.ceil(n / 10)}`}>{n}</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="lc-set__reason">{pick.reason}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/pages/lotto/components/decision/RetrospectiveBox.jsx
Normal file
11
src/pages/lotto/components/decision/RetrospectiveBox.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function RetrospectiveBox({ briefing, review }) {
|
||||
const retro = briefing?.narrative?.retrospective;
|
||||
if (!retro) return null;
|
||||
const drawNo = review?.draw_no ?? (briefing?.draw_no ? briefing.draw_no - 1 : null);
|
||||
return (
|
||||
<aside className="lc-retro">
|
||||
<p className="lc-retro__time">▸ 지난 주 {drawNo ? `${drawNo}회` : ''} 회고</p>
|
||||
<p className="lc-retro__body">{retro}</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
28
src/pages/lotto/components/decision/TierModeToggle.jsx
Normal file
28
src/pages/lotto/components/decision/TierModeToggle.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
const MODES = [
|
||||
{ key: 'core', label: '코어', sets: 5, amount: 5000 },
|
||||
{ key: 'core_bonus', label: '+ 보너스', sets: 10, amount: 10000 },
|
||||
{ key: 'core_bonus_extended', label: '+ 확장', sets: 15, amount: 15000 },
|
||||
{ key: 'full', label: '+ 풀', sets: 20, amount: 20000 },
|
||||
];
|
||||
|
||||
export default function TierModeToggle({ value, onChange }) {
|
||||
return (
|
||||
<div className="lc-toggle" role="tablist">
|
||||
{MODES.map((m, i) => (
|
||||
<button
|
||||
key={m.key}
|
||||
role="tab"
|
||||
aria-selected={value === m.key}
|
||||
className={`lc-toggle__chip ${value === m.key ? 'is-active' : ''}`}
|
||||
onClick={() => onChange(m.key)}
|
||||
>
|
||||
<span className="lc-toggle__dots">{'●'.repeat(i + 1) + '○'.repeat(3 - i)}</span>
|
||||
<span className="lc-toggle__lbl">{m.label}</span>
|
||||
<span className="lc-toggle__sub">{m.sets}세트 · {m.amount.toLocaleString()}원</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { MODES };
|
||||
25
src/pages/lotto/components/decision/TierSection.jsx
Normal file
25
src/pages/lotto/components/decision/TierSection.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import PickCard from './PickCard';
|
||||
|
||||
const TIER_TITLE = {
|
||||
core: '코어 (필수, 5세트)',
|
||||
bonus: '보너스 (+5)',
|
||||
extended: '확장 (+5)',
|
||||
pool: '풀 (+5)',
|
||||
};
|
||||
|
||||
export default function TierSection({ tier, picks, rationale, indexBase = 0, totalSets }) {
|
||||
if (!picks?.length) return null;
|
||||
return (
|
||||
<section className={`lc-tier lc-tier--${tier}`}>
|
||||
<header className="lc-tier__head">
|
||||
<h4>{TIER_TITLE[tier]}</h4>
|
||||
{rationale && tier !== 'core' && (
|
||||
<p className="lc-tier__rationale">{rationale}</p>
|
||||
)}
|
||||
</header>
|
||||
{picks.map((p, i) => (
|
||||
<PickCard key={i} pick={p} index={indexBase + i} total={totalSets} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
52
src/pages/lotto/components/decision/decision.css
Normal file
52
src/pages/lotto/components/decision/decision.css
Normal file
@@ -0,0 +1,52 @@
|
||||
.lc-card { max-width: 720px; margin: 0 auto; background: linear-gradient(180deg, #161220 0%, #1a1426 100%);
|
||||
border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 24px; color: #ece6f7; }
|
||||
.lc-head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 14px; }
|
||||
.lc-eyebrow { font-size: 10px; letter-spacing: 2px; opacity: 0.5; text-transform: uppercase; margin: 0 0 4px; }
|
||||
.lc-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
|
||||
.lc-conf { display: flex; flex-direction: column; align-items: flex-end; }
|
||||
.lc-conf__num { font-family: 'Courier New', monospace; font-size: 28px; font-weight: 700; color: #b8a8ff; letter-spacing: -0.04em; }
|
||||
.lc-conf__lbl { font-size: 9px; letter-spacing: 1.5px; opacity: 0.55; }
|
||||
.lc-retro { background: rgba(184, 168, 255, 0.06); border-left: 2px solid rgba(184, 168, 255, 0.4);
|
||||
padding: 10px 14px; margin: 14px 0; border-radius: 4px; }
|
||||
.lc-retro__time { font-size: 9px; letter-spacing: 1.5px; color: #b8a8ff; opacity: 0.7; margin: 0 0 4px; }
|
||||
.lc-retro__body { font-size: 13px; line-height: 1.55; opacity: 0.85; margin: 0; }
|
||||
.lc-headline { font-size: 16px; font-weight: 600; line-height: 1.5; margin: 18px 0 4px; }
|
||||
.lc-headline-3 { font-size: 12px; opacity: 0.65; line-height: 1.55; margin: 0 0 18px; }
|
||||
.lc-balance { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px;
|
||||
background: rgba(255,255,255,0.03); border-radius: 8px; margin-bottom: 16px; font-size: 11px; }
|
||||
.lc-balance__chips { display: flex; gap: 8px; }
|
||||
.lc-chip { padding: 3px 8px; border-radius: 100px; font-weight: 600; font-size: 11px; }
|
||||
.lc-chip--stable { background: rgba(80, 200, 120, 0.15); color: #76e09a; }
|
||||
.lc-chip--balance { background: rgba(255, 200, 80, 0.15); color: #ffce6e; }
|
||||
.lc-chip--aggro { background: rgba(255, 100, 130, 0.15); color: #ff8aa0; }
|
||||
.lc-toggle { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 16px 0; }
|
||||
.lc-toggle__chip { padding: 10px 8px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 10px; color: #ece6f7; cursor: pointer; display: flex; flex-direction: column; gap: 4px; align-items: center; }
|
||||
.lc-toggle__chip.is-active { background: rgba(184, 168, 255, 0.15); border-color: rgba(184, 168, 255, 0.5); }
|
||||
.lc-toggle__dots { letter-spacing: 2px; font-size: 10px; opacity: 0.7; }
|
||||
.lc-toggle__lbl { font-size: 12px; font-weight: 600; }
|
||||
.lc-toggle__sub { font-size: 10px; opacity: 0.55; }
|
||||
.lc-tier { margin-bottom: 14px; }
|
||||
.lc-tier__head { padding: 8px 0; border-top: 1px dashed rgba(255,255,255,0.1); margin-bottom: 8px; }
|
||||
.lc-tier:first-of-type .lc-tier__head { border-top: none; }
|
||||
.lc-tier__head h4 { font-size: 12px; font-weight: 600; margin: 0 0 4px; opacity: 0.75; letter-spacing: 0.5px; }
|
||||
.lc-tier__rationale { font-size: 11px; opacity: 0.55; margin: 0; }
|
||||
.lc-set { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 12px;
|
||||
padding: 14px; margin-bottom: 10px; }
|
||||
.lc-set__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.lc-set__role { font-size: 11px; font-weight: 600; letter-spacing: 0.5px; }
|
||||
.lc-set__role--stable { color: #76e09a; }
|
||||
.lc-set__role--balance { color: #ffce6e; }
|
||||
.lc-set__role--aggro { color: #ff8aa0; }
|
||||
.lc-set__idx { font-size: 10px; opacity: 0.4; }
|
||||
.lc-balls { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.lc-set__reason { font-size: 12px; opacity: 0.7; line-height: 1.45; margin: 0; }
|
||||
.lc-actions { display: flex; gap: 10px; margin-top: 18px; }
|
||||
.lc-btn { padding: 12px 16px; border-radius: 10px; border: none; font-weight: 600; cursor: pointer;
|
||||
font-size: 14px; min-width: 160px; }
|
||||
.lc-btn--prim { background: linear-gradient(135deg, #b8a8ff, #8a78db); color: #14101e; }
|
||||
.lc-btn--prim:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.lc-btn--ghost { background: transparent; border: 1px solid rgba(255,255,255,0.15); color: #ece6f7; }
|
||||
@media (max-width: 480px) {
|
||||
.lc-toggle { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@@ -1,6 +1,18 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { getLatestBriefing, triggerLottoCurate } from '../../../api';
|
||||
|
||||
const normalizePicks = (picks) => {
|
||||
if (Array.isArray(picks)) {
|
||||
return { core: picks, bonus: [], extended: [], pool: [] };
|
||||
}
|
||||
return {
|
||||
core: picks?.core || [],
|
||||
bonus: picks?.bonus || [],
|
||||
extended: picks?.extended || [],
|
||||
pool: picks?.pool || [],
|
||||
};
|
||||
};
|
||||
|
||||
export default function useBriefing() {
|
||||
const [briefing, setBriefing] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -12,7 +24,7 @@ export default function useBriefing() {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const data = await getLatestBriefing();
|
||||
setBriefing(data);
|
||||
setBriefing(data ? { ...data, picks: normalizePicks(data.picks) } : data);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
@@ -33,7 +45,7 @@ export default function useBriefing() {
|
||||
try {
|
||||
const data = await getLatestBriefing();
|
||||
if (data && data.generated_at !== prevGen) {
|
||||
setBriefing(data);
|
||||
setBriefing({ ...data, picks: normalizePicks(data.picks) });
|
||||
setRegenerating(false);
|
||||
clearInterval(pollingRef.current);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
getPurchases, getPurchaseStats, addPurchase, updatePurchase, deletePurchase,
|
||||
bulkPurchase as apiBulkPurchase,
|
||||
} from '../../../api';
|
||||
import { emptyPurchaseForm } from '../lottoUtils';
|
||||
|
||||
@@ -94,6 +95,12 @@ export default function usePurchases() {
|
||||
} catch { refreshPurchases(); }
|
||||
}, [refreshPurchases]);
|
||||
|
||||
const handleBulkPurchase = useCallback(async (params) => {
|
||||
const result = await apiBulkPurchase(params);
|
||||
await refreshPurchases();
|
||||
return result;
|
||||
}, [refreshPurchases]);
|
||||
|
||||
useEffect(() => { refreshPurchases(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return {
|
||||
@@ -101,5 +108,6 @@ export default function usePurchases() {
|
||||
purchaseFormOpen, purchaseForm, purchaseFormSaving, purchaseFormError, purchaseEditId,
|
||||
handlePurchaseFormOpen, handlePurchaseFormClose, handlePurchaseFormChange,
|
||||
handlePurchaseFormSubmit, handlePurchaseEditStart, handlePurchaseDelete,
|
||||
handleBulkPurchase,
|
||||
};
|
||||
}
|
||||
|
||||
23
src/pages/lotto/hooks/useReview.js
Normal file
23
src/pages/lotto/hooks/useReview.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getLatestReview, getReviewHistory } from '../../../api';
|
||||
|
||||
export default function useReview() {
|
||||
const [latest, setLatest] = useState(null);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancel = false;
|
||||
Promise.all([getLatestReview(), getReviewHistory(4)])
|
||||
.then(([l, h]) => {
|
||||
if (cancel) return;
|
||||
setLatest(l);
|
||||
setHistory(h);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => !cancel && setLoading(false));
|
||||
return () => { cancel = true; };
|
||||
}, []);
|
||||
|
||||
return { latest, history, loading };
|
||||
}
|
||||
@@ -40,18 +40,21 @@ export default function AnalysisTab() {
|
||||
<PerformanceBanner perf={ld.perfStats} />
|
||||
|
||||
{/* 종합 추론 번호 추천 */}
|
||||
<CombinedRecommendPanel
|
||||
combined={ld.combined}
|
||||
history={ld.combinedHistory}
|
||||
loading={ld.combinedLoading}
|
||||
histLoading={ld.combinedHistLoading}
|
||||
onRun={ld.runCombinedRecommend}
|
||||
onCopy={copyNumbers}
|
||||
/>
|
||||
<details className="lotto-section-fold">
|
||||
<summary>종합 추론 추천</summary>
|
||||
<CombinedRecommendPanel
|
||||
combined={ld.combined}
|
||||
history={ld.combinedHistory}
|
||||
loading={ld.combinedLoading}
|
||||
histLoading={ld.combinedHistLoading}
|
||||
onRun={ld.runCombinedRecommend}
|
||||
onCopy={copyNumbers}
|
||||
/>
|
||||
</details>
|
||||
|
||||
{/* 최신 회차 + 시뮬레이션 추천 */}
|
||||
<div className="lotto-grid">
|
||||
{/* Latest Draw */}
|
||||
{/* 최신 회차 */}
|
||||
<details className="lotto-section-fold">
|
||||
<summary>최신 회차</summary>
|
||||
<section className="lotto-panel">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
@@ -87,8 +90,11 @@ export default function AnalysisTab() {
|
||||
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
|
||||
)}
|
||||
</section>
|
||||
</details>
|
||||
|
||||
{/* Simulation Picks */}
|
||||
{/* Simulation Picks */}
|
||||
<details className="lotto-section-fold">
|
||||
<summary>시뮬레이션 추천</summary>
|
||||
<section className="lotto-panel">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
@@ -163,19 +169,24 @@ export default function AnalysisTab() {
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* 이번 주 공략 리포트 */}
|
||||
<ReportPanel
|
||||
report={ld.report}
|
||||
history={ld.reportHistory}
|
||||
loading={ld.reportLoading}
|
||||
onRefresh={ld.refreshReport}
|
||||
onSelectDrw={ld.loadSpecificReport}
|
||||
/>
|
||||
<details className="lotto-section-fold">
|
||||
<summary>이번 주 공략 리포트</summary>
|
||||
<ReportPanel
|
||||
report={ld.report}
|
||||
history={ld.reportHistory}
|
||||
loading={ld.reportLoading}
|
||||
onRefresh={ld.refreshReport}
|
||||
onSelectDrw={ld.loadSpecificReport}
|
||||
/>
|
||||
</details>
|
||||
|
||||
{/* 통계 분석 */}
|
||||
<section className="lotto-panel lotto-panel--wide">
|
||||
<details className="lotto-section-fold">
|
||||
<summary>통계 분석</summary>
|
||||
<section className="lotto-panel lotto-panel--wide">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">Analysis</p>
|
||||
@@ -237,9 +248,12 @@ export default function AnalysisTab() {
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</details>
|
||||
|
||||
{/* 전체 번호 분포 */}
|
||||
<section className="lotto-panel lotto-panel--wide">
|
||||
<details className="lotto-section-fold">
|
||||
<summary>전체 회차 번호 분포</summary>
|
||||
<section className="lotto-panel lotto-panel--wide">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">Distribution</p>
|
||||
@@ -263,12 +277,18 @@ export default function AnalysisTab() {
|
||||
<p className="lotto-empty">통계 데이터를 불러오지 못했습니다.</p>
|
||||
)}
|
||||
</section>
|
||||
</details>
|
||||
|
||||
{/* 내 번호 패턴 */}
|
||||
<PersonalAnalysisPanel data={ld.personalAnalysis} loading={ld.personalLoading} />
|
||||
<details className="lotto-section-fold">
|
||||
<summary>내 번호 패턴</summary>
|
||||
<PersonalAnalysisPanel data={ld.personalAnalysis} loading={ld.personalLoading} />
|
||||
</details>
|
||||
|
||||
{/* 수동 추천 */}
|
||||
<section className="lotto-panel">
|
||||
<details className="lotto-section-fold">
|
||||
<summary>수동 추천</summary>
|
||||
<section className="lotto-panel">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">Manual Recommendation</p>
|
||||
@@ -365,9 +385,12 @@ export default function AnalysisTab() {
|
||||
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
|
||||
)}
|
||||
</section>
|
||||
</details>
|
||||
|
||||
{/* 추천 히스토리 */}
|
||||
<section className="lotto-panel">
|
||||
<details className="lotto-section-fold">
|
||||
<summary>추천 히스토리</summary>
|
||||
<section className="lotto-panel">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">History</p>
|
||||
@@ -423,6 +446,7 @@ export default function AnalysisTab() {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import useBriefing from '../hooks/useBriefing';
|
||||
import BriefingHeader from '../components/briefing/BriefingHeader';
|
||||
import BriefingSummary from '../components/briefing/BriefingSummary';
|
||||
import PickSetCard from '../components/briefing/PickSetCard';
|
||||
import useReview from '../hooks/useReview';
|
||||
import DecisionCard from '../components/decision/DecisionCard';
|
||||
import BriefingEmpty from '../components/briefing/BriefingEmpty';
|
||||
import CuratorUsageFooter from '../components/briefing/CuratorUsageFooter';
|
||||
|
||||
export default function BriefingTab() {
|
||||
const { briefing, loading, error, regenerating, regenerate } = useBriefing();
|
||||
const { latest: review } = useReview();
|
||||
|
||||
if (loading) return <div className="briefing-empty"><p>로딩 중...</p></div>;
|
||||
if (!briefing) return <BriefingEmpty regenerating={regenerating} onRegenerate={regenerate} error={error} />;
|
||||
|
||||
return (
|
||||
<div className="briefing-tab">
|
||||
<BriefingHeader briefing={briefing} regenerating={regenerating} onRegenerate={regenerate} />
|
||||
<BriefingSummary narrative={briefing.narrative} />
|
||||
<div className="briefing-picks">
|
||||
<h3>이번 주 5세트</h3>
|
||||
{briefing.picks.map((p, i) => <PickSetCard key={i} pick={p} index={i} />)}
|
||||
</div>
|
||||
<CuratorUsageFooter />
|
||||
<DecisionCard briefing={briefing} review={review} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import usePurchases from '../hooks/usePurchases';
|
||||
import PurchasePanel from '../components/PurchasePanel';
|
||||
import PurchaseTrendChart from '../components/PurchaseTrendChart';
|
||||
|
||||
export default function PurchaseTab() {
|
||||
const pur = usePurchases();
|
||||
|
||||
return (
|
||||
<PurchasePanel
|
||||
records={pur.purchases}
|
||||
stats={pur.purchaseStats}
|
||||
loading={pur.purchaseLoading}
|
||||
formOpen={pur.purchaseFormOpen}
|
||||
form={pur.purchaseForm}
|
||||
formSaving={pur.purchaseFormSaving}
|
||||
formError={pur.purchaseFormError}
|
||||
editId={pur.purchaseEditId}
|
||||
onFormOpen={pur.handlePurchaseFormOpen}
|
||||
onFormClose={pur.handlePurchaseFormClose}
|
||||
onFormChange={pur.handlePurchaseFormChange}
|
||||
onFormSubmit={pur.handlePurchaseFormSubmit}
|
||||
onEditStart={pur.handlePurchaseEditStart}
|
||||
onDelete={pur.handlePurchaseDelete}
|
||||
/>
|
||||
<>
|
||||
<PurchaseTrendChart />
|
||||
<PurchasePanel
|
||||
records={pur.purchases}
|
||||
stats={pur.purchaseStats}
|
||||
loading={pur.purchaseLoading}
|
||||
formOpen={pur.purchaseFormOpen}
|
||||
form={pur.purchaseForm}
|
||||
formSaving={pur.purchaseFormSaving}
|
||||
formError={pur.purchaseFormError}
|
||||
editId={pur.purchaseEditId}
|
||||
onFormOpen={pur.handlePurchaseFormOpen}
|
||||
onFormClose={pur.handlePurchaseFormClose}
|
||||
onFormChange={pur.handlePurchaseFormChange}
|
||||
onFormSubmit={pur.handlePurchaseFormSubmit}
|
||||
onEditStart={pur.handlePurchaseEditStart}
|
||||
onDelete={pur.handlePurchaseDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -932,6 +932,27 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Track title input ── */
|
||||
.ms-title-input-wrap {
|
||||
padding: 0 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ms-title-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
padding: 9px 14px;
|
||||
color: #ccc;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ms-title-input::placeholder { color: #4b5563; }
|
||||
.ms-title-input:focus { outline: none; border-color: var(--ms-accent, #22c55e); }
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
GENERATE BUTTON
|
||||
═══════════════════════════════════════════════════ */
|
||||
@@ -2608,3 +2629,904 @@
|
||||
background: var(--ms-surface); border: 1px solid var(--ms-line);
|
||||
}
|
||||
.ms-remix-submit { align-self: flex-start; margin-top: 8px; }
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
YouTube Tab — yt-* classes
|
||||
══════════════════════════════════════════ */
|
||||
|
||||
.ms-tab--youtube.is-active {
|
||||
color: #f59e0b;
|
||||
border-bottom-color: #f59e0b;
|
||||
}
|
||||
|
||||
.yt-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.yt-subtabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
background: #0d1117;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.yt-subtab {
|
||||
padding: 10px 18px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.yt-subtab:hover { color: #9ca3af; }
|
||||
|
||||
.yt-subtab.is-active {
|
||||
color: #22c55e;
|
||||
border-bottom-color: #22c55e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.yt-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.yt-card {
|
||||
background: #0d1117;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.yt-card--create { border-color: #22c55e33; }
|
||||
.yt-card--export { border-color: #3b82f633; border-style: dashed; }
|
||||
|
||||
.yt-card__title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #ccc;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.yt-card--create .yt-card__title { color: #86efac; }
|
||||
.yt-card--export .yt-card__title { color: #93c5fd; }
|
||||
|
||||
.yt-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.yt-row--bottom { margin-bottom: 0; margin-top: 8px; }
|
||||
|
||||
.yt-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.yt-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.yt-field__label { font-size: 10px; color: #6b7280; }
|
||||
|
||||
.yt-input {
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 6px;
|
||||
padding: 7px 10px;
|
||||
color: #ccc;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.yt-input:focus { outline: none; border-color: #22c55e; }
|
||||
.yt-input--sm { padding: 4px 8px; font-size: 11px; }
|
||||
|
||||
.yt-select {
|
||||
flex: 1;
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.yt-format-toggle { display: flex; gap: 4px; }
|
||||
|
||||
.yt-format-btn {
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.yt-format-btn.is-active {
|
||||
background: #1a2e1a;
|
||||
border-color: #22c55e;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.yt-country-label { font-size: 11px; color: #6b7280; margin-bottom: 6px; }
|
||||
|
||||
.yt-country-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
|
||||
.yt-chip {
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 4px;
|
||||
padding: 3px 10px;
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.yt-chip.is-active {
|
||||
background: #1e3a2a;
|
||||
border-color: #22c55e;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.yt-create-btn { width: 100%; margin-top: 2px; }
|
||||
|
||||
.yt-project-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.yt-project-card {
|
||||
background: #1f2937;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.yt-project-card__icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #111827;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.yt-project-card__info { flex: 1; min-width: 0; }
|
||||
|
||||
.yt-project-card__title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #ccc;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.yt-project-card__meta { font-size: 10px; color: #6b7280; margin-top: 2px; }
|
||||
|
||||
.yt-status {
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.yt-status--pending { background: #1f2937; color: #9ca3af; }
|
||||
.yt-status--rendering { background: #1a1500; color: #f59e0b; }
|
||||
.yt-status--done { background: #0a3d1a; color: #22c55e; }
|
||||
.yt-status--failed { background: #2d0a0a; color: #f87171; }
|
||||
|
||||
.yt-progress-bar {
|
||||
height: 3px;
|
||||
background: #374151;
|
||||
border-radius: 2px;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.yt-progress-bar__fill {
|
||||
height: 100%;
|
||||
width: 65%;
|
||||
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||
border-radius: 2px;
|
||||
animation: yt-progress-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes yt-progress-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.yt-export-links { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
|
||||
.yt-meta-preview { background: #111827; border-radius: 6px; padding: 8px; }
|
||||
.yt-meta-preview__label { font-size: 10px; color: #6b7280; margin-bottom: 4px; }
|
||||
.yt-meta-preview__content {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
font-family: monospace;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.yt-empty {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
padding: 8px 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.yt-dash-cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.yt-dash-card {
|
||||
background: #0d1117;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.yt-dash-card__label { font-size: 10px; color: #6b7280; margin-bottom: 4px; }
|
||||
.yt-dash-card__sub { font-size: 9px; color: #6b7280; margin-top: 2px; }
|
||||
|
||||
.yt-dash-card__value { font-size: 18px; font-weight: 700; }
|
||||
.yt-dash-card__value--green { color: #22c55e; }
|
||||
.yt-dash-card__value--blue { color: #60a5fa; }
|
||||
.yt-dash-card__value--amber { color: #f59e0b; }
|
||||
|
||||
.yt-bar-chart { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.yt-bar-row { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.yt-bar-row__label {
|
||||
width: 80px;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.yt-bar-row__rank {
|
||||
width: 24px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #f59e0b;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.yt-bar-row__info { flex: 1; }
|
||||
|
||||
.yt-bar-row__genre-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.yt-bar-row__genre-name { font-size: 12px; color: #ccc; }
|
||||
.yt-bar-row__flags { font-size: 10px; color: #9ca3af; }
|
||||
|
||||
.yt-bar-row__track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: #1f2937;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.yt-bar-row__fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #22c55e, #4ade80);
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.yt-bar-row__fill--genre { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
|
||||
|
||||
.yt-bar-row__value {
|
||||
width: 44px;
|
||||
font-size: 11px;
|
||||
color: #22c55e;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.yt-table { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.yt-table__header {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr 1fr 28px;
|
||||
gap: 4px;
|
||||
padding: 0 4px 6px;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
font-size: 10px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.yt-table__row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr 1fr 28px;
|
||||
gap: 4px;
|
||||
padding: 7px 4px;
|
||||
border-bottom: 1px solid #111827;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.yt-table__row--editing {
|
||||
background: #111827;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.yt-table__row:last-child { border-bottom: none; }
|
||||
|
||||
.yt-table__cell { font-size: 11px; color: #9ca3af; }
|
||||
.yt-table__cell--mono { font-family: monospace; }
|
||||
.yt-table__cell--green { color: #22c55e; }
|
||||
.yt-table__cell--amber { color: #f59e0b; }
|
||||
|
||||
.yt-table__actions { display: flex; gap: 4px; grid-column: span 2; }
|
||||
|
||||
.yt-status-bar {
|
||||
background: #0d1117;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.yt-status-bar__left { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.yt-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 6px #22c55e;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.yt-status-bar__text { font-size: 11px; color: #9ca3af; }
|
||||
|
||||
.yt-prompt-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.yt-prompt-card {
|
||||
background: #1a0d2e;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.yt-prompt-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.yt-prompt-card__genre { font-size: 11px; font-weight: 700; color: #c084fc; }
|
||||
.yt-prompt-card__countries { font-size: 10px; color: #6b7280; }
|
||||
|
||||
.yt-prompt-card__text {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: #110820;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
color: #e9d5ff;
|
||||
line-height: 1.6;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.yt-prompt-card__text:hover { background: #1a0d30; }
|
||||
|
||||
.yt-prompt-card__copied { font-size: 10px; color: #22c55e; margin-top: 4px; display: block; }
|
||||
.yt-prompt-card__reason { font-size: 10px; color: #6b7280; margin-top: 5px; }
|
||||
|
||||
.yt-report-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
|
||||
.yt-report-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.yt-report-row:hover { background: #1f2937; }
|
||||
.yt-report-row.is-selected { background: #1f2937; }
|
||||
|
||||
.yt-report-row__date { font-size: 11px; color: #ccc; }
|
||||
.yt-report-row__today { font-size: 10px; color: #22c55e; margin-left: 4px; }
|
||||
.yt-report-row__meta { font-size: 10px; color: #9ca3af; }
|
||||
.yt-report-row__action { font-size: 11px; color: #60a5fa; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.yt-dash-cards { grid-template-columns: 1fr 1fr; }
|
||||
.yt-form-grid { grid-template-columns: 1fr; }
|
||||
.yt-table__header,
|
||||
.yt-table__row { grid-template-columns: 2fr 1fr 1fr 28px; }
|
||||
.yt-table__header span:nth-child(4),
|
||||
.yt-table__header span:nth-child(5),
|
||||
.yt-table__row span:nth-child(4),
|
||||
.yt-table__row span:nth-child(5) { display: none; }
|
||||
}
|
||||
|
||||
/* ── Compile subtab ── */
|
||||
.yt-compile-tracklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.yt-compile-track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.yt-compile-track:hover { background: #1f2937; }
|
||||
.yt-compile-track.is-selected { background: #0a2e18; }
|
||||
|
||||
.yt-compile-track__check {
|
||||
width: 16px;
|
||||
font-size: 11px;
|
||||
color: #22c55e;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.yt-compile-track__title {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.yt-compile-track__dur {
|
||||
font-size: 10px;
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.yt-compile-order {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.yt-compile-order__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: #1f2937;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.yt-compile-order__num {
|
||||
width: 20px;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.yt-compile-order__title {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.yt-compile-order__btns {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.yt-compile-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.yt-compile-slider {
|
||||
width: 100%;
|
||||
accent-color: #22c55e;
|
||||
}
|
||||
|
||||
/* === SetupTab === */
|
||||
.setup-container { display:flex; flex-direction:column; gap:16px; padding:16px; }
|
||||
.setup-card {
|
||||
background: rgba(0,0,0,.3);
|
||||
border: 1px solid var(--ms-line, #2a2a3a);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
.setup-card h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
color: var(--ms-text, #f0f0f5);
|
||||
font-family: var(--ms-ff-disp, inherit);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.setup-card label {
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
color: var(--ms-muted, #a0a0b0);
|
||||
}
|
||||
.setup-card input,
|
||||
.setup-card textarea,
|
||||
.setup-card select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin-top: 4px;
|
||||
background: rgba(255,255,255,.04);
|
||||
border: 1px solid var(--ms-line, #2a2a3a);
|
||||
border-radius: 8px;
|
||||
color: var(--ms-text, #f0f0f5);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.setup-card input[type="range"] {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
accent-color: var(--ms-accent, #f5a623);
|
||||
}
|
||||
.setup-card button {
|
||||
padding: 6px 14px;
|
||||
margin-top: 8px;
|
||||
background: rgba(245, 166, 35, 0.15);
|
||||
color: var(--ms-accent, #bae6fd);
|
||||
border: 1px solid rgba(245, 166, 35, 0.4);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.setup-card button:hover {
|
||||
background: rgba(245, 166, 35, 0.25);
|
||||
}
|
||||
.setup-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.setup-checkbox input[type="checkbox"] {
|
||||
width: auto;
|
||||
}
|
||||
.setup-channel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.setup-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.setup-prompt-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 6px 0;
|
||||
align-items: center;
|
||||
}
|
||||
.setup-prompt-genre {
|
||||
width: 80px;
|
||||
font-size: 12px;
|
||||
color: var(--ms-muted, #a0a0b0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.setup-saving {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
background: #222;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,.1);
|
||||
font-size: 12px;
|
||||
color: var(--ms-text, #f0f0f5);
|
||||
z-index: 100;
|
||||
}
|
||||
.ms-loading,
|
||||
.ms-error {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--ms-muted, #a0a0b0);
|
||||
}
|
||||
.ms-error {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* === PipelineTab === */
|
||||
.pipeline-container { padding:16px; }
|
||||
.pipeline-toolbar { display:flex; gap:12px; margin-bottom:16px; align-items:center; }
|
||||
.pipeline-toolbar select { padding:6px 10px; background:rgba(255,255,255,.04);
|
||||
border:1px solid var(--ms-line, #2a2a3a); border-radius:8px; color:var(--ms-text, #f0f0f5); font-size:13px; }
|
||||
.pipeline-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(320px, 1fr)); gap:16px; }
|
||||
.pipeline-card { background:rgba(0,0,0,.3); border:1px solid var(--ms-line, #2a2a3a);
|
||||
border-radius:14px; padding:16px; display:flex; flex-direction:column; gap:8px; }
|
||||
.pipeline-card__head { display:flex; justify-content:space-between; align-items:center; }
|
||||
.pipeline-card__head h4 { margin:0; font-size:14px; color:var(--ms-text, #f0f0f5); }
|
||||
.pipeline-card__head button { padding:4px 10px; background:rgba(248,113,113,.15); color:#fca5a5;
|
||||
border:1px solid rgba(248,113,113,.3); border-radius:6px; cursor:pointer; font-size:11px; }
|
||||
.pipeline-progress { display:flex; gap:6px; margin:8px 0; }
|
||||
.pipeline-dot { flex:1; text-align:center; padding:6px 0; border-radius:8px;
|
||||
background:rgba(255,255,255,.05); font-size:11px; color:var(--ms-muted, #a0a0b0); }
|
||||
.pipeline-dot.is-done { background:rgba(56,189,248,.2); color:#bae6fd; }
|
||||
.pipeline-dot.is-current { box-shadow:0 0 8px rgba(56,189,248,.6); }
|
||||
.pipeline-state { font-size:13px; color:var(--ms-text, #f0f0f5); }
|
||||
.pipeline-review { font-size:12px; color:var(--ms-muted, #a0a0b0); }
|
||||
.pipeline-review strong { color:#bae6fd; }
|
||||
.pipeline-feedback { margin-top:8px; font-size:12px; color:var(--ms-muted, #a0a0b0); }
|
||||
.pipeline-feedback summary { cursor:pointer; }
|
||||
.pipeline-card a { color:#bae6fd; font-size:12px; }
|
||||
.ms-empty { padding:32px; text-align:center; color:var(--ms-muted, #a0a0b0); grid-column:1/-1; }
|
||||
|
||||
/* Modal — shared */
|
||||
.modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,.6);
|
||||
display:flex; align-items:center; justify-content:center; z-index:1000; }
|
||||
.modal-body { background:#1a1a2e; padding:24px; border-radius:14px; min-width:320px;
|
||||
border:1px solid var(--ms-line, #2a2a3a); }
|
||||
.modal-body h3 { margin:0 0 12px; font-size:15px; color:var(--ms-text, #f0f0f5); }
|
||||
.modal-body select { width:100%; padding:8px; background:rgba(255,255,255,.04);
|
||||
border:1px solid var(--ms-line, #2a2a3a); border-radius:8px; color:var(--ms-text, #f0f0f5); font-size:13px; }
|
||||
.modal-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:16px; }
|
||||
.modal-actions button { padding:6px 14px; background:rgba(255,255,255,.05); color:var(--ms-text, #f0f0f5);
|
||||
border:1px solid var(--ms-line, #2a2a3a); border-radius:8px; cursor:pointer; font-size:13px; }
|
||||
.modal-actions .button.primary { background:rgba(56,189,248,.2); color:#bae6fd; border-color:rgba(56,189,248,.4); }
|
||||
|
||||
/* ── CompileTab → Pipeline 영상 만들기 버튼 ─────────────────────── */
|
||||
.cmp-btn-video {
|
||||
padding: 6px 12px;
|
||||
background: rgba(56, 189, 248, 0.15);
|
||||
color: #bae6fd;
|
||||
border: 1px solid rgba(56, 189, 248, 0.4);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.cmp-btn-video:hover { background: rgba(56, 189, 248, 0.25); }
|
||||
|
||||
.psm-input-radio { border: 1px solid var(--ms-line, #2a2a3a); padding: 8px 12px;
|
||||
border-radius: 8px; margin-bottom: 12px; }
|
||||
.psm-input-radio legend { padding: 0 6px; font-size: 11px; color: var(--ms-muted, #a0a0b0); }
|
||||
.psm-input-radio label { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; }
|
||||
.psm-advanced { margin-top: 12px; padding: 8px 0; }
|
||||
.psm-advanced summary { cursor: pointer; font-size: 12px; color: var(--ms-muted, #a0a0b0); user-select: none; }
|
||||
.psm-advanced label { display: block; margin: 8px 0; font-size: 12px; }
|
||||
.psm-advanced input, .psm-advanced select { width: 100%; padding: 6px 8px;
|
||||
background: rgba(255,255,255,.04); border: 1px solid var(--ms-line, #2a2a3a);
|
||||
border-radius: 6px; color: var(--ms-text, #f0f0f5); font-size: 12px; }
|
||||
|
||||
/* === Pipeline Detail Modal === */
|
||||
.modal-body--lg { max-width: 720px; max-height: 90vh; overflow-y: auto; }
|
||||
.pdm-header { display:flex; align-items:center; gap:12px; margin-bottom:16px; }
|
||||
.pdm-header h3 { flex:1; margin:0; }
|
||||
.pdm-badge { padding:2px 8px; background:rgba(56,189,248,.2); color:#bae6fd;
|
||||
border-radius:6px; font-size:11px; }
|
||||
.pdm-close { background:none; border:none; font-size:24px; cursor:pointer;
|
||||
color:var(--ms-muted, #a0a0b0); padding:0 6px; }
|
||||
|
||||
.pdm-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:16px; }
|
||||
.pdm-figure { margin:0; }
|
||||
.pdm-figure img { width:100%; border-radius:8px; display:block; }
|
||||
.pdm-figure figcaption { font-size:11px; color:var(--ms-muted, #a0a0b0); text-align:center; margin-top:4px; }
|
||||
|
||||
.pdm-video { margin-bottom:16px; }
|
||||
.pdm-video video { border-radius:8px; }
|
||||
|
||||
.pdm-section { margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid var(--ms-line, #2a2a3a); }
|
||||
.pdm-section:last-of-type { border-bottom:none; }
|
||||
.pdm-section h4 { margin:0 0 8px; font-size:14px; }
|
||||
.pdm-pre { background:rgba(0,0,0,.3); padding:8px; border-radius:6px; font-size:12px;
|
||||
white-space:pre-wrap; overflow-x:auto; max-height:400px; }
|
||||
|
||||
.pdm-verdict { padding:2px 8px; margin-left:8px; border-radius:6px; font-size:12px; font-weight:bold; }
|
||||
.pdm-verdict--pass { background:rgba(34,197,94,.2); color:#86efac; }
|
||||
.pdm-verdict--fail { background:rgba(248,113,113,.2); color:#fca5a5; }
|
||||
.pdm-score { color:var(--ms-muted, #a0a0b0); font-size:12px; margin-left:8px; font-weight:normal; }
|
||||
.pdm-review-table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
.pdm-review-table td { padding:4px 8px; border-bottom:1px solid var(--ms-line, #2a2a3a); }
|
||||
.pdm-review-table td:nth-child(2) { text-align:right; font-weight:bold; }
|
||||
.pdm-summary { font-size:12px; color:var(--ms-muted, #a0a0b0); margin-top:8px; }
|
||||
|
||||
.pdm-tracks { padding-left:24px; }
|
||||
.pdm-tracks li { margin-bottom:4px; font-size:13px; }
|
||||
.pdm-track-time { color:var(--ms-accent, #38bdf8); font-family:monospace; }
|
||||
.pdm-track-dur { color:var(--ms-muted, #a0a0b0); font-size:11px; }
|
||||
|
||||
.pdm-feedback { padding-left:0; list-style:none; }
|
||||
.pdm-feedback li { padding:6px 8px; background:rgba(0,0,0,.2); border-radius:6px;
|
||||
margin-bottom:4px; font-size:12px; }
|
||||
.pdm-feedback code { color:#fb923c; font-size:11px; }
|
||||
.pdm-feedback small { display:block; color:var(--ms-muted, #a0a0b0); margin-top:2px; }
|
||||
|
||||
.pdm-youtube { display:inline-block; padding:8px 16px; background:#ff0000; color:white;
|
||||
border-radius:8px; text-decoration:none; font-weight:bold; }
|
||||
|
||||
/* PipelineCard mini previews + style badge */
|
||||
.pipeline-previews { display:flex; gap:8px; margin:8px 0; align-items:center; }
|
||||
.pipeline-preview-mini { width:64px; height:64px; object-fit:cover; border-radius:6px;
|
||||
border:1px solid var(--ms-line, #2a2a3a); }
|
||||
.pipeline-video-icon { font-size:24px; color:var(--ms-accent, #38bdf8); margin-left:4px; }
|
||||
.pipeline-style-badge { padding:1px 6px; background:rgba(56,189,248,.15); color:#bae6fd;
|
||||
border-radius:4px; font-size:10px; }
|
||||
.pipeline-card { cursor:pointer; }
|
||||
.pipeline-card:hover { background:rgba(255,255,255,.02); }
|
||||
|
||||
.psm-keyword-main { display: block; margin: 12px 0; font-size: 13px; }
|
||||
.psm-keyword-main input {
|
||||
display: block; width: 100%; margin-top: 4px; padding: 8px;
|
||||
background: rgba(255,255,255,.04);
|
||||
border: 1px solid var(--ms-line, #2a2a3a);
|
||||
border-radius: 8px; color: var(--ms-text, #f0f0f5); font-size: 13px;
|
||||
}
|
||||
.psm-keyword-main small {
|
||||
display: block; color: var(--ms-muted, #a0a0b0); font-size: 11px; margin-top: 4px;
|
||||
}
|
||||
|
||||
/* === Batch Generation Section === */
|
||||
.ms-batch-section {
|
||||
margin: 16px 0;
|
||||
padding: 12px 16px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--ms-line, #2a2a3a);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.ms-batch-section summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--ms-text, #f0f0f5);
|
||||
user-select: none;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.ms-batch-section[open] summary {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ms-batch-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.ms-batch-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--ms-text, #f0f0f5);
|
||||
}
|
||||
.ms-batch-form select,
|
||||
.ms-batch-form input[type="range"] {
|
||||
padding: 6px 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--ms-line, #2a2a3a);
|
||||
border-radius: 6px;
|
||||
color: var(--ms-text, #f0f0f5);
|
||||
}
|
||||
.ms-batch-form input[type="range"] {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.ms-batch-checkbox {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.ms-batch-checkbox input {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
.ms-batch-estimate {
|
||||
font-size: 12px;
|
||||
color: var(--ms-muted, #a0a0b0);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.ms-batch-form button {
|
||||
padding: 8px 16px;
|
||||
background: rgba(56, 189, 248, 0.2);
|
||||
color: #bae6fd;
|
||||
border: 1px solid rgba(56, 189, 248, 0.4);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.ms-batch-form button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ms-batch-progress {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--ms-line, #2a2a3a);
|
||||
}
|
||||
.ms-batch-header {
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ms-batch-status--generating { color: var(--ms-accent, #38bdf8); }
|
||||
.ms-batch-status--compiling { color: #fb923c; }
|
||||
.ms-batch-status--piped { color: #86efac; }
|
||||
.ms-batch-status--failed { color: #fca5a5; }
|
||||
.ms-batch-status--cancelled { color: var(--ms-muted, #a0a0b0); }
|
||||
.ms-batch-tracks {
|
||||
padding-left: 24px;
|
||||
font-size: 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.ms-batch-tracks li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
.ms-batch-tracks li.done {
|
||||
color: #86efac;
|
||||
}
|
||||
.ms-batch-tracks li.current {
|
||||
color: var(--ms-accent, #38bdf8);
|
||||
font-weight: bold;
|
||||
}
|
||||
.ms-batch-tracks li.pending {
|
||||
color: var(--ms-muted, #a0a0b0);
|
||||
}
|
||||
.ms-batch-link {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--ms-muted, #a0a0b0);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
getTimestampedLyrics,
|
||||
generateStyleBoost,
|
||||
generateVideo,
|
||||
startBatchGen,
|
||||
getBatchJob,
|
||||
listGenres,
|
||||
} from '../../api';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
@@ -27,6 +30,8 @@ import LyricsTab from './components/LyricsTab';
|
||||
import StemModal from './components/StemModal';
|
||||
import SyncedLyricsPlayer from './components/SyncedLyricsPlayer';
|
||||
import RemixTab from './components/RemixTab';
|
||||
import YoutubeTab from './components/YoutubeTab';
|
||||
import BatchProgress from './components/BatchProgress';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
데이터 상수
|
||||
@@ -337,7 +342,7 @@ const TrackResult = ({ track, onDownload, onNew }) => {
|
||||
/* ─────────────────────────────────────────────
|
||||
Library Card
|
||||
───────────────────────────────────────────── */
|
||||
const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, isGenerating }) => {
|
||||
const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, onVideoProject, onVideoPipeline, isGenerating }) => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const genre = GENRES.find((g) => g.id === track.genre);
|
||||
const totalSec = track.duration_sec ?? null;
|
||||
@@ -425,6 +430,12 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
|
||||
disabled={isGenerating || !track.lyrics}>📝 Synced Lyrics</button>
|
||||
<button type="button" onClick={() => { onVideoGenerate(track); setMenuOpen(false); }}
|
||||
disabled={isGenerating}>🎬 Music Video</button>
|
||||
<button type="button" onClick={() => { onVideoProject(track); setMenuOpen(false); }}>
|
||||
🎯 YouTube 프로젝트
|
||||
</button>
|
||||
<button type="button" onClick={() => { onVideoPipeline(track); setMenuOpen(false); }}>
|
||||
🎬 영상 파이프라인
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -435,6 +446,10 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
|
||||
<a href={track.audio_url} download className="ms-btn ms-btn--ghost ms-btn--sm">
|
||||
↓ Download
|
||||
</a>
|
||||
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={() => onVideoPipeline(track)}>
|
||||
🎬 영상 파이프라인
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="ms-lib-card__date">
|
||||
@@ -447,7 +462,7 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
|
||||
/* ─────────────────────────────────────────────
|
||||
Library Section
|
||||
───────────────────────────────────────────── */
|
||||
const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, isGenerating, loading }) => {
|
||||
const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, onVideoProject, onVideoPipeline, isGenerating, loading }) => {
|
||||
const [playingId, setPlayingId] = useState(null);
|
||||
|
||||
const handlePlay = (track) => {
|
||||
@@ -501,6 +516,8 @@ const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCove
|
||||
onStemSplit={onStemSplit}
|
||||
onSyncedLyrics={onSyncedLyrics}
|
||||
onVideoGenerate={onVideoGenerate}
|
||||
onVideoProject={onVideoProject}
|
||||
onVideoPipeline={onVideoPipeline}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
))}
|
||||
@@ -515,6 +532,8 @@ const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCove
|
||||
export default function MusicStudio() {
|
||||
/* ── 탭 ── */
|
||||
const [tab, setTab] = useState('create');
|
||||
const [initialTrackId, setInitialTrackId] = useState(null);
|
||||
const [openPipelineFor, setOpenPipelineFor] = useState(null);
|
||||
|
||||
/* ── Provider 상태 ── */
|
||||
const [providers, setProviders] = useState([]);
|
||||
@@ -530,6 +549,7 @@ export default function MusicStudio() {
|
||||
const [musicalKey, setMusicalKey] = useState('C');
|
||||
const [scale, setScale] = useState('Major');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [customTitle, setCustomTitle] = useState('');
|
||||
|
||||
/* ── Suno 전용 상태 ── */
|
||||
const [lyrics, setLyrics] = useState('');
|
||||
@@ -576,6 +596,17 @@ export default function MusicStudio() {
|
||||
const pollRef = useRef(null);
|
||||
const taskIdRef = useRef(null);
|
||||
|
||||
/* ── 배치 생성 상태 ── */
|
||||
const [batchOpen, setBatchOpen] = useState(false);
|
||||
const [batchGenre, setBatchGenre] = useState('lo-fi');
|
||||
const [batchCount, setBatchCount] = useState(10);
|
||||
const [batchDuration, setBatchDuration] = useState(180);
|
||||
const [batchAutoPipe, setBatchAutoPipe] = useState(true);
|
||||
const [currentBatch, setCurrentBatch] = useState(null);
|
||||
const [batchPolling, setBatchPolling] = useState(false);
|
||||
const [batchGenresList, setBatchGenresList] = useState(['lo-fi', 'phonk', 'ambient', 'pop']);
|
||||
const batchPollRef = useRef(null);
|
||||
|
||||
const activeGenre = GENRES.find((g) => g.id === genre);
|
||||
const accentColor = activeGenre?.color ?? '#f5a623';
|
||||
|
||||
@@ -635,6 +666,56 @@ export default function MusicStudio() {
|
||||
/* ── 언마운트 시 폴링 정리 ── */
|
||||
useEffect(() => () => clearInterval(pollRef.current), []);
|
||||
|
||||
/* ── 배치 생성 시작 ── */
|
||||
const startBatch = async () => {
|
||||
try {
|
||||
const res = await startBatchGen({
|
||||
genre: batchGenre,
|
||||
count: batchCount,
|
||||
target_duration_sec: batchDuration,
|
||||
auto_pipeline: batchAutoPipe,
|
||||
});
|
||||
setCurrentBatch(res);
|
||||
setBatchPolling(true);
|
||||
} catch (e) {
|
||||
alert(`배치 시작 실패: ${e.message || e}`);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 배치: 지원 장르 목록 fetch (mount 시 1회) ── */
|
||||
useEffect(() => {
|
||||
listGenres()
|
||||
.then((r) => {
|
||||
if (Array.isArray(r?.genres) && r.genres.length) {
|
||||
setBatchGenresList(r.genres);
|
||||
if (!r.genres.includes(batchGenre)) setBatchGenre(r.genres[0]);
|
||||
}
|
||||
})
|
||||
.catch(() => { /* fallback hardcoded list 유지 */ });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/* ── 배치 폴링 ── */
|
||||
useEffect(() => {
|
||||
if (!batchPolling || !currentBatch?.id) return;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const j = await getBatchJob(currentBatch.id);
|
||||
if (j) {
|
||||
setCurrentBatch(j);
|
||||
if (['piped', 'failed', 'cancelled'].includes(j.status)) {
|
||||
setBatchPolling(false);
|
||||
// library 갱신 (새 트랙들 표시되도록)
|
||||
if (typeof loadLibrary === 'function') loadLibrary();
|
||||
}
|
||||
}
|
||||
} catch { /* swallow */ }
|
||||
};
|
||||
batchPollRef.current = setInterval(tick, 5000);
|
||||
return () => clearInterval(batchPollRef.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [batchPolling, currentBatch?.id]);
|
||||
|
||||
/* ── helpers ── */
|
||||
const toggleMood = (id) =>
|
||||
setMoods((prev) =>
|
||||
@@ -730,7 +811,7 @@ export default function MusicStudio() {
|
||||
|
||||
const durSec = DURATIONS.find((d) => d.id === duration)?.sec ?? 60;
|
||||
const moodLabel = moods[0] ? MOODS.find((m) => m.id === moods[0])?.label : 'Original';
|
||||
const title = `${activeGenre?.label} — ${moodLabel} Mix`;
|
||||
const title = customTitle.trim() || `${activeGenre?.label} — ${moodLabel} Mix`;
|
||||
const instList = instruments.length > 0 ? instruments : ['piano', 'synth', 'bass'];
|
||||
|
||||
const payload = {
|
||||
@@ -1058,10 +1139,21 @@ export default function MusicStudio() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoProject = (track) => {
|
||||
setInitialTrackId(track.id);
|
||||
setTab('youtube');
|
||||
};
|
||||
|
||||
const handleVideoPipeline = (track) => {
|
||||
setOpenPipelineFor(track.id);
|
||||
setTab('youtube');
|
||||
};
|
||||
|
||||
const handleNewTrack = () => {
|
||||
setTrack(null);
|
||||
setGenProgress(0);
|
||||
setGenError(null);
|
||||
setCustomTitle('');
|
||||
clearInterval(pollRef.current);
|
||||
};
|
||||
|
||||
@@ -1121,6 +1213,13 @@ export default function MusicStudio() {
|
||||
>
|
||||
<span className="ms-tab__icon">🔄</span> Remix
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ms-tab ms-tab--youtube ${tab === 'youtube' ? 'is-active' : ''}`}
|
||||
onClick={() => setTab('youtube')}
|
||||
>
|
||||
<span className="ms-tab__icon">🎯</span> YouTube
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* ═══ LIBRARY TAB ═══ */}
|
||||
@@ -1138,6 +1237,8 @@ export default function MusicStudio() {
|
||||
onStemSplit={handleStemSplit}
|
||||
onSyncedLyrics={handleSyncedLyrics}
|
||||
onVideoGenerate={handleVideoGenerate}
|
||||
onVideoProject={handleVideoProject}
|
||||
onVideoPipeline={handleVideoPipeline}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
</PullToRefresh>
|
||||
@@ -1166,6 +1267,16 @@ export default function MusicStudio() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ YOUTUBE TAB ═══ */}
|
||||
{tab === 'youtube' && (
|
||||
<YoutubeTab
|
||||
library={library}
|
||||
initialTrackId={initialTrackId}
|
||||
onClearInitialTrack={() => setInitialTrackId(null)}
|
||||
openPipelineFor={openPipelineFor}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ CREATE TAB ═══ */}
|
||||
{tab === 'create' && (
|
||||
<div className="ms-layout">
|
||||
@@ -1230,6 +1341,44 @@ export default function MusicStudio() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch Generation Section */}
|
||||
<details className="ms-batch-section" open={batchOpen} onToggle={(e) => setBatchOpen(e.currentTarget.open)}>
|
||||
<summary>🎲 배치 생성 (장르 → 1-10트랙 + 자동 영상)</summary>
|
||||
<div className="ms-batch-form">
|
||||
<label>장르
|
||||
<select value={batchGenre} onChange={e => setBatchGenre(e.target.value)}>
|
||||
{batchGenresList.map(g => (
|
||||
<option key={g} value={g}>
|
||||
{g.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('-')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>트랙 수: <strong>{batchCount}</strong>
|
||||
<input type="range" min={1} max={10} value={batchCount}
|
||||
onChange={e => setBatchCount(parseInt(e.target.value))} />
|
||||
</label>
|
||||
<label>트랙당 길이: <strong>{batchDuration}초</strong>
|
||||
<input type="range" min={60} max={300} step={10} value={batchDuration}
|
||||
onChange={e => setBatchDuration(parseInt(e.target.value))} />
|
||||
</label>
|
||||
<label className="ms-batch-checkbox">
|
||||
<input type="checkbox" checked={batchAutoPipe}
|
||||
onChange={e => setBatchAutoPipe(e.target.checked)} />
|
||||
모든 트랙 생성 후 자동 영상 파이프라인 시작
|
||||
</label>
|
||||
<p className="ms-batch-estimate">
|
||||
예상: 약 {Math.ceil(batchCount * 1.5)}–{batchCount * 2}분 ·
|
||||
{' '}비용 ~${(batchCount * 0.005 + (batchAutoPipe ? 0.05 : 0)).toFixed(2)}
|
||||
</p>
|
||||
<button className="button primary" onClick={startBatch}
|
||||
disabled={batchPolling}>
|
||||
🎵 배치 생성 시작
|
||||
</button>
|
||||
</div>
|
||||
{currentBatch && <BatchProgress batch={currentBatch} />}
|
||||
</details>
|
||||
|
||||
{/* Step 1: Genre */}
|
||||
<section className="ms-section">
|
||||
<div className="ms-section__head">
|
||||
@@ -1661,6 +1810,20 @@ export default function MusicStudio() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Track title input */}
|
||||
{!track && (
|
||||
<div className="ms-title-input-wrap">
|
||||
<input
|
||||
type="text"
|
||||
className="ms-title-input"
|
||||
placeholder="트랙 제목 (비워두면 자동 생성)"
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
maxLength={80}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate button */}
|
||||
{!track && (
|
||||
<button
|
||||
|
||||
48
src/pages/music/components/BatchProgress.jsx
Normal file
48
src/pages/music/components/BatchProgress.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
const STATUS_LABELS = {
|
||||
queued: '대기 중',
|
||||
generating: '음악 생성 중',
|
||||
generated: '음악 완료, 컴파일 대기',
|
||||
compiling: '컴파일 중',
|
||||
piped: '영상 파이프라인 시작됨 — YouTube 탭 진행 탭에서 확인',
|
||||
failed: '실패',
|
||||
cancelled: '취소',
|
||||
};
|
||||
|
||||
export default function BatchProgress({ batch }) {
|
||||
if (!batch) return null;
|
||||
const trackList = Array.from({ length: batch.count }, (_, i) => i + 1);
|
||||
return (
|
||||
<div className="ms-batch-progress">
|
||||
<div className="ms-batch-header">
|
||||
배치 #{batch.id} — <strong>{batch.genre}</strong> ·{' '}
|
||||
{batch.completed}/{batch.count} 완료 ·{' '}
|
||||
상태: <strong className={`ms-batch-status ms-batch-status--${batch.status}`}>
|
||||
{STATUS_LABELS[batch.status] || batch.status}
|
||||
</strong>
|
||||
</div>
|
||||
{batch.error && <div className="ms-error">에러: {batch.error}</div>}
|
||||
<ol className="ms-batch-tracks">
|
||||
{trackList.map(n => {
|
||||
const completed = n <= batch.completed;
|
||||
const current = n === batch.current_track_index && batch.status === 'generating';
|
||||
const tr = (batch.tracks || [])[n - 1];
|
||||
return (
|
||||
<li key={n} className={completed ? 'done' : current ? 'current' : 'pending'}>
|
||||
{completed ? '✓' : current ? '⏳' : '○'}
|
||||
{' '}Track {n}: {tr?.title || (current ? '생성 중...' : '대기')}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{batch.compile_job_id && (
|
||||
<div className="ms-batch-link">📀 컴파일 #{batch.compile_job_id}</div>
|
||||
)}
|
||||
{batch.pipeline_id && (
|
||||
<div className="ms-batch-link">
|
||||
🎬 영상 파이프라인 #{batch.pipeline_id} —{' '}
|
||||
<em>YouTube 탭 → 진행 탭에서 확인</em>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
281
src/pages/music/components/CompileTab.jsx
Normal file
281
src/pages/music/components/CompileTab.jsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
createCompileJob, getCompileJobs, deleteCompileJob, exportCompileJob,
|
||||
createPipeline, startPipeline,
|
||||
} from '../../../api';
|
||||
|
||||
const fmtDuration = (sec) => {
|
||||
if (!sec) return '';
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.round(sec % 60);
|
||||
return `${m}분 ${s}초`;
|
||||
};
|
||||
|
||||
export default function CompileTab({ library, onSwitchToPipeline }) {
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [selected, setSelected] = useState([]); // [{id, title, audio_url}] in order
|
||||
const [crossfade, setCrossfade] = useState(3);
|
||||
const [title, setTitle] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [exportData, setExportData] = useState(null); // {mp4_url, duration_sec, title}
|
||||
const [exportingId, setExportingId] = useState(null);
|
||||
const pollRef = useRef(null);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
const res = await getCompileJobs().catch(() => ({ jobs: [] }));
|
||||
setJobs(Array.isArray(res.jobs) ? res.jobs : []);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadJobs(); }, [loadJobs]);
|
||||
|
||||
// Poll while any job is rendering
|
||||
useEffect(() => {
|
||||
const hasRendering = jobs.some(j => j.status === 'rendering');
|
||||
if (hasRendering && !pollRef.current) {
|
||||
pollRef.current = setInterval(loadJobs, 5000);
|
||||
} else if (!hasRendering && pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
return () => { clearInterval(pollRef.current); pollRef.current = null; };
|
||||
}, [jobs, loadJobs]);
|
||||
|
||||
const toggleTrack = (track) => {
|
||||
setSelected(prev => {
|
||||
const exists = prev.find(t => t.id === track.id);
|
||||
if (exists) return prev.filter(t => t.id !== track.id);
|
||||
return [...prev, { id: track.id, title: track.title, audio_url: track.audio_url }];
|
||||
});
|
||||
};
|
||||
|
||||
const moveUp = (idx) => setSelected(prev => { const a = [...prev]; [a[idx-1], a[idx]] = [a[idx], a[idx-1]]; return a; });
|
||||
const moveDown = (idx) => setSelected(prev => { const a = [...prev]; [a[idx], a[idx+1]] = [a[idx+1], a[idx]]; return a; });
|
||||
const remove = (idx) => setSelected(prev => prev.filter((_, i) => i !== idx));
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (selected.length < 2 || creating) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await createCompileJob({
|
||||
title: title.trim() || `컴파일 ${new Date().toLocaleDateString('ko-KR')}`,
|
||||
track_ids: selected.map(t => t.id),
|
||||
crossfade_sec: crossfade,
|
||||
});
|
||||
setSelected([]);
|
||||
setTitle('');
|
||||
await loadJobs();
|
||||
} catch (e) {
|
||||
console.error('createCompileJob:', e);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (jobId) => {
|
||||
setExportingId(jobId);
|
||||
try {
|
||||
const data = await exportCompileJob(jobId);
|
||||
setExportData(data);
|
||||
} catch (e) {
|
||||
console.error('exportCompileJob:', e);
|
||||
} finally {
|
||||
setExportingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (jobId) => {
|
||||
if (!window.confirm('컴파일 영상을 삭제할까요?')) return;
|
||||
await deleteCompileJob(jobId).catch(() => {});
|
||||
setJobs(prev => prev.filter(j => j.id !== jobId));
|
||||
if (exportData && exportData.id === jobId) setExportData(null);
|
||||
};
|
||||
|
||||
const handleVideoFromCompile = async (jobId) => {
|
||||
if (!window.confirm('이 mix로 영상 파이프라인을 시작할까요?')) return;
|
||||
try {
|
||||
const p = await createPipeline({ compile_job_id: jobId });
|
||||
await startPipeline(p.id);
|
||||
if (onSwitchToPipeline) {
|
||||
onSwitchToPipeline(p.id);
|
||||
}
|
||||
} catch (e) {
|
||||
alert(`파이프라인 시작 실패: ${e.message || e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const totalMin = selected.length > 0
|
||||
? Math.round(selected.reduce((acc, t) => {
|
||||
const match = library.find(l => l.id === t.id);
|
||||
return acc + (match?.duration_sec ?? 180);
|
||||
}, 0) / 60)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="yt-content">
|
||||
{/* 트랙 선택 패널 */}
|
||||
<div className="yt-card yt-card--create">
|
||||
<h3 className="yt-card__title">🎵 트랙 선택 (2개 이상)</h3>
|
||||
{library.length === 0 ? (
|
||||
<p className="yt-empty">라이브러리에 트랙이 없습니다</p>
|
||||
) : (
|
||||
<div className="yt-compile-tracklist">
|
||||
{library.map(t => {
|
||||
const isSelected = !!selected.find(s => s.id === t.id);
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`yt-compile-track ${isSelected ? 'is-selected' : ''}`}
|
||||
onClick={() => toggleTrack(t)}
|
||||
>
|
||||
<span className="yt-compile-track__check">{isSelected ? '✓' : ''}</span>
|
||||
<span className="yt-compile-track__title">{t.title}</span>
|
||||
{t.duration_sec && (
|
||||
<span className="yt-compile-track__dur">{fmtDuration(t.duration_sec)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 순서 조정 + 설정 */}
|
||||
{selected.length > 0 && (
|
||||
<div className="yt-card">
|
||||
<h3 className="yt-card__title">
|
||||
📋 선택된 트랙 순서 ({selected.length}개
|
||||
{totalMin > 0 ? ` · 약 ${totalMin}분` : ''})
|
||||
</h3>
|
||||
<div className="yt-compile-order">
|
||||
{selected.map((t, i) => (
|
||||
<div key={t.id} className="yt-compile-order__row">
|
||||
<span className="yt-compile-order__num">{i + 1}</span>
|
||||
<span className="yt-compile-order__title">{t.title}</span>
|
||||
<div className="yt-compile-order__btns">
|
||||
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={() => moveUp(i)} disabled={i === 0}>↑</button>
|
||||
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={() => moveDown(i)} disabled={i === selected.length - 1}>↓</button>
|
||||
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={() => remove(i)}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 설정 */}
|
||||
<div className="yt-compile-settings">
|
||||
<div className="yt-field">
|
||||
<label className="yt-field__label">크로스페이드 {crossfade}초</label>
|
||||
<input type="range" min="1" max="10" step="0.5"
|
||||
value={crossfade}
|
||||
onChange={e => setCrossfade(Number(e.target.value))}
|
||||
className="yt-compile-slider"
|
||||
/>
|
||||
</div>
|
||||
<div className="yt-field">
|
||||
<label className="yt-field__label">컴파일 제목 (선택)</label>
|
||||
<input type="text" className="yt-input"
|
||||
placeholder={`컴파일 ${new Date().toLocaleDateString('ko-KR')}`}
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
maxLength={80}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn ms-btn--primary yt-create-btn"
|
||||
onClick={handleCreate}
|
||||
disabled={selected.length < 2 || creating}
|
||||
>
|
||||
{creating ? '생성 중...' : `🎬 컴파일 생성 (${selected.length}곡)`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컴파일 작업 목록 */}
|
||||
{jobs.length > 0 && (
|
||||
<div className="yt-card">
|
||||
<h3 className="yt-card__title">컴파일 작업</h3>
|
||||
<div className="yt-project-list">
|
||||
{jobs.map(j => (
|
||||
<div key={j.id} className="yt-project-card">
|
||||
<div className="yt-project-card__icon">🎵</div>
|
||||
<div className="yt-project-card__info">
|
||||
<div className="yt-project-card__title">{j.title || `컴파일 #${j.id}`}</div>
|
||||
<div className="yt-project-card__meta">
|
||||
{j.track_ids?.length ?? 0}곡
|
||||
{j.duration_sec ? ` · ${fmtDuration(j.duration_sec)}` : ''}
|
||||
{' · '}크로스페이드 {j.crossfade_sec}초
|
||||
</div>
|
||||
</div>
|
||||
{j.status === 'rendering' && (
|
||||
<>
|
||||
<span className="yt-status yt-status--rendering">처리중</span>
|
||||
<div className="yt-progress-bar" style={{position:'absolute',bottom:0,left:0,right:0}}>
|
||||
<div className="yt-progress-bar__fill" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{j.status === 'done' && (
|
||||
<>
|
||||
<span className="yt-status yt-status--done">✓ 완료</span>
|
||||
<button type="button"
|
||||
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={() => handleExport(j.id)}
|
||||
disabled={exportingId === j.id}
|
||||
>
|
||||
{exportingId === j.id ? '...' : '↓ 내보내기'}
|
||||
</button>
|
||||
<button type="button"
|
||||
className="cmp-btn-video"
|
||||
onClick={() => handleVideoFromCompile(j.id)}
|
||||
>
|
||||
🎬 영상 만들기
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{j.status === 'failed' && (
|
||||
<span className="yt-status yt-status--failed">실패</span>
|
||||
)}
|
||||
{j.status === 'pending' && (
|
||||
<span className="yt-status yt-status--pending">대기</span>
|
||||
)}
|
||||
<button type="button"
|
||||
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={() => handleDelete(j.id)}
|
||||
style={{marginLeft: 4}}
|
||||
>🗑</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 내보내기 패키지 */}
|
||||
{exportData && (
|
||||
<div className="yt-card yt-card--export">
|
||||
<h3 className="yt-card__title">↓ 내보내기</h3>
|
||||
<div className="yt-export-links">
|
||||
<a href={exportData.mp4_url} download
|
||||
className="ms-btn ms-btn--primary ms-btn--sm">
|
||||
↓ MP4 다운로드
|
||||
</a>
|
||||
</div>
|
||||
<div className="yt-meta-preview">
|
||||
<div className="yt-meta-preview__label">파일 정보</div>
|
||||
<pre className="yt-meta-preview__content">
|
||||
{JSON.stringify({
|
||||
title: exportData.title,
|
||||
duration: fmtDuration(exportData.duration_sec),
|
||||
mp4_url: exportData.mp4_url,
|
||||
}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
src/pages/music/components/PipelineCard.jsx
Normal file
90
src/pages/music/components/PipelineCard.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { cancelPipeline, publishPipeline } from '../../../api';
|
||||
import PipelineDetailModal from './PipelineDetailModal';
|
||||
|
||||
const STEP_LABELS = ['커버','영상','썸네','메타','검토','발행'];
|
||||
|
||||
function stepIndex(state) {
|
||||
if (!state) return -1;
|
||||
if (state.startsWith('cover')) return 0;
|
||||
if (state.startsWith('video')) return 1;
|
||||
if (state.startsWith('thumb')) return 2;
|
||||
if (state.startsWith('meta')) return 3;
|
||||
if (state.startsWith('ai_review') || state === 'publish_pending') return 4;
|
||||
if (state.startsWith('publish')) return 5;
|
||||
if (state === 'published') return 6;
|
||||
return -1;
|
||||
}
|
||||
|
||||
export default function PipelineCard({ pipeline, onChanged }) {
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const i = stepIndex(pipeline.state);
|
||||
const title = pipeline.compile_title || pipeline.track_title || `Pipeline #${pipeline.id}`;
|
||||
|
||||
const handleCardClick = (e) => {
|
||||
if (e.target.closest('button') || e.target.closest('a')) return;
|
||||
setShowDetail(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pipeline-card" onClick={handleCardClick}>
|
||||
<div className="pipeline-card__head">
|
||||
<h4>{title}</h4>
|
||||
{pipeline.visual_style && (
|
||||
<span className="pipeline-style-badge">{pipeline.visual_style}</span>
|
||||
)}
|
||||
{!['published','cancelled','failed'].includes(pipeline.state) && (
|
||||
<button onClick={async () => { await cancelPipeline(pipeline.id); onChanged(); }}>
|
||||
취소
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pipeline-previews">
|
||||
{pipeline.cover_url && (
|
||||
<img src={pipeline.cover_url} alt="" className="pipeline-preview-mini" />
|
||||
)}
|
||||
{pipeline.thumbnail_url && (
|
||||
<img src={pipeline.thumbnail_url} alt="" className="pipeline-preview-mini" />
|
||||
)}
|
||||
{pipeline.video_url && <span className="pipeline-video-icon">▶</span>}
|
||||
</div>
|
||||
|
||||
<div className="pipeline-progress">
|
||||
{STEP_LABELS.map((lbl, idx) => (
|
||||
<div key={lbl}
|
||||
className={`pipeline-dot ${idx <= i ? 'is-done' : ''} ${idx === i ? 'is-current' : ''}`}>
|
||||
<span>{lbl}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pipeline-state">현재: {pipeline.state}</div>
|
||||
|
||||
{pipeline.review && (
|
||||
<div className="pipeline-review">
|
||||
AI 검토: <strong>{pipeline.review.verdict}</strong>
|
||||
({pipeline.review.weighted_total}/100)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pipeline.state === 'publish_pending' && (
|
||||
<button className="button primary"
|
||||
onClick={async () => { await publishPipeline(pipeline.id); onChanged(); }}>
|
||||
YouTube 업로드
|
||||
</button>
|
||||
)}
|
||||
|
||||
{pipeline.youtube_video_id && (
|
||||
<a href={`https://youtu.be/${pipeline.youtube_video_id}`}
|
||||
target="_blank" rel="noreferrer">
|
||||
유튜브에서 보기
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDetail && <PipelineDetailModal pipeline={pipeline} onClose={() => setShowDetail(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
117
src/pages/music/components/PipelineDetailModal.jsx
Normal file
117
src/pages/music/components/PipelineDetailModal.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
const fmtTimestamp = (sec) => {
|
||||
if (sec == null) return '';
|
||||
const total = Math.floor(sec);
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
if (h) return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
||||
return `${m}:${String(s).padStart(2,'0')}`;
|
||||
};
|
||||
|
||||
export default function PipelineDetailModal({ pipeline, onClose }) {
|
||||
if (!pipeline) return null;
|
||||
const meta = pipeline.metadata || {};
|
||||
const review = pipeline.review || {};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-body modal-body--lg" onClick={e => e.stopPropagation()}>
|
||||
<header className="pdm-header">
|
||||
<h3>{pipeline.compile_title || pipeline.track_title || `Pipeline #${pipeline.id}`}</h3>
|
||||
<span className="pdm-badge">{pipeline.visual_style || 'essential'}</span>
|
||||
<button onClick={onClose} className="pdm-close" aria-label="close">×</button>
|
||||
</header>
|
||||
|
||||
<div className="pdm-grid">
|
||||
{pipeline.cover_url && (
|
||||
<figure className="pdm-figure">
|
||||
<img src={pipeline.cover_url} alt="cover" />
|
||||
<figcaption>커버 (배경)</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
{pipeline.thumbnail_url && (
|
||||
<figure className="pdm-figure">
|
||||
<img src={pipeline.thumbnail_url} alt="thumbnail" />
|
||||
<figcaption>썸네일</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pipeline.video_url && (
|
||||
<div className="pdm-video">
|
||||
<video src={pipeline.video_url} controls preload="metadata" width="100%" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.title && (
|
||||
<section className="pdm-section">
|
||||
<h4>메타데이터</h4>
|
||||
<p><strong>제목:</strong> {meta.title}</p>
|
||||
<details>
|
||||
<summary>설명 ({(meta.description || '').length}자)</summary>
|
||||
<pre className="pdm-pre">{meta.description}</pre>
|
||||
</details>
|
||||
<p><strong>태그:</strong> {(meta.tags || []).join(', ')}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{review.weighted_total != null && (
|
||||
<section className="pdm-section">
|
||||
<h4>
|
||||
AI 검토
|
||||
<span className={`pdm-verdict pdm-verdict--${review.verdict}`}>
|
||||
{review.verdict}
|
||||
</span>
|
||||
<span className="pdm-score">({review.weighted_total}/100)</span>
|
||||
</h4>
|
||||
<table className="pdm-review-table">
|
||||
<tbody>
|
||||
<tr><td>메타데이터 품질</td><td>{review.metadata_quality?.score}</td></tr>
|
||||
<tr><td>콘텐츠 정책</td><td>{review.policy_compliance?.score}</td></tr>
|
||||
<tr><td>시청 경험</td><td>{review.viewer_experience?.score}</td></tr>
|
||||
<tr><td>트렌드 정렬</td><td>{review.trend_alignment?.score}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{review.summary && <p className="pdm-summary"><em>{review.summary}</em></p>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{pipeline.tracks && pipeline.tracks.length > 1 && (
|
||||
<section className="pdm-section">
|
||||
<h4>트랙 리스트 ({pipeline.tracks.length})</h4>
|
||||
<ol className="pdm-tracks">
|
||||
{pipeline.tracks.map(t => (
|
||||
<li key={t.id}>
|
||||
<span className="pdm-track-time">[{fmtTimestamp(t.start_offset_sec)}]</span>
|
||||
{' '}{t.title}
|
||||
<span className="pdm-track-dur"> ({fmtTimestamp(t.duration_sec)})</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{pipeline.feedback && pipeline.feedback.length > 0 && (
|
||||
<section className="pdm-section">
|
||||
<h4>피드백 히스토리 ({pipeline.feedback.length})</h4>
|
||||
<ul className="pdm-feedback">
|
||||
{pipeline.feedback.map(f => (
|
||||
<li key={f.id}>
|
||||
<code>[{f.step}]</code> {f.feedback_text}
|
||||
<small> {(f.received_at || '').replace('T', ' ')}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{pipeline.youtube_video_id && (
|
||||
<a href={`https://youtu.be/${pipeline.youtube_video_id}`}
|
||||
target="_blank" rel="noreferrer" className="pdm-youtube">
|
||||
🎬 YouTube에서 보기
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
src/pages/music/components/PipelineStartModal.jsx
Normal file
141
src/pages/music/components/PipelineStartModal.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createPipeline, startPipeline, getCompileJobs } from '../../../api';
|
||||
|
||||
const fmtDur = (s) => {
|
||||
if (!s) return '0:00';
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.round(s % 60);
|
||||
return `${m}:${String(sec).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export default function PipelineStartModal({ library, initialTrackId, onClose, onCreated }) {
|
||||
const [inputType, setInputType] = useState('track'); // 'track' | 'compile'
|
||||
const [tid, setTid] = useState(initialTrackId || library?.[0]?.id || '');
|
||||
const [cid, setCid] = useState('');
|
||||
const [compileJobs, setCompileJobs] = useState([]);
|
||||
const [advanced, setAdvanced] = useState(false);
|
||||
const [visualStyle, setVisualStyle] = useState('');
|
||||
const [bgMode, setBgMode] = useState('');
|
||||
const [bgKeyword, setBgKeyword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (inputType === 'compile') {
|
||||
getCompileJobs()
|
||||
.then(r => {
|
||||
const list = (r.jobs || r || []);
|
||||
const completed = list.filter(j => j.status === 'done' || j.status === 'succeeded');
|
||||
setCompileJobs(completed);
|
||||
if (completed.length && !cid) setCid(completed[0].id);
|
||||
})
|
||||
.catch(e => setError(String(e)));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inputType]);
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
const payload = {};
|
||||
if (inputType === 'track') {
|
||||
payload.track_id = parseInt(tid);
|
||||
} else {
|
||||
if (!cid) {
|
||||
setError('완료된 Mix를 선택해주세요');
|
||||
return;
|
||||
}
|
||||
payload.compile_job_id = parseInt(cid);
|
||||
}
|
||||
if (visualStyle) payload.visual_style = visualStyle;
|
||||
if (bgMode) payload.background_mode = bgMode;
|
||||
if (bgKeyword) payload.background_keyword = bgKeyword;
|
||||
|
||||
const p = await createPipeline(payload);
|
||||
await startPipeline(p.id);
|
||||
onCreated(p);
|
||||
} catch (e) {
|
||||
setError(e.message || String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-body" onClick={e => e.stopPropagation()}>
|
||||
<h3>새 파이프라인 시작</h3>
|
||||
|
||||
<fieldset className="psm-input-radio">
|
||||
<legend>입력</legend>
|
||||
<label>
|
||||
<input type="radio" checked={inputType === 'track'}
|
||||
onChange={() => setInputType('track')} />
|
||||
{' '}단일 트랙
|
||||
</label>
|
||||
<label style={{ marginLeft: 12 }}>
|
||||
<input type="radio" checked={inputType === 'compile'}
|
||||
onChange={() => setInputType('compile')} />
|
||||
{' '}Mix (컴파일 결과)
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{inputType === 'track' ? (
|
||||
<select value={tid} onChange={e => setTid(e.target.value)}>
|
||||
{(library || []).map(t => (
|
||||
<option key={t.id} value={t.id}>{t.title} ({t.genre})</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<select value={cid} onChange={e => setCid(e.target.value)}>
|
||||
{compileJobs.length === 0 && <option value="">완료된 Mix가 없습니다</option>}
|
||||
{compileJobs.map(j => (
|
||||
<option key={j.id} value={j.id}>
|
||||
{j.title || `Mix #${j.id}`}
|
||||
{' '}({fmtDur(j.duration_sec || 0)},{' '}
|
||||
{j.tracks_count || (j.track_ids && j.track_ids.length) || '?'}곡)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<label className="psm-keyword-main">
|
||||
원하는 이미지 분위기 (선택)
|
||||
<input
|
||||
value={bgKeyword}
|
||||
onChange={e => setBgKeyword(e.target.value)}
|
||||
placeholder="예: 스케이트보드 파크 밝은 오후, 비 오는 카페 창가, 산 정상 일출 ..."
|
||||
/>
|
||||
<small>처음부터 cover 이미지 prompt에 반영됩니다. 비우면 장르 기본값 사용.</small>
|
||||
</label>
|
||||
|
||||
<details className="psm-advanced" open={advanced}>
|
||||
<summary onClick={(e) => { e.preventDefault(); setAdvanced(!advanced); }}>
|
||||
고급 옵션
|
||||
</summary>
|
||||
<label>
|
||||
시각 스타일
|
||||
<select value={visualStyle} onChange={e => setVisualStyle(e.target.value)}>
|
||||
<option value="">기본 (구성 탭 default)</option>
|
||||
<option value="essential">essential (배경 + 중앙 비주얼)</option>
|
||||
<option value="single">single (커버 + 가장자리 파형)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
배경 모드
|
||||
<select value={bgMode} onChange={e => setBgMode(e.target.value)}>
|
||||
<option value="">기본 (구성 탭 default)</option>
|
||||
<option value="static">정적 사진</option>
|
||||
<option value="video_loop">영상 루프 (Pexels)</option>
|
||||
</select>
|
||||
</label>
|
||||
</details>
|
||||
|
||||
{error && <div className="ms-error">{error}</div>}
|
||||
<div className="modal-actions">
|
||||
<button onClick={onClose}>취소</button>
|
||||
<button className="button primary" onClick={submit}
|
||||
disabled={inputType === 'compile' && !cid}>
|
||||
시작
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
src/pages/music/components/PipelineTab.jsx
Normal file
55
src/pages/music/components/PipelineTab.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { listPipelines } from '../../../api';
|
||||
import PipelineCard from './PipelineCard';
|
||||
import PipelineStartModal from './PipelineStartModal';
|
||||
|
||||
export default function PipelineTab({ library, initialTrackId }) {
|
||||
const [pipelines, setPipelines] = useState([]);
|
||||
const [filter, setFilter] = useState('active');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const timer = useRef(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const r = await listPipelines(filter);
|
||||
setPipelines(r.pipelines || []);
|
||||
} catch { /* swallow */ }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
timer.current = setInterval(load, 5000);
|
||||
return () => clearInterval(timer.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialTrackId) setModalOpen(true);
|
||||
}, [initialTrackId]);
|
||||
|
||||
return (
|
||||
<div className="pipeline-container">
|
||||
<div className="pipeline-toolbar">
|
||||
<button className="button primary" onClick={() => setModalOpen(true)}>+ 새 파이프라인</button>
|
||||
<select value={filter} onChange={e => setFilter(e.target.value)}>
|
||||
<option value="active">진행 중</option>
|
||||
<option value="all">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="pipeline-grid">
|
||||
{pipelines.map(p => (
|
||||
<PipelineCard key={p.id} pipeline={p} onChanged={load} />
|
||||
))}
|
||||
{pipelines.length === 0 && <p className="ms-empty">진행 중인 파이프라인이 없습니다</p>}
|
||||
</div>
|
||||
{modalOpen && (
|
||||
<PipelineStartModal
|
||||
library={library}
|
||||
initialTrackId={initialTrackId}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onCreated={() => { setModalOpen(false); load(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
276
src/pages/music/components/RevenueTab.jsx
Normal file
276
src/pages/music/components/RevenueTab.jsx
Normal file
@@ -0,0 +1,276 @@
|
||||
// src/pages/music/components/RevenueTab.jsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
getRevenueDashboard, getRevenueRecords,
|
||||
addRevenueRecord, updateRevenueRecord, deleteRevenueRecord,
|
||||
} from '../../../api';
|
||||
|
||||
const COUNTRIES = ['BR', 'US', 'ID', 'MX', 'KR'];
|
||||
const currentMonth = () => new Date().toISOString().slice(0, 7);
|
||||
|
||||
export default function RevenueTab() {
|
||||
const [dashboard, setDashboard] = useState(null);
|
||||
const [records, setRecords] = useState([]);
|
||||
const [form, setForm] = useState({
|
||||
yt_video_id: '', record_month: currentMonth(),
|
||||
revenue_usd: '', views: '', country: 'BR',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editForm, setEditForm] = useState({});
|
||||
|
||||
const loadAll = async () => {
|
||||
const [dash, recs] = await Promise.all([
|
||||
getRevenueDashboard().catch(() => null),
|
||||
getRevenueRecords().catch(() => []),
|
||||
]);
|
||||
setDashboard(dash);
|
||||
setRecords(Array.isArray(recs) ? recs : recs.records ?? []);
|
||||
};
|
||||
|
||||
useEffect(() => { loadAll(); }, []);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!form.yt_video_id || !form.revenue_usd || !form.views) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await addRevenueRecord({
|
||||
yt_video_id: form.yt_video_id,
|
||||
record_month: form.record_month,
|
||||
revenue_usd: parseFloat(form.revenue_usd),
|
||||
views: parseInt(form.views, 10),
|
||||
country: form.country,
|
||||
});
|
||||
setForm({ yt_video_id: '', record_month: currentMonth(), revenue_usd: '', views: '', country: 'BR' });
|
||||
await loadAll();
|
||||
} catch (e) {
|
||||
console.error('addRevenueRecord:', e);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditSave = async () => {
|
||||
try {
|
||||
await updateRevenueRecord(editingId, {
|
||||
revenue_usd: parseFloat(editForm.revenue_usd),
|
||||
views: parseInt(editForm.views, 10),
|
||||
});
|
||||
setEditingId(null);
|
||||
await loadAll();
|
||||
} catch (e) {
|
||||
console.error('updateRevenueRecord:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!window.confirm('이 기록을 삭제할까요?')) return;
|
||||
try {
|
||||
await deleteRevenueRecord(id);
|
||||
await loadAll();
|
||||
} catch (e) {
|
||||
console.error('deleteRevenueRecord:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 영상별 RPM 상위 5개 (bar chart 용)
|
||||
const chartData = records
|
||||
.filter(r => r.views > 0)
|
||||
.map(r => ({
|
||||
label: r.yt_video_id,
|
||||
rpm: (r.revenue_usd / r.views) * 1000,
|
||||
}))
|
||||
.sort((a, b) => b.rpm - a.rpm)
|
||||
.slice(0, 5);
|
||||
const maxRpm = chartData.length > 0 ? Math.max(...chartData.map(d => d.rpm)) : 1;
|
||||
|
||||
return (
|
||||
<div className="yt-content">
|
||||
{/* 대시보드 카드 3개 */}
|
||||
<div className="yt-dash-cards">
|
||||
<div className="yt-dash-card">
|
||||
<div className="yt-dash-card__label">총 수익</div>
|
||||
<div className="yt-dash-card__value yt-dash-card__value--green">
|
||||
${dashboard?.total_revenue_usd?.toFixed(2) ?? '—'}
|
||||
</div>
|
||||
<div className="yt-dash-card__sub">누적</div>
|
||||
</div>
|
||||
<div className="yt-dash-card">
|
||||
<div className="yt-dash-card__label">총 조회수</div>
|
||||
<div className="yt-dash-card__value yt-dash-card__value--blue">
|
||||
{dashboard?.total_views != null
|
||||
? (dashboard.total_views >= 1000
|
||||
? `${(dashboard.total_views / 1000).toFixed(1)}K`
|
||||
: String(dashboard.total_views))
|
||||
: '—'}
|
||||
</div>
|
||||
<div className="yt-dash-card__sub">누적</div>
|
||||
</div>
|
||||
<div className="yt-dash-card">
|
||||
<div className="yt-dash-card__label">평균 RPM</div>
|
||||
<div className="yt-dash-card__value yt-dash-card__value--amber">
|
||||
${dashboard?.avg_rpm?.toFixed(2) ?? '—'}
|
||||
</div>
|
||||
<div className="yt-dash-card__sub">가중평균</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 영상별 RPM 바 차트 */}
|
||||
{chartData.length > 0 && (
|
||||
<div className="yt-card">
|
||||
<h3 className="yt-card__title">영상별 RPM 비교</h3>
|
||||
<div className="yt-bar-chart">
|
||||
{chartData.map((d, i) => (
|
||||
<div key={i} className="yt-bar-row">
|
||||
<div className="yt-bar-row__label" title={d.label}>
|
||||
{d.label.slice(0, 11)}
|
||||
</div>
|
||||
<div className="yt-bar-row__track">
|
||||
<div
|
||||
className="yt-bar-row__fill"
|
||||
style={{ width: `${(d.rpm / maxRpm) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="yt-bar-row__value">${d.rpm.toFixed(2)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 수익 기록 추가 폼 */}
|
||||
<div className="yt-card yt-card--create">
|
||||
<h3 className="yt-card__title">+ 수익 기록 추가</h3>
|
||||
<div className="yt-form-grid">
|
||||
<div className="yt-field">
|
||||
<label className="yt-field__label">YouTube 영상 ID</label>
|
||||
<input
|
||||
className="yt-input"
|
||||
value={form.yt_video_id}
|
||||
onChange={e => setForm(f => ({ ...f, yt_video_id: e.target.value }))}
|
||||
placeholder="dQw4w9WgXcQ"
|
||||
/>
|
||||
</div>
|
||||
<div className="yt-field">
|
||||
<label className="yt-field__label">기록 월</label>
|
||||
<input
|
||||
className="yt-input"
|
||||
type="month"
|
||||
value={form.record_month}
|
||||
onChange={e => setForm(f => ({ ...f, record_month: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="yt-field">
|
||||
<label className="yt-field__label">수익 (USD)</label>
|
||||
<input
|
||||
className="yt-input"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.revenue_usd}
|
||||
onChange={e => setForm(f => ({ ...f, revenue_usd: e.target.value }))}
|
||||
placeholder="3.45"
|
||||
/>
|
||||
</div>
|
||||
<div className="yt-field">
|
||||
<label className="yt-field__label">조회수</label>
|
||||
<input
|
||||
className="yt-input"
|
||||
type="number"
|
||||
value={form.views}
|
||||
onChange={e => setForm(f => ({ ...f, views: e.target.value }))}
|
||||
placeholder="1200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="yt-row yt-row--bottom">
|
||||
<select
|
||||
className="yt-select"
|
||||
value={form.country}
|
||||
onChange={e => setForm(f => ({ ...f, country: e.target.value }))}
|
||||
>
|
||||
{COUNTRIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn ms-btn--primary"
|
||||
onClick={handleAdd}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 수익 기록 테이블 */}
|
||||
<div className="yt-card">
|
||||
<h3 className="yt-card__title">수익 기록</h3>
|
||||
{records.length === 0 ? (
|
||||
<p className="yt-empty">수익 기록이 없습니다. 위 폼으로 추가해보세요.</p>
|
||||
) : (
|
||||
<div className="yt-table">
|
||||
<div className="yt-table__header">
|
||||
<span>영상 ID</span>
|
||||
<span>월</span>
|
||||
<span>수익</span>
|
||||
<span>조회수</span>
|
||||
<span>RPM</span>
|
||||
<span />
|
||||
</div>
|
||||
{records.map(rec => (
|
||||
editingId === rec.id ? (
|
||||
<div key={rec.id} className="yt-table__row yt-table__row--editing">
|
||||
<span className="yt-table__cell">{rec.yt_video_id.slice(0, 11)}</span>
|
||||
<span className="yt-table__cell">{rec.record_month}</span>
|
||||
<input
|
||||
className="yt-input yt-input--sm"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editForm.revenue_usd}
|
||||
onChange={e => setEditForm(f => ({ ...f, revenue_usd: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="yt-input yt-input--sm"
|
||||
type="number"
|
||||
value={editForm.views}
|
||||
onChange={e => setEditForm(f => ({ ...f, views: e.target.value }))}
|
||||
/>
|
||||
<span className="yt-table__cell">—</span>
|
||||
<div className="yt-table__actions">
|
||||
<button type="button" className="ms-btn ms-btn--primary ms-btn--sm" onClick={handleEditSave}>저장</button>
|
||||
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm" onClick={() => setEditingId(null)}>취소</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={rec.id}
|
||||
className="yt-table__row"
|
||||
onClick={() => {
|
||||
setEditingId(rec.id);
|
||||
setEditForm({ revenue_usd: rec.revenue_usd, views: rec.views });
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<span className="yt-table__cell yt-table__cell--mono">{rec.yt_video_id.slice(0, 11)}</span>
|
||||
<span className="yt-table__cell">{rec.record_month}</span>
|
||||
<span className="yt-table__cell yt-table__cell--green">${rec.revenue_usd?.toFixed(2)}</span>
|
||||
<span className="yt-table__cell">{rec.views?.toLocaleString()}</span>
|
||||
<span className="yt-table__cell yt-table__cell--amber">
|
||||
{rec.views > 0
|
||||
? `$${((rec.revenue_usd / rec.views) * 1000).toFixed(2)}`
|
||||
: '—'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn--icon ms-btn--danger"
|
||||
onClick={e => { e.stopPropagation(); handleDelete(rec.id); }}
|
||||
aria-label="삭제"
|
||||
>✕</button>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
src/pages/music/components/SetupTab.jsx
Normal file
181
src/pages/music/components/SetupTab.jsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
getMusicSetup, updateMusicSetup,
|
||||
getYoutubeAuthUrl, getYoutubeStatus, disconnectYoutube,
|
||||
} from '../../../api';
|
||||
|
||||
export default function SetupTab() {
|
||||
const [setup, setSetup] = useState(null);
|
||||
const [yt, setYt] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getMusicSetup(), getYoutubeStatus()])
|
||||
.then(([s, y]) => { setSetup(s); setYt(y); })
|
||||
.catch(e => setError(String(e)));
|
||||
}, []);
|
||||
|
||||
if (!setup) return <p className="ms-loading">Loading…</p>;
|
||||
|
||||
const save = async (patch) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const next = await updateMusicSetup(patch);
|
||||
setSetup(next);
|
||||
} catch (e) { setError(String(e)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const connectYoutube = async () => {
|
||||
const { url } = await getYoutubeAuthUrl();
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="setup-container">
|
||||
{error && <div className="ms-error">{error}</div>}
|
||||
|
||||
<section className="setup-card">
|
||||
<h3>YouTube 채널 연동</h3>
|
||||
{yt && yt.channel_id ? (
|
||||
<div className="setup-channel">
|
||||
{yt.avatar_url && <img src={yt.avatar_url} alt="" className="setup-avatar" />}
|
||||
<span>{yt.channel_title}</span>
|
||||
<button onClick={async () => { await disconnectYoutube(); setYt({}); }}>
|
||||
연결 해제
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="button primary" onClick={connectYoutube}>
|
||||
Google 계정 연결
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="setup-card">
|
||||
<h3>메타데이터 템플릿</h3>
|
||||
<label>제목 패턴
|
||||
<input
|
||||
value={setup.metadata_template.title}
|
||||
onChange={e => setSetup(s => ({...s, metadata_template: {...s.metadata_template, title: e.target.value}}))}
|
||||
/>
|
||||
</label>
|
||||
<label>설명 템플릿
|
||||
<textarea
|
||||
rows={6}
|
||||
value={setup.metadata_template.description}
|
||||
onChange={e => setSetup(s => ({...s, metadata_template: {...s.metadata_template, description: e.target.value}}))}
|
||||
/>
|
||||
</label>
|
||||
<label>기본 태그 (쉼표 구분)
|
||||
<input
|
||||
value={(setup.metadata_template.tags || []).join(', ')}
|
||||
onChange={e => setSetup(s => ({...s, metadata_template: {...s.metadata_template,
|
||||
tags: e.target.value.split(',').map(t => t.trim()).filter(Boolean)}}))}
|
||||
/>
|
||||
</label>
|
||||
<button onClick={() => save({ metadata_template: setup.metadata_template })}>저장</button>
|
||||
</section>
|
||||
|
||||
<section className="setup-card">
|
||||
<h3>AI 커버 prompt (장르별)</h3>
|
||||
{Object.entries(setup.cover_prompts).map(([g, p]) => (
|
||||
<div key={g} className="setup-prompt-row">
|
||||
<span className="setup-prompt-genre">{g}</span>
|
||||
<input
|
||||
value={p}
|
||||
onChange={e => setSetup(s => ({...s, cover_prompts: {...s.cover_prompts, [g]: e.target.value}}))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={() => save({ cover_prompts: setup.cover_prompts })}>저장</button>
|
||||
</section>
|
||||
|
||||
<section className="setup-card">
|
||||
<h3>AI 최종 검토 기준</h3>
|
||||
{['meta','policy','viewer','trend'].map(k => (
|
||||
<label key={k}>
|
||||
{k} 가중치 ({setup.review_weights[k]})
|
||||
<input type="range" min="0" max="100"
|
||||
value={setup.review_weights[k]}
|
||||
onChange={e => setSetup(s => ({...s, review_weights: {...s.review_weights, [k]: parseInt(e.target.value)}}))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label>임계값 ({setup.review_threshold})
|
||||
<input type="range" min="0" max="100" value={setup.review_threshold}
|
||||
onChange={e => setSetup(s => ({...s, review_threshold: parseInt(e.target.value)}))}
|
||||
/>
|
||||
</label>
|
||||
<button onClick={() => save({ review_weights: setup.review_weights, review_threshold: setup.review_threshold })}>저장</button>
|
||||
</section>
|
||||
|
||||
<section className="setup-card">
|
||||
<h3>영상 비주얼 기본값</h3>
|
||||
|
||||
<label>해상도
|
||||
<select value={setup.visual_defaults.resolution || '1920x1080'}
|
||||
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, resolution: e.target.value}}))}>
|
||||
<option value="1920x1080">1920×1080 (가로)</option>
|
||||
<option value="1080x1920">1080×1920 (세로/Shorts)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>기본 시각 스타일
|
||||
<select value={setup.visual_defaults.default_visual_style || 'essential'}
|
||||
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, default_visual_style: e.target.value}}))}>
|
||||
<option value="essential">essential (배경 + 중앙 비주얼)</option>
|
||||
<option value="single">single (커버 + 가장자리 파형)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>기본 배경 모드
|
||||
<select value={setup.visual_defaults.default_background_mode || 'static'}
|
||||
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, default_background_mode: e.target.value}}))}>
|
||||
<option value="static">정적 사진</option>
|
||||
<option value="video_loop">영상 루프 (Pexels)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>기본 배경 키워드 (비우면 장르 기반 자동)
|
||||
<input value={setup.visual_defaults.default_background_keyword || ''}
|
||||
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, default_background_keyword: e.target.value}}))}
|
||||
placeholder="lofi cafe, rainy window, mountain ..." />
|
||||
</label>
|
||||
|
||||
<label>배경 이미지 소스 (정적 모드)
|
||||
<select value={setup.visual_defaults.background_image_source || 'ai'}
|
||||
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, background_image_source: e.target.value}}))}>
|
||||
<option value="ai">AI 생성 (DALL·E)</option>
|
||||
<option value="pexels">Pexels 스톡 사진</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="setup-checkbox">
|
||||
<input type="checkbox"
|
||||
checked={setup.visual_defaults.subtitle_track_titles ?? true}
|
||||
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, subtitle_track_titles: e.target.checked}}))}/>
|
||||
Mix에서 곡명 자막 표시 (트랙 시작 시 5초)
|
||||
</label>
|
||||
|
||||
<button onClick={() => save({ visual_defaults: setup.visual_defaults })}>저장</button>
|
||||
</section>
|
||||
|
||||
<section className="setup-card">
|
||||
<h3>발행 정책</h3>
|
||||
<label>privacy
|
||||
<select value={setup.publish_policy.privacy}
|
||||
onChange={e => setSetup(s => ({...s, publish_policy: {...s.publish_policy, privacy: e.target.value}}))}>
|
||||
<option value="private">Private (비공개)</option>
|
||||
<option value="unlisted">Unlisted</option>
|
||||
<option value="public">Public</option>
|
||||
</select>
|
||||
</label>
|
||||
<button onClick={() => save({ publish_policy: setup.publish_policy })}>저장</button>
|
||||
</section>
|
||||
|
||||
{saving && <div className="setup-saving">저장 중...</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
220
src/pages/music/components/TrendsTab.jsx
Normal file
220
src/pages/music/components/TrendsTab.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
// src/pages/music/components/TrendsTab.jsx
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
getLatestTrendReport, getTrendReports,
|
||||
getMarketSuggestions, triggerYoutubeResearch,
|
||||
} from '../../../api';
|
||||
|
||||
const FLAG = { BR: '🇧🇷', US: '🇺🇸', ID: '🇮🇩', MX: '🇲🇽', KR: '🇰🇷' };
|
||||
|
||||
function fmtDateTime(iso) {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso.slice(0, 10);
|
||||
const today = new Date().toDateString();
|
||||
if (d.toDateString() === today) {
|
||||
return `오늘 ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
||||
}
|
||||
return iso.slice(0, 10); // YYYY-MM-DD
|
||||
}
|
||||
|
||||
export default function TrendsTab() {
|
||||
const [latestReport, setLatestReport] = useState(null);
|
||||
const [reports, setReports] = useState([]);
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
const [selectedReport, setSelectedReport] = useState(null);
|
||||
const [researching, setResearching] = useState(false);
|
||||
const [copiedIdx, setCopiedIdx] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [researchMsg, setResearchMsg] = useState('');
|
||||
|
||||
const researchTimerRef = useRef(null);
|
||||
const copyTimerRef = useRef(null);
|
||||
|
||||
const loadAll = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [latest, rpts, sugg] = await Promise.all([
|
||||
getLatestTrendReport().catch(() => null),
|
||||
getTrendReports().catch(() => []),
|
||||
getMarketSuggestions().catch(() => []),
|
||||
]);
|
||||
setLatestReport(latest);
|
||||
setReports(Array.isArray(rpts) ? rpts : rpts.reports ?? []);
|
||||
setSuggestions(Array.isArray(sugg) ? sugg : sugg.suggestions ?? []);
|
||||
} catch (e) {
|
||||
console.error('loadAll:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadAll(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(researchTimerRef.current);
|
||||
clearTimeout(copyTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleResearch = async () => {
|
||||
setResearching(true);
|
||||
try {
|
||||
await triggerYoutubeResearch();
|
||||
setResearchMsg('수집이 시작되었습니다. 잠시 후 새로고침하세요.');
|
||||
clearTimeout(researchTimerRef.current);
|
||||
researchTimerRef.current = setTimeout(() => setResearchMsg(''), 4000);
|
||||
} catch (e) {
|
||||
console.error('triggerYoutubeResearch:', e);
|
||||
} finally {
|
||||
setResearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (text, idx) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedIdx(idx);
|
||||
clearTimeout(copyTimerRef.current);
|
||||
copyTimerRef.current = setTimeout(() => setCopiedIdx(null), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
// 선택된 리포트가 있으면 그것, 없으면 최신 리포트의 장르 표시
|
||||
const displayReport = selectedReport ?? latestReport;
|
||||
const topGenres = displayReport?.top_genres?.slice(0, 5) ?? [];
|
||||
const maxScore = topGenres.length > 0 ? Math.max(...topGenres.map(g => g.score)) : 1;
|
||||
|
||||
// Suno 프롬프트: 선택된 리포트가 있으면 그것의 recommended_styles, 없으면 라이브 suggestions
|
||||
const displaySuggestions = selectedReport
|
||||
? (selectedReport.recommended_styles ?? [])
|
||||
: suggestions;
|
||||
|
||||
if (loading) return <div className="yt-content"><p className="yt-empty">데이터 로딩 중...</p></div>;
|
||||
|
||||
return (
|
||||
<div className="yt-content">
|
||||
{/* 수집 상태 바 */}
|
||||
<div className="yt-status-bar">
|
||||
<div className="yt-status-bar__left">
|
||||
<span className="yt-status-dot" />
|
||||
<span className="yt-status-bar__text">
|
||||
마지막 수집 일시: <strong>{fmtDateTime(latestReport?.created_at) ?? latestReport?.report_date ?? '없음'}</strong>
|
||||
{latestReport && ` · ${latestReport.top_genres?.length ?? 0}개 장르`}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={handleResearch}
|
||||
disabled={researching}
|
||||
>
|
||||
{researching ? '수집 중...' : '↻ 수동 수집'}
|
||||
</button>
|
||||
{researchMsg && <p className="yt-empty" style={{ color: '#22c55e', marginTop: 4 }}>{researchMsg}</p>}
|
||||
</div>
|
||||
|
||||
{/* 인기 장르 Top 5 */}
|
||||
<div className="yt-card">
|
||||
<h3 className="yt-card__title">🔥 오늘의 인기 장르 Top 5</h3>
|
||||
{topGenres.length === 0 ? (
|
||||
<p className="yt-empty">
|
||||
트렌드 데이터가 없습니다. 수동 수집을 실행하거나 agent-office가 내일 09:00에 자동 수집합니다.
|
||||
</p>
|
||||
) : (
|
||||
<div className="yt-bar-chart yt-bar-chart--genre">
|
||||
{topGenres.map((g, i) => (
|
||||
<div key={i} className="yt-bar-row">
|
||||
<div className="yt-bar-row__rank">#{i + 1}</div>
|
||||
<div className="yt-bar-row__info">
|
||||
<div className="yt-bar-row__genre-header">
|
||||
<span className="yt-bar-row__genre-name">{g.genre}</span>
|
||||
<span className="yt-bar-row__flags">
|
||||
{(g.countries ?? []).map(c => FLAG[c] ?? c).join(' ')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="yt-bar-row__track">
|
||||
<div
|
||||
className="yt-bar-row__fill yt-bar-row__fill--genre"
|
||||
style={{ width: `${(g.score / maxScore) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="yt-bar-row__value">{g.score}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Suno 프롬프트 추천 */}
|
||||
{displaySuggestions.length > 0 && (
|
||||
<div className="yt-card">
|
||||
<h3 className="yt-card__title">
|
||||
{selectedReport
|
||||
? `✨ ${selectedReport.report_date} 추천 프롬프트`
|
||||
: '✨ AI 추천 Suno 프롬프트'}
|
||||
</h3>
|
||||
<div className="yt-prompt-list">
|
||||
{displaySuggestions.map((s, i) => (
|
||||
<div key={i} className="yt-prompt-card">
|
||||
<div className="yt-prompt-card__header">
|
||||
<span className="yt-prompt-card__genre">{s.genre}</span>
|
||||
<span className="yt-prompt-card__countries">
|
||||
{(s.target_countries ?? []).map(c => FLAG[c] ?? c).join(' ')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="yt-prompt-card__text"
|
||||
onClick={() => handleCopy(s.suno_prompt, i)}
|
||||
title="클릭해서 복사"
|
||||
>
|
||||
{s.suno_prompt}
|
||||
</button>
|
||||
{copiedIdx === i && (
|
||||
<span className="yt-prompt-card__copied">✓ 복사됨</span>
|
||||
)}
|
||||
{s.reason && (
|
||||
<div className="yt-prompt-card__reason">{s.reason}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 트렌드 리포트 이력 */}
|
||||
<div className="yt-card">
|
||||
<h3 className="yt-card__title">📋 트렌드 리포트 이력</h3>
|
||||
{reports.length === 0 ? (
|
||||
<p className="yt-empty">리포트 이력이 없습니다</p>
|
||||
) : (
|
||||
<div className="yt-report-list">
|
||||
{reports.map(r => (
|
||||
<div
|
||||
key={r.id ?? r.report_date}
|
||||
className={`yt-report-row ${selectedReport?.report_date === r.report_date ? 'is-selected' : ''}`}
|
||||
onClick={() => {
|
||||
setSelectedReport(selectedReport?.report_date === r.report_date ? null : r);
|
||||
setCopiedIdx(null);
|
||||
}}
|
||||
>
|
||||
<span className="yt-report-row__date">
|
||||
{r.report_date}
|
||||
{r.report_date === latestReport?.report_date && (
|
||||
<span className="yt-report-row__today"> ● 오늘</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="yt-report-row__meta">
|
||||
{r.top_genres?.length ?? 0}개 장르 · {r.recommended_styles?.length ?? 0}개 추천
|
||||
</span>
|
||||
<span className="yt-report-row__action">보기 →</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
269
src/pages/music/components/VideoProjectsTab.jsx
Normal file
269
src/pages/music/components/VideoProjectsTab.jsx
Normal file
@@ -0,0 +1,269 @@
|
||||
// src/pages/music/components/VideoProjectsTab.jsx
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
createVideoProject, getVideoProjects,
|
||||
renderVideoProject, exportVideoProject, deleteVideoProject,
|
||||
} from '../../../api';
|
||||
|
||||
const COUNTRY_OPTIONS = ['BR', 'US', 'ID', 'MX', 'KR'];
|
||||
const COUNTRY_FLAGS = { BR: '🇧🇷', US: '🇺🇸', ID: '🇮🇩', MX: '🇲🇽', KR: '🇰🇷' };
|
||||
|
||||
export default function VideoProjectsTab({ library, initialTrackId, onClearInitialTrack }) {
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [selectedTrackId, setSelectedTrackId] = useState(initialTrackId ?? '');
|
||||
const [format, setFormat] = useState('visualizer');
|
||||
const [countries, setCountries] = useState(['BR']);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [exportData, setExportData] = useState(null);
|
||||
const [exportingId, setExportingId] = useState(null);
|
||||
const pollRef = useRef(null);
|
||||
|
||||
// initialTrackId prop 반영
|
||||
useEffect(() => {
|
||||
if (initialTrackId) {
|
||||
setSelectedTrackId(String(initialTrackId));
|
||||
onClearInitialTrack?.();
|
||||
}
|
||||
}, [initialTrackId]);
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
try {
|
||||
const data = await getVideoProjects();
|
||||
setProjects(Array.isArray(data) ? data : data.projects ?? []);
|
||||
} catch (e) {
|
||||
console.error('getVideoProjects:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadProjects(); }, []);
|
||||
|
||||
// 렌더링 중인 프로젝트가 있으면 5초마다 폴링
|
||||
useEffect(() => {
|
||||
const hasRendering = projects.some(p => p.status === 'rendering');
|
||||
if (hasRendering && !pollRef.current) {
|
||||
pollRef.current = setInterval(loadProjects, 5000);
|
||||
} else if (!hasRendering && pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
return () => {
|
||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||
};
|
||||
}, [projects, loadProjects]);
|
||||
|
||||
const toggleCountry = (c) => {
|
||||
setCountries(prev =>
|
||||
prev.includes(c) ? prev.filter(x => x !== c) : [...prev, c]
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!selectedTrackId || countries.length === 0) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await createVideoProject({
|
||||
track_id: Number(selectedTrackId),
|
||||
format,
|
||||
target_countries: countries,
|
||||
});
|
||||
await loadProjects();
|
||||
} catch (e) {
|
||||
console.error('createVideoProject:', e);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRender = async (id) => {
|
||||
try {
|
||||
await renderVideoProject(id);
|
||||
await loadProjects();
|
||||
} catch (e) {
|
||||
console.error('renderVideoProject:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (id) => {
|
||||
setExportingId(id);
|
||||
try {
|
||||
const data = await exportVideoProject(id);
|
||||
setExportData({ id, ...data });
|
||||
} catch (e) {
|
||||
console.error('exportVideoProject:', e);
|
||||
} finally {
|
||||
setExportingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!window.confirm('이 프로젝트를 삭제할까요?')) return;
|
||||
try {
|
||||
await deleteVideoProject(id);
|
||||
setProjects(prev => prev.filter(p => p.id !== id));
|
||||
if (exportData?.id === id) setExportData(null);
|
||||
} catch (e) {
|
||||
console.error('deleteVideoProject:', e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="yt-content">
|
||||
{/* ① 새 영상 만들기 */}
|
||||
<div className="yt-card yt-card--create">
|
||||
<h3 className="yt-card__title">① 새 영상 만들기</h3>
|
||||
<div className="yt-row">
|
||||
<select
|
||||
className="yt-select"
|
||||
value={selectedTrackId}
|
||||
onChange={e => setSelectedTrackId(e.target.value)}
|
||||
>
|
||||
<option value="">📚 트랙 선택...</option>
|
||||
{(library ?? []).map(t => (
|
||||
<option key={t.id} value={String(t.id)}>{t.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="yt-format-toggle">
|
||||
{['visualizer', 'slideshow'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
className={`yt-format-btn ${format === f ? 'is-active' : ''}`}
|
||||
onClick={() => setFormat(f)}
|
||||
>
|
||||
{f === 'visualizer' ? '비주얼라이저' : '슬라이드쇼'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="yt-country-label">타겟 국가 (복수 선택)</div>
|
||||
<div className="yt-country-chips">
|
||||
{COUNTRY_OPTIONS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`yt-chip ${countries.includes(c) ? 'is-active' : ''}`}
|
||||
onClick={() => toggleCountry(c)}
|
||||
>
|
||||
{COUNTRY_FLAGS[c]} {c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn ms-btn--primary yt-create-btn"
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !selectedTrackId || countries.length === 0}
|
||||
>
|
||||
{creating ? '생성 중...' : '프로젝트 생성'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ② 프로젝트 목록 */}
|
||||
<div className="yt-card">
|
||||
<h3 className="yt-card__title">② 영상 프로젝트</h3>
|
||||
{projects.length === 0 ? (
|
||||
<p className="yt-empty">트랙을 선택해 영상을 만들어보세요</p>
|
||||
) : (
|
||||
<div className="yt-project-list">
|
||||
{projects.map(p => (
|
||||
<ProjectCard
|
||||
key={p.id}
|
||||
project={p}
|
||||
onRender={handleRender}
|
||||
onExport={handleExport}
|
||||
onDelete={handleDelete}
|
||||
isExporting={exportingId === p.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ③ 내보내기 패키지 */}
|
||||
{exportData && (
|
||||
<div className="yt-card yt-card--export">
|
||||
<h3 className="yt-card__title">③ 내보내기 패키지</h3>
|
||||
<div className="yt-export-links">
|
||||
{exportData.mp4_url && (
|
||||
<a href={exportData.mp4_url} download className="ms-btn ms-btn--ghost ms-btn--sm">
|
||||
📹 output.mp4 다운로드
|
||||
</a>
|
||||
)}
|
||||
{exportData.thumbnail_url && (
|
||||
<a href={exportData.thumbnail_url} download className="ms-btn ms-btn--ghost ms-btn--sm">
|
||||
🖼️ thumbnail.jpg
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{exportData.metadata && (
|
||||
<div className="yt-meta-preview">
|
||||
<div className="yt-meta-preview__label">metadata.json 미리보기</div>
|
||||
<pre className="yt-meta-preview__content">
|
||||
{JSON.stringify(exportData.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectCard({ project, onRender, onExport, onDelete, isExporting }) {
|
||||
const STATUS_MAP = {
|
||||
pending: { text: '대기', cls: 'yt-status--pending' },
|
||||
rendering: { text: '⚙ 처리중', cls: 'yt-status--rendering' },
|
||||
done: { text: '✓ 완료', cls: 'yt-status--done' },
|
||||
failed: { text: '실패', cls: 'yt-status--failed' },
|
||||
};
|
||||
const s = STATUS_MAP[project.status] ?? { text: project.status, cls: '' };
|
||||
|
||||
return (
|
||||
<div className="yt-project-card">
|
||||
<div className="yt-project-card__icon">
|
||||
{project.status === 'rendering' ? '⚙️' : project.status === 'done' ? '🎬' : '🎵'}
|
||||
</div>
|
||||
<div className="yt-project-card__info">
|
||||
<div className="yt-project-card__title">
|
||||
{project.title ?? `프로젝트 #${project.id}`}
|
||||
</div>
|
||||
<div className="yt-project-card__meta">
|
||||
{project.format} · {(project.target_countries ?? []).join(' ')}
|
||||
</div>
|
||||
{project.status === 'rendering' && (
|
||||
<div className="yt-progress-bar">
|
||||
<div className="yt-progress-bar__fill" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={`yt-status ${s.cls}`}>{s.text}</span>
|
||||
{project.status === 'pending' && (
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={() => onRender(project.id)}
|
||||
>
|
||||
▶ 렌더
|
||||
</button>
|
||||
)}
|
||||
{project.status === 'done' && (
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={() => onExport(project.id)}
|
||||
disabled={isExporting}
|
||||
>
|
||||
{isExporting ? '...' : '↓ 내보내기'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn--icon ms-btn--danger"
|
||||
onClick={() => onDelete(project.id)}
|
||||
aria-label="삭제"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
src/pages/music/components/YoutubeTab.jsx
Normal file
63
src/pages/music/components/YoutubeTab.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import VideoProjectsTab from './VideoProjectsTab';
|
||||
import RevenueTab from './RevenueTab';
|
||||
import TrendsTab from './TrendsTab';
|
||||
import CompileTab from './CompileTab';
|
||||
import PipelineTab from './PipelineTab';
|
||||
import SetupTab from './SetupTab';
|
||||
|
||||
export default function YoutubeTab({ library, initialTrackId, onClearInitialTrack, openPipelineFor }) {
|
||||
const [subtab, setSubtab] = useState('pipeline');
|
||||
|
||||
useEffect(() => {
|
||||
if (initialTrackId) setSubtab('video');
|
||||
}, [initialTrackId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (openPipelineFor) setSubtab('pipeline');
|
||||
}, [openPipelineFor]);
|
||||
|
||||
const tabs = [
|
||||
['pipeline', '🚀 진행'],
|
||||
['video', '🎬 영상 제작'],
|
||||
['compile', '🎵 컴파일'],
|
||||
['trends', '📊 시장 트렌드'],
|
||||
['revenue', '💰 수익 추적'],
|
||||
['setup', '⚙️ 구성'],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="yt-container">
|
||||
<nav className="yt-subtabs">
|
||||
{tabs.map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`yt-subtab ${subtab === key ? 'is-active' : ''}`}
|
||||
onClick={() => setSubtab(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{subtab === 'pipeline' && <PipelineTab library={library} initialTrackId={openPipelineFor} />}
|
||||
{subtab === 'video' && (
|
||||
<VideoProjectsTab
|
||||
library={library}
|
||||
initialTrackId={initialTrackId}
|
||||
onClearInitialTrack={onClearInitialTrack}
|
||||
/>
|
||||
)}
|
||||
{subtab === 'compile' && (
|
||||
<CompileTab
|
||||
library={library}
|
||||
onSwitchToPipeline={() => setSubtab('pipeline')}
|
||||
/>
|
||||
)}
|
||||
{subtab === 'trends' && <TrendsTab />}
|
||||
{subtab === 'revenue' && <RevenueTab />}
|
||||
{subtab === 'setup' && <SetupTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
src/pages/portfolio/IntroTab.jsx
Normal file
94
src/pages/portfolio/IntroTab.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const emptyIntro = { title: '', content: '', is_main: 0 };
|
||||
|
||||
export default function IntroTab({ introductions, editing, api, onRefresh }) {
|
||||
const [form, setForm] = useState(null);
|
||||
const [copiedId, setCopiedId] = useState(null);
|
||||
|
||||
const save = async () => {
|
||||
if (form.id) {
|
||||
await api.editIntro(form.id, { title: form.title, content: form.content });
|
||||
} else {
|
||||
await api.addIntro(form);
|
||||
}
|
||||
setForm(null);
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const remove = async (id) => {
|
||||
await api.removeIntro(id);
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const setMain = async (id) => {
|
||||
await api.setMainIntro(id);
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const copyToClipboard = async (intro) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(intro.content);
|
||||
setCopiedId(intro.id);
|
||||
setTimeout(() => setCopiedId(null), 1500);
|
||||
} catch {
|
||||
/* 무시 */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pf-intro-tab">
|
||||
{editing && (
|
||||
<div className="pf-intro-tab__toolbar">
|
||||
<button className="button primary" onClick={() => setForm({...emptyIntro})}>+ 새 글 작성</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 작성/수정 폼 */}
|
||||
{form && (
|
||||
<div className="pf-edit-form">
|
||||
<label>버전명 <input value={form.title} placeholder="예: 이직용 짧은 버전" onChange={(e) => setForm(f => ({...f, title: e.target.value}))} /></label>
|
||||
<label>본문 <textarea value={form.content} rows={8} onChange={(e) => setForm(f => ({...f, content: e.target.value}))} /></label>
|
||||
<div className="pf-edit-form__actions">
|
||||
<button className="button ghost" onClick={() => setForm(null)}>취소</button>
|
||||
<button className="button primary" onClick={save}>저장</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 자기소개 목록 */}
|
||||
<div className="pf-intro-list">
|
||||
{introductions.length === 0 && <p className="pf-empty">자기소개 글이 없습니다.</p>}
|
||||
{introductions.map(intro => (
|
||||
<div key={intro.id} className={`pf-intro-card${intro.is_main ? ' is-main' : ''}`}>
|
||||
<div className="pf-intro-card__header">
|
||||
<span className="pf-intro-card__title">
|
||||
{intro.is_main ? <span className="pf-intro-card__badge">MAIN</span> : null}
|
||||
{intro.title || '제목 없음'}
|
||||
</span>
|
||||
<span className="pf-intro-card__date">
|
||||
{intro.updated_at ? new Date(intro.updated_at).toLocaleDateString('ko-KR') : ''}
|
||||
</span>
|
||||
</div>
|
||||
<p className="pf-intro-card__preview">{intro.content}</p>
|
||||
<div className="pf-intro-card__actions">
|
||||
<button
|
||||
className="button ghost"
|
||||
onClick={() => copyToClipboard(intro)}
|
||||
>
|
||||
{copiedId === intro.id ? '복사됨!' : '복사'}
|
||||
</button>
|
||||
{editing && (
|
||||
<>
|
||||
<button className="button ghost" onClick={() => setForm({...intro})}>수정</button>
|
||||
{!intro.is_main && <button className="button ghost" onClick={() => setMain(intro.id)}>메인 지정</button>}
|
||||
<button className="button ghost" onClick={() => remove(intro.id)}>삭제</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/pages/portfolio/PasswordModal.jsx
Normal file
43
src/pages/portfolio/PasswordModal.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function PasswordModal({ open, onAuth, onClose, error }) {
|
||||
const [pw, setPw] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!pw.trim()) return;
|
||||
setLoading(true);
|
||||
await onAuth(pw);
|
||||
setLoading(false);
|
||||
setPw('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pf-modal-backdrop" onClick={onClose}>
|
||||
<div className="pf-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="pf-modal__title">편집 모드</h3>
|
||||
<p className="pf-modal__desc">편집하려면 비밀번호를 입력하세요.</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="password"
|
||||
className="pf-modal__input"
|
||||
placeholder="비밀번호"
|
||||
value={pw}
|
||||
onChange={(e) => setPw(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="pf-modal__error">{error}</p>}
|
||||
<div className="pf-modal__actions">
|
||||
<button type="button" className="button ghost" onClick={onClose}>취소</button>
|
||||
<button type="submit" className="button primary" disabled={loading || !pw.trim()}>
|
||||
{loading ? '확인 중...' : '확인'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
921
src/pages/portfolio/Portfolio.css
Normal file
921
src/pages/portfolio/Portfolio.css
Normal file
@@ -0,0 +1,921 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
Portfolio Page — Cyberpunk Resume
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.pf-page {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.pf-loading,
|
||||
.pf-error {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pf-error {
|
||||
color: #f9b6b1;
|
||||
border: 1px solid rgba(249, 182, 177, 0.4);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: rgba(249, 182, 177, 0.08);
|
||||
}
|
||||
|
||||
.pf-empty {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ── Toolbar ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.pf-toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Password Modal ──────────────────────────────────────────────────── */
|
||||
|
||||
.pf-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pf-modal {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 28px 24px;
|
||||
width: min(400px, 90vw);
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pf-modal__title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.pf-modal__desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pf-modal__input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: var(--text-bright);
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pf-modal__input:focus {
|
||||
border-color: rgba(6, 182, 212, 0.5);
|
||||
box-shadow: 0 0 0 2px rgba(6, 182, 212, 0.1);
|
||||
}
|
||||
|
||||
.pf-modal__error {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: #f9b6b1;
|
||||
}
|
||||
|
||||
.pf-modal__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ── Edit Form (공통) ────────────────────────────────────────────────── */
|
||||
|
||||
.pf-edit-form {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
animation: fadeIn 0.2s ease both;
|
||||
}
|
||||
|
||||
.pf-edit-form label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pf-edit-form input,
|
||||
.pf-edit-form textarea,
|
||||
.pf-edit-form select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: var(--text-bright);
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.pf-edit-form input:focus,
|
||||
.pf-edit-form textarea:focus,
|
||||
.pf-edit-form select:focus {
|
||||
border-color: rgba(6, 182, 212, 0.5);
|
||||
box-shadow: 0 0 0 2px rgba(6, 182, 212, 0.1);
|
||||
}
|
||||
|
||||
.pf-edit-form__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pf-edit-form__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pf-edit-btn {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ── Profile Card ────────────────────────────────────────────────────── */
|
||||
|
||||
.pf-profile-tab {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.pf-profile-card {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.pf-profile-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pf-profile-card__photo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid rgba(6, 182, 212, 0.3);
|
||||
}
|
||||
|
||||
.pf-profile-card__name {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.pf-profile-card__name-en {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pf-profile-card__role {
|
||||
margin: 4px 0 0;
|
||||
font-size: 14px;
|
||||
color: #06b6d4;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pf-profile-card__bio {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
line-height: 1.7;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.pf-profile-card__links {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pf-profile-card__links a {
|
||||
font-size: 13px;
|
||||
color: #06b6d4;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pf-profile-card__links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Section ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.pf-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pf-section__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pf-section__header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-bright);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ── Career Group ────────────────────────────────────────────────────── */
|
||||
|
||||
.pf-career-group {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pf-career-group__title {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #06b6d4;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.pf-career-item {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pf-career-item__period {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.pf-career-item__role {
|
||||
font-size: 14px;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.pf-career-item__org {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.pf-career-item__desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.pf-career-item__actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Skill Group ─────────────────────────────────────────────────────── */
|
||||
|
||||
.pf-skill-group {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pf-skill-group__title {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #06b6d4;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.pf-skill-group__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pf-skill-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(6, 182, 212, 0.1);
|
||||
border: 1px solid rgba(6, 182, 212, 0.25);
|
||||
color: #06b6d4;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pf-skill-tag[data-level="5"] {
|
||||
background: rgba(6, 182, 212, 0.2);
|
||||
border-color: rgba(6, 182, 212, 0.5);
|
||||
}
|
||||
|
||||
.pf-skill-tag[data-level="4"] {
|
||||
background: rgba(6, 182, 212, 0.15);
|
||||
border-color: rgba(6, 182, 212, 0.35);
|
||||
}
|
||||
|
||||
.pf-skill-tag__actions {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.pf-skill-tag__actions button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pf-skill-tag__actions button:hover {
|
||||
color: #06b6d4;
|
||||
}
|
||||
|
||||
/* ── Skill Logo Loop ─────────────────────────────────────────────────── */
|
||||
|
||||
.pf-skill-loop {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 24px 28px;
|
||||
}
|
||||
|
||||
.pf-skill-logo {
|
||||
height: 36px;
|
||||
width: auto;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pf-skill-fallback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-bright);
|
||||
background: rgba(6, 182, 212, 0.12);
|
||||
border: 1px solid rgba(6, 182, 212, 0.35);
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Filter Bar ──────────────────────────────────────────────────────── */
|
||||
|
||||
.pf-filter-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pf-filter-btn {
|
||||
font-size: 12px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.pf-filter-btn:hover {
|
||||
background: rgba(6, 182, 212, 0.1);
|
||||
border-color: rgba(6, 182, 212, 0.3);
|
||||
color: #06b6d4;
|
||||
}
|
||||
|
||||
.pf-filter-btn.is-active {
|
||||
background: rgba(6, 182, 212, 0.18);
|
||||
border-color: rgba(6, 182, 212, 0.5);
|
||||
color: #06b6d4;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Project Tab ─────────────────────────────────────────────────────── */
|
||||
|
||||
.pf-project-tab {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pf-project-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.pf-project-card {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.pf-project-card:hover {
|
||||
border-color: rgba(6, 182, 212, 0.25);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.pf-project-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pf-project-card__cat {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.pf-project-card__cat--company {
|
||||
background: rgba(96, 165, 250, 0.15);
|
||||
color: #60a5fa;
|
||||
border: 1px solid rgba(96, 165, 250, 0.3);
|
||||
}
|
||||
|
||||
.pf-project-card__cat--personal {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
color: #34d399;
|
||||
border: 1px solid rgba(52, 211, 153, 0.3);
|
||||
}
|
||||
|
||||
.pf-project-card__cat--academy {
|
||||
background: rgba(251, 146, 60, 0.15);
|
||||
color: #fb923c;
|
||||
border: 1px solid rgba(251, 146, 60, 0.3);
|
||||
}
|
||||
|
||||
.pf-project-card__period {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pf-project-card__title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.pf-project-card__role {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #06b6d4;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pf-project-card__desc {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.6;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pf-project-card__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pf-tech-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(6, 182, 212, 0.08);
|
||||
border: 1px solid rgba(6, 182, 212, 0.2);
|
||||
color: #06b6d4;
|
||||
}
|
||||
|
||||
.pf-tech-tag button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pf-tech-tag button:hover {
|
||||
color: #f9b6b1;
|
||||
}
|
||||
|
||||
.pf-project-card__link {
|
||||
font-size: 12px;
|
||||
color: #06b6d4;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pf-project-card__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.pf-project-card__actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Tech Input ──────────────────────────────────────────────────────── */
|
||||
|
||||
.pf-tech-input {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pf-tech-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── Intro Tab ───────────────────────────────────────────────────────── */
|
||||
|
||||
.pf-intro-tab {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pf-intro-tab__toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pf-intro-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pf-intro-card {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.pf-intro-card.is-main {
|
||||
border-color: rgba(6, 182, 212, 0.4);
|
||||
background: rgba(6, 182, 212, 0.03);
|
||||
}
|
||||
|
||||
.pf-intro-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pf-intro-card__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pf-intro-card__badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(6, 182, 212, 0.2);
|
||||
border: 1px solid rgba(6, 182, 212, 0.4);
|
||||
color: #06b6d4;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.pf-intro-card__date {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pf-intro-card__preview {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.7;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.pf-intro-card__actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Resume View ─────────────────────────────────────────────────────── */
|
||||
|
||||
.pf-resume-overlay {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pf-resume-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pf-resume {
|
||||
background: #fff;
|
||||
color: #1a1a2e;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 40px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
font-family: 'Pretendard', -apple-system, system-ui, sans-serif;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.pf-resume__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #1a1a2e;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.pf-resume__name {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.pf-resume__role {
|
||||
margin: 4px 0 0;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.pf-resume__contact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.pf-resume__section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.pf-resume__section h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #1a1a2e;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.pf-resume__section p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.pf-resume__item {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.pf-resume__item-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pf-resume__item-header strong {
|
||||
font-size: 14px;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.pf-resume__item-header span {
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.pf-resume__period {
|
||||
font-size: 11px !important;
|
||||
color: #888 !important;
|
||||
}
|
||||
|
||||
.pf-resume__item p {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pf-resume__tech {
|
||||
font-size: 11px !important;
|
||||
color: #06b6d4 !important;
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
|
||||
.pf-resume__skills {
|
||||
font-size: 13px !important;
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
/* ── Print ────────────────────────────────────────────────────────────── */
|
||||
|
||||
@media print {
|
||||
body * {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.pf-resume-overlay,
|
||||
.pf-resume-overlay * {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.pf-resume-overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.pf-resume {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.pf-resume__header {
|
||||
border-bottom: 2px solid #000;
|
||||
}
|
||||
|
||||
.pf-resume__section h2 {
|
||||
border-bottom: 1px solid #000;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pf-toolbar {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 90;
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pf-toolbar .button {
|
||||
font-size: 13px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.pf-profile-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.pf-profile-card__photo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.pf-profile-card__name {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.pf-project-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pf-edit-form__row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pf-edit-form input,
|
||||
.pf-edit-form textarea,
|
||||
.pf-edit-form select {
|
||||
font-size: 16px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.pf-edit-form label {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pf-modal__input {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.pf-resume {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.pf-resume__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pf-resume__contact {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.pf-filter-btn {
|
||||
font-size: 13px;
|
||||
padding: 8px 16px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.pf-intro-card__actions .button {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
121
src/pages/portfolio/Portfolio.jsx
Normal file
121
src/pages/portfolio/Portfolio.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import SwipeableView from '../../components/SwipeableView';
|
||||
import usePortfolioApi from './usePortfolioApi';
|
||||
import PasswordModal from './PasswordModal';
|
||||
import ProfileTab from './ProfileTab';
|
||||
import ProjectTab from './ProjectTab';
|
||||
import IntroTab from './IntroTab';
|
||||
import ResumeView from './ResumeView';
|
||||
import './Portfolio.css';
|
||||
|
||||
export default function Portfolio() {
|
||||
const isMobile = useIsMobile();
|
||||
const api = usePortfolioApi();
|
||||
|
||||
const [data, setData] = useState(null);
|
||||
const [intros, setIntros] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [showPwModal, setShowPwModal] = useState(false);
|
||||
const [showResume, setShowResume] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const d = await api.fetchPublic();
|
||||
setData(d);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleEditToggle = () => {
|
||||
if (editing) {
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
if (api.token) {
|
||||
setEditing(true);
|
||||
} else {
|
||||
setShowPwModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuth = async (pw) => {
|
||||
const ok = await api.login(pw);
|
||||
if (ok) {
|
||||
setShowPwModal(false);
|
||||
setEditing(true);
|
||||
try {
|
||||
const list = await api.fetchIntros();
|
||||
setIntros(list);
|
||||
} catch { /* 무시 */ }
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const d = await api.fetchPublic();
|
||||
setData(d);
|
||||
if (api.token) {
|
||||
const list = await api.fetchIntros();
|
||||
setIntros(list);
|
||||
}
|
||||
} catch { /* 무시 */ }
|
||||
}, [api.token]);
|
||||
|
||||
if (loading && !data) return <div className="pf-page"><p className="pf-loading">불러오는 중...</p></div>;
|
||||
if (error && !data) return <div className="pf-page"><p className="pf-error">{error}</p></div>;
|
||||
if (!data) return null;
|
||||
|
||||
if (showResume) {
|
||||
return <ResumeView data={data} onClose={() => setShowResume(false)} />;
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
key: 'profile',
|
||||
label: '프로필',
|
||||
content: <ProfileTab data={data} editing={editing} api={api} onRefresh={refresh} />,
|
||||
},
|
||||
{
|
||||
key: 'projects',
|
||||
label: '프로젝트',
|
||||
content: <ProjectTab projects={data.projects} editing={editing} api={api} onRefresh={refresh} />,
|
||||
},
|
||||
{
|
||||
key: 'intro',
|
||||
label: '자기소개',
|
||||
content: <IntroTab introductions={editing ? intros : (data.main_introduction ? [data.main_introduction] : [])} editing={editing} api={api} onRefresh={refresh} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="pf-page">
|
||||
<div className="pf-toolbar">
|
||||
<button className={`button ${editing ? 'primary' : 'ghost'}`} onClick={handleEditToggle}>
|
||||
{editing ? '편집 완료' : '편집'}
|
||||
</button>
|
||||
<button className="button ghost" onClick={() => setShowResume(true)}>
|
||||
PDF 내보내기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SwipeableView tabs={tabs} />
|
||||
|
||||
<PasswordModal
|
||||
open={showPwModal}
|
||||
onAuth={handleAuth}
|
||||
onClose={() => setShowPwModal(false)}
|
||||
error={api.authError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
251
src/pages/portfolio/ProfileTab.jsx
Normal file
251
src/pages/portfolio/ProfileTab.jsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const CAREER_CATEGORIES = { company: '회사', education: '교육', etc: '기타' };
|
||||
const SKILL_CATEGORIES = { language: '언어', framework: '프레임워크', infra: '인프라', tool: '도구' };
|
||||
|
||||
const emptyCareer = { category: 'company', organization: '', role: '', description: '', start_date: '', end_date: '', sort_order: 0 };
|
||||
const emptySkill = { category: 'language', name: '', level: 3, sort_order: 0 };
|
||||
|
||||
const SKILL_LOGO_SLUGS = {
|
||||
Python: 'python',
|
||||
JavaScript: 'javascript',
|
||||
SQL: 'mysql',
|
||||
'HTML/CSS': 'html5',
|
||||
FastAPI: 'fastapi',
|
||||
React: 'react',
|
||||
Vite: 'vite',
|
||||
Docker: 'docker',
|
||||
'Synology NAS': 'synology',
|
||||
Nginx: 'nginx',
|
||||
Gitea: 'gitea',
|
||||
SQLite: 'sqlite',
|
||||
Linux: 'linux',
|
||||
Git: 'git',
|
||||
'Claude API': 'anthropic',
|
||||
Ollama: 'ollama',
|
||||
'Suno API': 'suno',
|
||||
};
|
||||
|
||||
function SkillLogoNode({ name, slug }) {
|
||||
const [error, setError] = useState(false);
|
||||
if (!slug || error) {
|
||||
return <span className="pf-skill-fallback">{name}</span>;
|
||||
}
|
||||
return (
|
||||
<img
|
||||
className="pf-skill-logo"
|
||||
src={`https://cdn.simpleicons.org/${slug}`}
|
||||
alt={name}
|
||||
title={name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProfileTab({ data, editing, api, onRefresh }) {
|
||||
const { profile, careers, skills } = data;
|
||||
const [editingProfile, setEditingProfile] = useState(null);
|
||||
const [careerForm, setCareerForm] = useState(null);
|
||||
const [skillForm, setSkillForm] = useState(null);
|
||||
|
||||
// ── Profile 편집 ──
|
||||
const startEditProfile = () => setEditingProfile({ ...profile });
|
||||
const saveProfileEdit = async () => {
|
||||
await api.saveProfile(editingProfile);
|
||||
setEditingProfile(null);
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
// ── Career CRUD ──
|
||||
const saveCareer = async () => {
|
||||
if (careerForm.id) {
|
||||
await api.editCareer(careerForm.id, careerForm);
|
||||
} else {
|
||||
await api.addCareer(careerForm);
|
||||
}
|
||||
setCareerForm(null);
|
||||
onRefresh();
|
||||
};
|
||||
const deleteCareer = async (id) => {
|
||||
await api.removeCareer(id);
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
// ── Skill CRUD ──
|
||||
const saveSkill = async () => {
|
||||
if (skillForm.id) {
|
||||
await api.editSkill(skillForm.id, skillForm);
|
||||
} else {
|
||||
await api.addSkill(skillForm);
|
||||
}
|
||||
setSkillForm(null);
|
||||
onRefresh();
|
||||
};
|
||||
const deleteSkill = async (id) => {
|
||||
await api.removeSkill(id);
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const grouped = (items, catMap) => {
|
||||
const groups = {};
|
||||
for (const key of Object.keys(catMap)) groups[key] = [];
|
||||
for (const item of items) {
|
||||
const cat = item.category || Object.keys(catMap)[0];
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(item);
|
||||
}
|
||||
return groups;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pf-profile-tab">
|
||||
{/* ── 프로필 카드 ── */}
|
||||
<div className="pf-profile-card">
|
||||
{editingProfile ? (
|
||||
<div className="pf-edit-form">
|
||||
<label>이름 <input value={editingProfile.name} onChange={(e) => setEditingProfile(p => ({...p, name: e.target.value}))} /></label>
|
||||
<label>이름(영문) <input value={editingProfile.name_en} onChange={(e) => setEditingProfile(p => ({...p, name_en: e.target.value}))} /></label>
|
||||
<label>직함 <input value={editingProfile.role} onChange={(e) => setEditingProfile(p => ({...p, role: e.target.value}))} /></label>
|
||||
<label>직함(영문) <input value={editingProfile.role_en} onChange={(e) => setEditingProfile(p => ({...p, role_en: e.target.value}))} /></label>
|
||||
<label>이메일 <input value={editingProfile.email} onChange={(e) => setEditingProfile(p => ({...p, email: e.target.value}))} /></label>
|
||||
<label>전화번호 <input value={editingProfile.phone} onChange={(e) => setEditingProfile(p => ({...p, phone: e.target.value}))} /></label>
|
||||
<label>GitHub <input value={editingProfile.github_url} onChange={(e) => setEditingProfile(p => ({...p, github_url: e.target.value}))} /></label>
|
||||
<label>블로그 <input value={editingProfile.blog_url} onChange={(e) => setEditingProfile(p => ({...p, blog_url: e.target.value}))} /></label>
|
||||
<label>사진 URL <input value={editingProfile.photo_url} onChange={(e) => setEditingProfile(p => ({...p, photo_url: e.target.value}))} /></label>
|
||||
<label>소개 <textarea value={editingProfile.bio} rows={3} onChange={(e) => setEditingProfile(p => ({...p, bio: e.target.value}))} /></label>
|
||||
<div className="pf-edit-form__actions">
|
||||
<button className="button ghost" onClick={() => setEditingProfile(null)}>취소</button>
|
||||
<button className="button primary" onClick={saveProfileEdit}>저장</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="pf-profile-card__header">
|
||||
{profile.photo_url && <img className="pf-profile-card__photo" src={profile.photo_url} alt="" />}
|
||||
<div>
|
||||
<h2 className="pf-profile-card__name">{profile.name || '이름 미설정'}</h2>
|
||||
{profile.name_en && <p className="pf-profile-card__name-en">{profile.name_en}</p>}
|
||||
<p className="pf-profile-card__role">{profile.role || profile.role_en}</p>
|
||||
</div>
|
||||
</div>
|
||||
{profile.bio && <p className="pf-profile-card__bio">{profile.bio}</p>}
|
||||
<div className="pf-profile-card__links">
|
||||
{profile.email && <a href={`mailto:${profile.email}`}>{profile.email}</a>}
|
||||
{profile.github_url && <a href={profile.github_url} target="_blank" rel="noreferrer">GitHub</a>}
|
||||
{profile.blog_url && <a href={profile.blog_url} target="_blank" rel="noreferrer">Blog</a>}
|
||||
</div>
|
||||
{editing && <button className="button ghost pf-edit-btn" onClick={startEditProfile}>프로필 수정</button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 경력 타임라인 ── */}
|
||||
<div className="pf-section">
|
||||
<div className="pf-section__header">
|
||||
<h3>경력</h3>
|
||||
{editing && <button className="button ghost" onClick={() => setCareerForm({...emptyCareer})}>+ 추가</button>}
|
||||
</div>
|
||||
{careerForm && (
|
||||
<div className="pf-edit-form">
|
||||
<label>구분
|
||||
<select value={careerForm.category} onChange={(e) => setCareerForm(f => ({...f, category: e.target.value}))}>
|
||||
{Object.entries(CAREER_CATEGORIES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>기관명 <input value={careerForm.organization} onChange={(e) => setCareerForm(f => ({...f, organization: e.target.value}))} /></label>
|
||||
<label>직함 <input value={careerForm.role} onChange={(e) => setCareerForm(f => ({...f, role: e.target.value}))} /></label>
|
||||
<label>설명 <textarea value={careerForm.description} rows={2} onChange={(e) => setCareerForm(f => ({...f, description: e.target.value}))} /></label>
|
||||
<div className="pf-edit-form__row">
|
||||
<label>시작 <input type="month" value={careerForm.start_date} onChange={(e) => setCareerForm(f => ({...f, start_date: e.target.value}))} /></label>
|
||||
<label>종료 <input type="month" value={careerForm.end_date} onChange={(e) => setCareerForm(f => ({...f, end_date: e.target.value}))} placeholder="현재" /></label>
|
||||
</div>
|
||||
<div className="pf-edit-form__actions">
|
||||
<button className="button ghost" onClick={() => setCareerForm(null)}>취소</button>
|
||||
<button className="button primary" onClick={saveCareer}>저장</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(grouped(careers, CAREER_CATEGORIES)).map(([cat, items]) =>
|
||||
items.length > 0 && (
|
||||
<div key={cat} className="pf-career-group">
|
||||
<h4 className="pf-career-group__title">{CAREER_CATEGORIES[cat]}</h4>
|
||||
{items.map((c) => (
|
||||
<div key={c.id} className="pf-career-item">
|
||||
<span className="pf-career-item__period">{c.start_date} — {c.end_date || '현재'}</span>
|
||||
<strong className="pf-career-item__role">{c.role}</strong>
|
||||
<span className="pf-career-item__org">{c.organization}</span>
|
||||
{c.description && <p className="pf-career-item__desc">{c.description}</p>}
|
||||
{editing && (
|
||||
<div className="pf-career-item__actions">
|
||||
<button className="button ghost" onClick={() => setCareerForm({...c})}>수정</button>
|
||||
<button className="button ghost" onClick={() => deleteCareer(c.id)}>삭제</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 기술 스택 ── */}
|
||||
<div className="pf-section">
|
||||
<div className="pf-section__header">
|
||||
<h3>기술 스택</h3>
|
||||
{editing && <button className="button ghost" onClick={() => setSkillForm({...emptySkill})}>+ 추가</button>}
|
||||
</div>
|
||||
{skillForm && (
|
||||
<div className="pf-edit-form">
|
||||
<label>구분
|
||||
<select value={skillForm.category} onChange={(e) => setSkillForm(f => ({...f, category: e.target.value}))}>
|
||||
{Object.entries(SKILL_CATEGORIES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>기술명 <input value={skillForm.name} onChange={(e) => setSkillForm(f => ({...f, name: e.target.value}))} /></label>
|
||||
<label>숙련도 (1~5)
|
||||
<input type="range" min={1} max={5} value={skillForm.level} onChange={(e) => setSkillForm(f => ({...f, level: +e.target.value}))} />
|
||||
<span>{skillForm.level}</span>
|
||||
</label>
|
||||
<div className="pf-edit-form__actions">
|
||||
<button className="button ghost" onClick={() => setSkillForm(null)}>취소</button>
|
||||
<button className="button primary" onClick={saveSkill}>저장</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(grouped(skills, SKILL_CATEGORIES)).map(([cat, items]) =>
|
||||
items.length > 0 && (
|
||||
<div key={cat} className="pf-skill-group">
|
||||
<h4 className="pf-skill-group__title">{SKILL_CATEGORIES[cat]}</h4>
|
||||
{editing ? (
|
||||
<div className="pf-skill-group__tags">
|
||||
{items.map((s) => (
|
||||
<span key={s.id} className="pf-skill-tag" data-level={s.level}>
|
||||
{s.name}
|
||||
<span className="pf-skill-tag__actions">
|
||||
<button onClick={() => setSkillForm({...s})}>✎</button>
|
||||
<button onClick={() => deleteSkill(s.id)}>×</button>
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="pf-skill-loop" aria-label={`${SKILL_CATEGORIES[cat]} 기술 스택`}>
|
||||
{items.map((s) => (
|
||||
<SkillLogoNode
|
||||
key={s.id}
|
||||
name={s.name}
|
||||
slug={SKILL_LOGO_SLUGS[s.name]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
src/pages/portfolio/ProjectTab.jsx
Normal file
133
src/pages/portfolio/ProjectTab.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: 'all', label: '전체' },
|
||||
{ key: 'company', label: '회사' },
|
||||
{ key: 'personal', label: '개인' },
|
||||
{ key: 'academy', label: '아카데미' },
|
||||
];
|
||||
|
||||
const emptyProject = {
|
||||
category: 'personal', title: '', description: '', tech_stack: [],
|
||||
role: '', start_date: '', end_date: '', url: '', image_url: '', sort_order: 0,
|
||||
};
|
||||
|
||||
export default function ProjectTab({ projects, editing, api, onRefresh }) {
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [form, setForm] = useState(null);
|
||||
const [techInput, setTechInput] = useState('');
|
||||
|
||||
const filtered = filter === 'all' ? projects : projects.filter(p => p.category === filter);
|
||||
|
||||
const addTech = () => {
|
||||
const tag = techInput.trim();
|
||||
if (tag && !form.tech_stack.includes(tag)) {
|
||||
setForm(f => ({ ...f, tech_stack: [...f.tech_stack, tag] }));
|
||||
}
|
||||
setTechInput('');
|
||||
};
|
||||
|
||||
const removeTech = (tag) => {
|
||||
setForm(f => ({ ...f, tech_stack: f.tech_stack.filter(t => t !== tag) }));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (form.id) {
|
||||
await api.editProject(form.id, form);
|
||||
} else {
|
||||
await api.addProject(form);
|
||||
}
|
||||
setForm(null);
|
||||
setTechInput('');
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const remove = async (id) => {
|
||||
await api.removeProject(id);
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pf-project-tab">
|
||||
{/* 카테고리 필터 */}
|
||||
<div className="pf-filter-bar">
|
||||
{CATEGORIES.map(c => (
|
||||
<button
|
||||
key={c.key}
|
||||
className={`pf-filter-btn${filter === c.key ? ' is-active' : ''}`}
|
||||
onClick={() => setFilter(c.key)}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
{editing && <button className="button ghost" onClick={() => { setForm({...emptyProject}); setTechInput(''); }}>+ 추가</button>}
|
||||
</div>
|
||||
|
||||
{/* 추가/수정 폼 */}
|
||||
{form && (
|
||||
<div className="pf-edit-form">
|
||||
<label>구분
|
||||
<select value={form.category} onChange={(e) => setForm(f => ({...f, category: e.target.value}))}>
|
||||
{CATEGORIES.filter(c => c.key !== 'all').map(c => <option key={c.key} value={c.key}>{c.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>프로젝트명 <input value={form.title} onChange={(e) => setForm(f => ({...f, title: e.target.value}))} /></label>
|
||||
<label>설명 <textarea value={form.description} rows={3} onChange={(e) => setForm(f => ({...f, description: e.target.value}))} /></label>
|
||||
<label>담당 역할 <input value={form.role} onChange={(e) => setForm(f => ({...f, role: e.target.value}))} /></label>
|
||||
<div className="pf-edit-form__row">
|
||||
<label>시작 <input type="month" value={form.start_date} onChange={(e) => setForm(f => ({...f, start_date: e.target.value}))} /></label>
|
||||
<label>종료 <input type="month" value={form.end_date} onChange={(e) => setForm(f => ({...f, end_date: e.target.value}))} placeholder="현재" /></label>
|
||||
</div>
|
||||
<label>URL <input value={form.url} onChange={(e) => setForm(f => ({...f, url: e.target.value}))} /></label>
|
||||
<label>기술 스택
|
||||
<div className="pf-tech-input">
|
||||
<input
|
||||
value={techInput}
|
||||
onChange={(e) => setTechInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTech(); } }}
|
||||
placeholder="기술명 입력 후 Enter"
|
||||
/>
|
||||
<div className="pf-tech-tags">
|
||||
{form.tech_stack.map(t => (
|
||||
<span key={t} className="pf-tech-tag">{t} <button onClick={() => removeTech(t)}>×</button></span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<div className="pf-edit-form__actions">
|
||||
<button className="button ghost" onClick={() => { setForm(null); setTechInput(''); }}>취소</button>
|
||||
<button className="button primary" onClick={save}>저장</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 프로젝트 카드 그리드 */}
|
||||
<div className="pf-project-grid">
|
||||
{filtered.length === 0 && <p className="pf-empty">프로젝트가 없습니다.</p>}
|
||||
{filtered.map(p => (
|
||||
<div key={p.id} className="pf-project-card">
|
||||
<div className="pf-project-card__header">
|
||||
<span className={`pf-project-card__cat pf-project-card__cat--${p.category}`}>{CATEGORIES.find(c => c.key === p.category)?.label}</span>
|
||||
<span className="pf-project-card__period">{p.start_date} — {p.end_date || '현재'}</span>
|
||||
</div>
|
||||
<h4 className="pf-project-card__title">{p.title}</h4>
|
||||
{p.role && <p className="pf-project-card__role">{p.role}</p>}
|
||||
{p.description && <p className="pf-project-card__desc">{p.description}</p>}
|
||||
{p.tech_stack?.length > 0 && (
|
||||
<div className="pf-project-card__tags">
|
||||
{p.tech_stack.map(t => <span key={t} className="pf-tech-tag">{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
{p.url && <a className="pf-project-card__link" href={p.url} target="_blank" rel="noreferrer">링크 →</a>}
|
||||
{editing && (
|
||||
<div className="pf-project-card__actions">
|
||||
<button className="button ghost" onClick={() => { setForm({...p}); setTechInput(''); }}>수정</button>
|
||||
<button className="button ghost" onClick={() => remove(p.id)}>삭제</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/pages/portfolio/ResumeView.jsx
Normal file
82
src/pages/portfolio/ResumeView.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
export default function ResumeView({ data, onClose }) {
|
||||
const { profile, careers, projects, skills, main_introduction } = data;
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pf-resume-overlay">
|
||||
<div className="pf-resume-actions no-print">
|
||||
<button className="button primary" onClick={handlePrint}>PDF 저장 / 인쇄</button>
|
||||
<button className="button ghost" onClick={onClose}>닫기</button>
|
||||
</div>
|
||||
<div className="pf-resume">
|
||||
{/* 헤더 */}
|
||||
<header className="pf-resume__header">
|
||||
<div>
|
||||
<h1 className="pf-resume__name">{profile.name}</h1>
|
||||
<p className="pf-resume__role">{profile.role}</p>
|
||||
</div>
|
||||
<div className="pf-resume__contact">
|
||||
{profile.email && <span>{profile.email}</span>}
|
||||
{profile.phone && <span>{profile.phone}</span>}
|
||||
{profile.github_url && <span>{profile.github_url}</span>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* About */}
|
||||
{(main_introduction?.content || profile.bio) && (
|
||||
<section className="pf-resume__section">
|
||||
<h2>About</h2>
|
||||
<p>{main_introduction?.content || profile.bio}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Experience */}
|
||||
{careers.length > 0 && (
|
||||
<section className="pf-resume__section">
|
||||
<h2>Experience</h2>
|
||||
{careers.map(c => (
|
||||
<div key={c.id} className="pf-resume__item">
|
||||
<div className="pf-resume__item-header">
|
||||
<strong>{c.role}</strong>
|
||||
<span>{c.organization}</span>
|
||||
<span className="pf-resume__period">{c.start_date} — {c.end_date || '현재'}</span>
|
||||
</div>
|
||||
{c.description && <p>{c.description}</p>}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Projects */}
|
||||
{projects.length > 0 && (
|
||||
<section className="pf-resume__section">
|
||||
<h2>Projects</h2>
|
||||
{projects.map(p => (
|
||||
<div key={p.id} className="pf-resume__item">
|
||||
<div className="pf-resume__item-header">
|
||||
<strong>{p.title}</strong>
|
||||
<span className="pf-resume__period">{p.start_date} — {p.end_date || '현재'}</span>
|
||||
</div>
|
||||
{p.description && <p>{p.description}</p>}
|
||||
{p.tech_stack?.length > 0 && (
|
||||
<p className="pf-resume__tech">{p.tech_stack.join(' · ')}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{skills.length > 0 && (
|
||||
<section className="pf-resume__section">
|
||||
<h2>Skills</h2>
|
||||
<p className="pf-resume__skills">{skills.map(s => s.name).join(' · ')}</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
src/pages/portfolio/usePortfolioApi.js
Normal file
100
src/pages/portfolio/usePortfolioApi.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
const BASE = '/api/profile';
|
||||
|
||||
async function apiFetch(path, options = {}) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...options,
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export default function usePortfolioApi() {
|
||||
const [token, setToken] = useState(null);
|
||||
const [authError, setAuthError] = useState('');
|
||||
|
||||
const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
|
||||
const login = useCallback(async (password) => {
|
||||
setAuthError('');
|
||||
try {
|
||||
const data = await apiFetch('/auth', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
setToken(data.token);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setAuthError(err.message);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => setToken(null), []);
|
||||
|
||||
// ── Public ──
|
||||
const fetchPublic = useCallback(() => apiFetch('/public'), []);
|
||||
|
||||
// ── Profile ──
|
||||
const fetchProfile = useCallback(() =>
|
||||
apiFetch('/profile', { headers: authHeaders }), [token]);
|
||||
const saveProfile = useCallback((data) =>
|
||||
apiFetch('/profile', { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
|
||||
|
||||
// ── Careers ──
|
||||
const fetchCareers = useCallback(() =>
|
||||
apiFetch('/careers', { headers: authHeaders }), [token]);
|
||||
const addCareer = useCallback((data) =>
|
||||
apiFetch('/careers', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
|
||||
const editCareer = useCallback((id, data) =>
|
||||
apiFetch(`/careers/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
|
||||
const removeCareer = useCallback((id) =>
|
||||
apiFetch(`/careers/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
|
||||
|
||||
// ── Projects ──
|
||||
const fetchProjects = useCallback(() =>
|
||||
apiFetch('/projects', { headers: authHeaders }), [token]);
|
||||
const addProject = useCallback((data) =>
|
||||
apiFetch('/projects', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
|
||||
const editProject = useCallback((id, data) =>
|
||||
apiFetch(`/projects/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
|
||||
const removeProject = useCallback((id) =>
|
||||
apiFetch(`/projects/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
|
||||
|
||||
// ── Skills ──
|
||||
const fetchSkills = useCallback(() =>
|
||||
apiFetch('/skills', { headers: authHeaders }), [token]);
|
||||
const addSkill = useCallback((data) =>
|
||||
apiFetch('/skills', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
|
||||
const editSkill = useCallback((id, data) =>
|
||||
apiFetch(`/skills/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
|
||||
const removeSkill = useCallback((id) =>
|
||||
apiFetch(`/skills/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
|
||||
|
||||
// ── Introductions ──
|
||||
const fetchIntros = useCallback(() =>
|
||||
apiFetch('/introductions', { headers: authHeaders }), [token]);
|
||||
const addIntro = useCallback((data) =>
|
||||
apiFetch('/introductions', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
|
||||
const editIntro = useCallback((id, data) =>
|
||||
apiFetch(`/introductions/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
|
||||
const removeIntro = useCallback((id) =>
|
||||
apiFetch(`/introductions/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
|
||||
const setMainIntro = useCallback((id) =>
|
||||
apiFetch(`/introductions/${id}/main`, { method: 'PATCH', headers: authHeaders }), [token]);
|
||||
|
||||
return {
|
||||
token, authError, login, logout,
|
||||
fetchPublic,
|
||||
fetchProfile, saveProfile,
|
||||
fetchCareers, addCareer, editCareer, removeCareer,
|
||||
fetchProjects, addProject, editProject, removeProject,
|
||||
fetchSkills, addSkill, editSkill, removeSkill,
|
||||
fetchIntros, addIntro, editIntro, removeIntro, setMainIntro,
|
||||
};
|
||||
}
|
||||
@@ -833,6 +833,17 @@
|
||||
.pf-total-summary__card strong {
|
||||
font-size: 16px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pf-total-summary__card strong.is-fit-sm {
|
||||
font-size: 13px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.pf-total-summary__card strong.is-fit-xs {
|
||||
font-size: 11px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.pf-item-actions {
|
||||
@@ -890,6 +901,22 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.pf-nxt-badge {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(139, 92, 246, 0.45);
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
color: #c4b5fd;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
vertical-align: middle;
|
||||
cursor: help;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pf-edit-row {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
@@ -955,6 +982,14 @@
|
||||
.pf-total-summary__card strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pf-total-summary__card strong.is-fit-sm {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pf-total-summary__card strong.is-fit-xs {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Cash Panel (예수금) ─────────────────────────────────────────── */
|
||||
|
||||
@@ -245,6 +245,9 @@ const Stock = () => {
|
||||
<Link className="button ghost" to="/stock/trade">
|
||||
거래 데스크
|
||||
</Link>
|
||||
<Link className="button ghost" to="/stock/screener">
|
||||
스크리너
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stock-card">
|
||||
|
||||
@@ -4,7 +4,27 @@ import {
|
||||
ResponsiveContainer, AreaChart, Area, XAxis, YAxis,
|
||||
Tooltip as ChartTooltip,
|
||||
} from 'recharts';
|
||||
import { formatNumber, formatPercent, toNumeric, profitColorClass } from '../stockUtils';
|
||||
import { formatNumber, formatPercent, toNumeric, profitColorClass, numFitClass } from '../stockUtils';
|
||||
|
||||
const formatPriceTime = (iso) => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const PriceSessionBadge = ({ session, asOf }) => {
|
||||
if (session !== 'NXT_AFTER' && session !== 'NXT_PRE') return null;
|
||||
const isPre = session === 'NXT_PRE';
|
||||
const label = isPre ? 'NXT 프리' : 'NXT';
|
||||
const desc = isPre ? 'NXT 프리마켓 거래가' : 'NXT 야간거래 (15:30~20:00)';
|
||||
const time = formatPriceTime(asOf);
|
||||
return (
|
||||
<span className="pf-nxt-badge" title={time ? `${desc} · ${time}` : desc}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
|
||||
<>
|
||||
@@ -140,32 +160,38 @@ const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
|
||||
{ label: '총 평가', value: pf.portfolioSummary.total_eval },
|
||||
{ label: '총 손익', value: pf.portfolioSummary.total_profit, isProfit: true },
|
||||
{ label: '수익률', value: pf.portfolioSummary.total_profit_rate, isRate: true },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="pf-total-summary__card">
|
||||
<span>{s.label}</span>
|
||||
<strong
|
||||
className={
|
||||
s.isProfit || s.isRate
|
||||
? `stock-profit ${profitColorClass(toNumeric(s.value))}`
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{s.isRate ? formatPercent(s.value) : formatNumber(s.value)}
|
||||
</strong>
|
||||
</div>
|
||||
))}
|
||||
{pf.totalCash != null && (
|
||||
<div className="pf-total-summary__card is-cash">
|
||||
<span>예수금 합계</span>
|
||||
<strong>{formatNumber(pf.totalCash)}원</strong>
|
||||
</div>
|
||||
)}
|
||||
{pf.totalAssets != null && (
|
||||
<div className="pf-total-summary__card is-assets">
|
||||
<span>총 자산</span>
|
||||
<strong>{formatNumber(pf.totalAssets)}원</strong>
|
||||
</div>
|
||||
)}
|
||||
].map((s) => {
|
||||
const display = s.isRate ? formatPercent(s.value) : formatNumber(s.value);
|
||||
const profitCls = s.isProfit || s.isRate
|
||||
? `stock-profit ${profitColorClass(toNumeric(s.value))}`
|
||||
: '';
|
||||
return (
|
||||
<div key={s.label} className="pf-total-summary__card">
|
||||
<span>{s.label}</span>
|
||||
<strong className={`${profitCls} ${numFitClass(display)}`.trim()}>
|
||||
{display}
|
||||
</strong>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{pf.totalCash != null && (() => {
|
||||
const display = `${formatNumber(pf.totalCash)}원`;
|
||||
return (
|
||||
<div className="pf-total-summary__card is-cash">
|
||||
<span>예수금 합계</span>
|
||||
<strong className={numFitClass(display)}>{display}</strong>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{pf.totalAssets != null && (() => {
|
||||
const display = `${formatNumber(pf.totalAssets)}원`;
|
||||
return (
|
||||
<div className="pf-total-summary__card is-assets">
|
||||
<span>총 자산</span>
|
||||
<strong className={numFitClass(display)}>{display}</strong>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -521,6 +547,10 @@ const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
|
||||
{item.current_price != null
|
||||
? formatNumber(item.current_price)
|
||||
: '조회 실패'}
|
||||
<PriceSessionBadge
|
||||
session={item.price_session}
|
||||
asOf={item.price_as_of}
|
||||
/>
|
||||
</strong>
|
||||
</div>
|
||||
<div className="stock-holdings__metric">
|
||||
|
||||
82
src/pages/stock/screener/Screener.css
Normal file
82
src/pages/stock/screener/Screener.css
Normal file
@@ -0,0 +1,82 @@
|
||||
.screener-page {
|
||||
padding: 24px;
|
||||
color: var(--text, #e5e7eb);
|
||||
background: var(--bg, #0b0f17);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.screener-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.screener-header h1 {
|
||||
font-size: 28px;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.screener-header .meta {
|
||||
color: #9ca3af;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.screener-header nav a {
|
||||
margin-left: 12px;
|
||||
color: #9ca3af;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.screener-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr 280px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.screener-page { padding: 16px; }
|
||||
.screener-header { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
.screener-grid { grid-template-columns: 1fr; gap: 16px; }
|
||||
.screener-left { order: 1; }
|
||||
.screener-center { order: 2; }
|
||||
.screener-right { order: 3; }
|
||||
.screener-table { font-size: 12px; }
|
||||
.screener-table th, .screener-table td { padding: 6px 4px; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.screener-page { padding: 12px; }
|
||||
.screener-card { padding: 12px; }
|
||||
}
|
||||
|
||||
.screener-loading { padding: 80px; text-align: center; color: #9ca3af; }
|
||||
|
||||
.screener-card {
|
||||
background: #0f1623;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.screener-card h3 { margin: 0 0 12px 0; font-size: 15px; }
|
||||
|
||||
.node-card {
|
||||
background: #0a0f1a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.node-card-header { font-weight: 500; margin-bottom: 6px; }
|
||||
.weight-row, .param-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
|
||||
|
||||
.screener-table {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.screener-table th { text-align: left; padding: 8px; background: #0a0f1a; color: #9ca3af; font-weight: 500; border-bottom: 1px solid #1f2937; }
|
||||
.screener-table td { padding: 8px; border-bottom: 1px solid #1a2230; vertical-align: middle; }
|
||||
.screener-table tr:hover { background: #0a0f1a; }
|
||||
71
src/pages/stock/screener/Screener.jsx
Normal file
71
src/pages/stock/screener/Screener.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import './Screener.css';
|
||||
|
||||
import { useScreenerMeta } from './hooks/useScreenerMeta';
|
||||
import { useScreenerSettings } from './hooks/useScreenerSettings';
|
||||
import { useScreenerRun } from './hooks/useScreenerRun';
|
||||
import { useScreenerHistory } from './hooks/useScreenerHistory';
|
||||
|
||||
import GatePanel from './components/GatePanel';
|
||||
import NodePanel from './components/NodePanel';
|
||||
import GlobalControls from './components/GlobalControls';
|
||||
import ResultTable from './components/ResultTable';
|
||||
import TelegramPreview from './components/TelegramPreview';
|
||||
import RunHistoryList from './components/RunHistoryList';
|
||||
|
||||
export default function Screener() {
|
||||
const { meta, loading: metaLoading } = useScreenerMeta();
|
||||
const { settings, dirty, setLocal, save } = useScreenerSettings();
|
||||
const { result, running, runPreview, runSave } = useScreenerRun();
|
||||
const { runs, runs_loading, selectRun, selectedRun } = useScreenerHistory();
|
||||
|
||||
const activeResult = selectedRun || result;
|
||||
|
||||
if (metaLoading || !meta || !settings) {
|
||||
return <div className="screener-loading">로딩 중…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="screener-page">
|
||||
<header className="screener-header">
|
||||
<div>
|
||||
<h1>스크리너</h1>
|
||||
<p className="meta">
|
||||
최근 자동 잡: {runs?.find(r => r.mode === 'auto')?.asof ?? '-'}
|
||||
· 분석 기준일: {activeResult?.asof ?? settings.asof ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<nav>
|
||||
<Link to="/stock">시장</Link>
|
||||
<Link to="/stock/trade">트레이드</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className="screener-grid">
|
||||
<aside className="screener-left">
|
||||
<GatePanel meta={meta.gate_nodes[0]} value={settings.gate_params} onChange={(p) => setLocal({...settings, gate_params: p})} />
|
||||
<NodePanel meta={meta.score_nodes} weights={settings.weights} params={settings.node_params}
|
||||
onWeights={(w) => setLocal({...settings, weights: w})}
|
||||
onParams={(p) => setLocal({...settings, node_params: p})} />
|
||||
<GlobalControls settings={settings} setSettings={setLocal}
|
||||
onRun={() => runPreview(settings)}
|
||||
onSave={() => runSave(settings)}
|
||||
onPersist={save}
|
||||
dirty={dirty}
|
||||
running={running} />
|
||||
</aside>
|
||||
|
||||
<main className="screener-center">
|
||||
<ResultTable result={activeResult} />
|
||||
<TelegramPreview payload={activeResult?.telegram_payload} />
|
||||
</main>
|
||||
|
||||
<aside className="screener-right">
|
||||
<RunHistoryList runs={runs} loading={runs_loading} onSelect={selectRun}
|
||||
selectedId={selectedRun?.meta?.id} />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/pages/stock/screener/components/GatePanel.jsx
Normal file
41
src/pages/stock/screener/components/GatePanel.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
export default function GatePanel({ meta, value, onChange }) {
|
||||
if (!meta) return null;
|
||||
const props = meta.param_schema?.properties || {};
|
||||
return (
|
||||
<section className="screener-card">
|
||||
<h3>{meta.label}</h3>
|
||||
<p style={{ fontSize: 11, color: '#9ca3af', marginTop: 0 }}>
|
||||
통과 조건 — 통과한 종목만 점수 노드에 전달
|
||||
</p>
|
||||
{Object.entries(props).map(([key, prop]) => (
|
||||
<GateField key={key} paramKey={key} prop={prop}
|
||||
value={value?.[key] ?? meta.default_params?.[key]}
|
||||
onChange={(v) => onChange({ ...value, [key]: v })} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GateField({ paramKey, prop, value, onChange }) {
|
||||
if (prop.type === 'integer') {
|
||||
return (
|
||||
<div className="param-row">
|
||||
<label style={{ width: 160, fontSize: 12 }}>{paramKey}</label>
|
||||
<input type="number" value={value ?? ''}
|
||||
min={prop.minimum} onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||
style={{ flex: 1 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (prop.type === 'boolean') {
|
||||
return (
|
||||
<div className="param-row">
|
||||
<label>
|
||||
<input type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked)} />
|
||||
<span style={{ marginLeft: 6, fontSize: 12 }}>{paramKey}</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
43
src/pages/stock/screener/components/GlobalControls.jsx
Normal file
43
src/pages/stock/screener/components/GlobalControls.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
export default function GlobalControls({ settings, setSettings, onRun, onSave, onPersist, dirty, running }) {
|
||||
return (
|
||||
<section className="screener-card">
|
||||
<h3>실행 옵션</h3>
|
||||
<div className="param-row">
|
||||
<label style={{ width: 80, fontSize: 12 }}>Top N</label>
|
||||
<input type="number" value={settings.top_n}
|
||||
onChange={(e) => setSettings({ ...settings, top_n: parseInt(e.target.value, 10) })}
|
||||
min={5} max={100} style={{ width: 80 }} />
|
||||
</div>
|
||||
<div className="param-row">
|
||||
<label style={{ width: 80, fontSize: 12 }}>ATR window</label>
|
||||
<input type="number" value={settings.atr_window}
|
||||
onChange={(e) => setSettings({ ...settings, atr_window: parseInt(e.target.value, 10) })}
|
||||
min={5} max={50} style={{ width: 80 }} />
|
||||
</div>
|
||||
<div className="param-row">
|
||||
<label style={{ width: 80, fontSize: 12 }}>손절 ×ATR</label>
|
||||
<input type="number" value={settings.atr_stop_mult} step={0.1}
|
||||
onChange={(e) => setSettings({ ...settings, atr_stop_mult: parseFloat(e.target.value) })}
|
||||
min={0.5} max={5} style={{ width: 80 }} />
|
||||
</div>
|
||||
<div className="param-row">
|
||||
<label style={{ width: 80, fontSize: 12 }}>R:R 비율</label>
|
||||
<input type="number" value={settings.rr_ratio} step={0.1}
|
||||
onChange={(e) => setSettings({ ...settings, rr_ratio: parseFloat(e.target.value) })}
|
||||
min={1} max={10} style={{ width: 80 }} />
|
||||
</div>
|
||||
<button onClick={onRun} disabled={running}
|
||||
style={{ marginTop: 16, width: '100%', padding: 10, background: '#fbbf24', color: '#0b0f17', border: 'none', borderRadius: 6, fontWeight: 600 }}>
|
||||
{running ? '실행 중…' : '지금 실행 (미리보기)'}
|
||||
</button>
|
||||
<button onClick={onSave} disabled={running}
|
||||
style={{ marginTop: 8, width: '100%', padding: 8 }}>
|
||||
스냅샷 저장
|
||||
</button>
|
||||
<button onClick={onPersist} disabled={!dirty}
|
||||
style={{ marginTop: 8, width: '100%', padding: 8, opacity: dirty ? 1 : 0.5 }}>
|
||||
설정 저장 (디폴트 갱신)
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
80
src/pages/stock/screener/components/NodeCard.jsx
Normal file
80
src/pages/stock/screener/components/NodeCard.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function NodeCard({ meta, weight, params, onWeightChange, onParamsChange }) {
|
||||
const enabled = (weight ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="node-card" style={{ opacity: enabled ? 1 : 0.6 }}>
|
||||
<div className="node-card-header">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => onWeightChange(e.target.checked ? (weight || 1) : 0)}
|
||||
/>
|
||||
<span>{meta.label}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="node-card-body">
|
||||
<div className="weight-row">
|
||||
<span style={{ width: 50, fontSize: 12, color: '#9ca3af' }}>가중치</span>
|
||||
<input
|
||||
type="range" min="0" max="3" step="0.1"
|
||||
value={weight ?? 0}
|
||||
disabled={!enabled}
|
||||
onChange={(e) => onWeightChange(parseFloat(e.target.value))}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: 32, textAlign: 'right', fontSize: 12 }}>{(weight ?? 0).toFixed(1)}</span>
|
||||
</div>
|
||||
{Object.entries(meta.param_schema?.properties || {}).map(([key, prop]) => (
|
||||
<ParamRow
|
||||
key={key}
|
||||
paramKey={key}
|
||||
prop={prop}
|
||||
value={params?.[key] ?? meta.default_params?.[key]}
|
||||
disabled={!enabled}
|
||||
onChange={(v) => onParamsChange({ ...params, [key]: v })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ParamRow({ paramKey, prop, value, disabled, onChange }) {
|
||||
const type = prop.type;
|
||||
if (type === 'integer' || type === 'number') {
|
||||
return (
|
||||
<div className="param-row">
|
||||
<span style={{ width: 100, fontSize: 12 }}>{paramKey}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={prop.minimum} max={prop.maximum}
|
||||
step={type === 'integer' ? 1 : 0.1}
|
||||
value={value ?? ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(type === 'integer' ? parseInt(e.target.value, 10) : parseFloat(e.target.value))}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (type === 'boolean') {
|
||||
return (
|
||||
<div className="param-row">
|
||||
<label>
|
||||
<input type="checkbox" checked={!!value} disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)} />
|
||||
<span style={{ marginLeft: 6, fontSize: 12 }}>{paramKey}</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// object/array는 MVP에서 read-only JSON 표시 (RsRating의 weights 등)
|
||||
return (
|
||||
<div className="param-row" style={{ fontSize: 11, color: '#9ca3af' }}>
|
||||
{paramKey}: <code>{JSON.stringify(value)}</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/pages/stock/screener/components/NodePanel.jsx
Normal file
21
src/pages/stock/screener/components/NodePanel.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import NodeCard from './NodeCard';
|
||||
|
||||
export default function NodePanel({ meta, weights, params, onWeights, onParams }) {
|
||||
return (
|
||||
<section className="screener-card">
|
||||
<h3>점수 노드 ({meta.length})</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{meta.map((m) => (
|
||||
<NodeCard
|
||||
key={m.name}
|
||||
meta={m}
|
||||
weight={weights[m.name]}
|
||||
params={params[m.name]}
|
||||
onWeightChange={(w) => onWeights({ ...weights, [m.name]: w })}
|
||||
onParamsChange={(p) => onParams({ ...params, [m.name]: p })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
54
src/pages/stock/screener/components/ResultTable.jsx
Normal file
54
src/pages/stock/screener/components/ResultTable.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import ScoreChips from './ScoreChips';
|
||||
|
||||
export default function ResultTable({ result }) {
|
||||
if (!result) {
|
||||
return (
|
||||
<section className="screener-card">
|
||||
<p style={{ color: '#9ca3af' }}>아직 결과 없음. "지금 실행"을 눌러보세요.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="screener-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
Top {result.top_n} · 통과 {result.survivors_count} · {result.asof}
|
||||
</h3>
|
||||
{result.warnings?.length > 0 && (
|
||||
<div style={{
|
||||
background: '#7c2d12', color: '#fde68a', padding: '4px 10px',
|
||||
borderRadius: 4, fontSize: 12,
|
||||
}}>
|
||||
⚠ {result.warnings.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto', marginTop: 12 }}>
|
||||
<table className="screener-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>종목</th><th>총점</th><th>노드</th>
|
||||
<th>진입</th><th>손절</th><th>익절</th><th>R%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(result.results || []).map((r) => (
|
||||
<tr key={r.ticker}>
|
||||
<td>{r.rank}</td>
|
||||
<td>{r.name}<br /><span style={{ fontSize: 11, color: '#9ca3af' }}>{r.ticker}</span></td>
|
||||
<td style={{ fontWeight: 600 }}>{r.total_score?.toFixed(1)}</td>
|
||||
<td><ScoreChips scores={r.scores} /></td>
|
||||
<td>{r.entry_price?.toLocaleString?.()}</td>
|
||||
<td>{r.stop_price?.toLocaleString?.()}</td>
|
||||
<td>{r.target_price?.toLocaleString?.()}</td>
|
||||
<td>{r.r_pct?.toFixed?.(1)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
17
src/pages/stock/screener/components/RunHistoryList.jsx
Normal file
17
src/pages/stock/screener/components/RunHistoryList.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export default function RunHistoryList({ runs, loading, onSelect, selectedId }) {
|
||||
if (loading) return <section className="screener-card"><p>로딩…</p></section>;
|
||||
return (
|
||||
<section className="screener-card">
|
||||
<h3>최근 실행</h3>
|
||||
<ul style={{listStyle:'none', padding:0, margin:0, fontSize:13}}>
|
||||
{(runs || []).map((r) => (
|
||||
<li key={r.id} style={{padding:'6px 0', borderBottom:'1px solid #1f2937', cursor:'pointer',
|
||||
color: selectedId === r.id ? '#fbbf24' : '#e5e7eb'}}
|
||||
onClick={() => onSelect(r.id)}>
|
||||
{r.asof} · {r.mode}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
32
src/pages/stock/screener/components/ScoreChips.jsx
Normal file
32
src/pages/stock/screener/components/ScoreChips.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
const NODE_ICONS = {
|
||||
foreign_buy: { icon: '👤', label: '외국인' },
|
||||
volume_surge: { icon: '⚡', label: '거래량' },
|
||||
momentum: { icon: '🚀', label: '모멘텀' },
|
||||
high52w: { icon: '🆙', label: '52w고' },
|
||||
rs_rating: { icon: '💪', label: 'RS' },
|
||||
ma_alignment: { icon: '📈', label: '정배열' },
|
||||
vcp_lite: { icon: '🌀', label: 'VCP' },
|
||||
};
|
||||
|
||||
export default function ScoreChips({ scores }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{Object.entries(scores || {}).map(([name, s]) => {
|
||||
const meta = NODE_ICONS[name];
|
||||
if (!meta) return null;
|
||||
const active = s >= 70;
|
||||
return (
|
||||
<span key={name}
|
||||
title={`${meta.label}: ${s.toFixed?.(0) ?? s}`}
|
||||
style={{
|
||||
padding: '2px 6px', borderRadius: 4, fontSize: 11,
|
||||
background: active ? '#fbbf24' : '#1f2937',
|
||||
color: active ? '#0b0f17' : '#9ca3af',
|
||||
}}>
|
||||
{meta.icon}{Math.round(s)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
src/pages/stock/screener/components/TelegramPreview.jsx
Normal file
9
src/pages/stock/screener/components/TelegramPreview.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export default function TelegramPreview({ payload }) {
|
||||
if (!payload) return null;
|
||||
return (
|
||||
<section className="screener-card">
|
||||
<h3>텔레그램 미리보기</h3>
|
||||
<pre style={{whiteSpace:'pre-wrap', fontFamily:'monospace', fontSize:12}}>{payload.text}</pre>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
32
src/pages/stock/screener/hooks/useScreenerHistory.js
Normal file
32
src/pages/stock/screener/hooks/useScreenerHistory.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { listScreenerRuns, getScreenerRun } from '../../../../api';
|
||||
|
||||
export function useScreenerHistory() {
|
||||
const [runs, setRuns] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedRun, setSelectedRun] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
listScreenerRuns(30).then((r) => { setRuns(r); setLoading(false); });
|
||||
}, []);
|
||||
|
||||
async function selectRun(id) {
|
||||
if (!id) { setSelectedRun(null); return; }
|
||||
const detail = await getScreenerRun(id);
|
||||
setSelectedRun({
|
||||
asof: detail.meta.asof,
|
||||
mode: detail.meta.mode,
|
||||
status: detail.meta.status,
|
||||
run_id: detail.meta.id,
|
||||
survivors_count: detail.meta.survivors_count,
|
||||
weights: detail.meta.weights,
|
||||
top_n: detail.meta.top_n,
|
||||
results: detail.results,
|
||||
telegram_payload: null,
|
||||
warnings: [],
|
||||
meta: detail.meta,
|
||||
});
|
||||
}
|
||||
|
||||
return { runs, runs_loading: loading, selectedRun, selectRun };
|
||||
}
|
||||
11
src/pages/stock/screener/hooks/useScreenerMeta.js
Normal file
11
src/pages/stock/screener/hooks/useScreenerMeta.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getScreenerNodes } from '../../../../api';
|
||||
|
||||
export function useScreenerMeta() {
|
||||
const [meta, setMeta] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
getScreenerNodes().then((m) => { setMeta(m); setLoading(false); });
|
||||
}, []);
|
||||
return { meta, loading };
|
||||
}
|
||||
31
src/pages/stock/screener/hooks/useScreenerRun.js
Normal file
31
src/pages/stock/screener/hooks/useScreenerRun.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useState } from 'react';
|
||||
import { runScreener } from '../../../../api';
|
||||
|
||||
export function useScreenerRun() {
|
||||
const [result, setResult] = useState(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
async function call(mode, settings) {
|
||||
setRunning(true);
|
||||
try {
|
||||
const body = {
|
||||
mode,
|
||||
weights: settings.weights,
|
||||
node_params: settings.node_params,
|
||||
gate_params: settings.gate_params,
|
||||
top_n: settings.top_n,
|
||||
};
|
||||
const r = await runScreener(body);
|
||||
setResult(r);
|
||||
return r;
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result, running,
|
||||
runPreview: (s) => call('preview', s),
|
||||
runSave: (s) => call('manual_save', s),
|
||||
};
|
||||
}
|
||||
26
src/pages/stock/screener/hooks/useScreenerSettings.js
Normal file
26
src/pages/stock/screener/hooks/useScreenerSettings.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getScreenerSettings, saveScreenerSettings } from '../../../../api';
|
||||
|
||||
export function useScreenerSettings() {
|
||||
const [remote, setRemote] = useState(null);
|
||||
const [local, setLocal] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
getScreenerSettings().then((s) => { setRemote(s); setLocal(s); });
|
||||
}, []);
|
||||
|
||||
const dirty = remote && local && JSON.stringify(remote) !== JSON.stringify(local);
|
||||
|
||||
async function save() {
|
||||
if (!local) return;
|
||||
const saved = await saveScreenerSettings({
|
||||
weights: local.weights, node_params: local.node_params, gate_params: local.gate_params,
|
||||
top_n: local.top_n, rr_ratio: local.rr_ratio,
|
||||
atr_window: local.atr_window, atr_stop_mult: local.atr_stop_mult,
|
||||
});
|
||||
setRemote(saved);
|
||||
setLocal(saved);
|
||||
}
|
||||
|
||||
return { settings: local, dirty, setLocal, save };
|
||||
}
|
||||
@@ -71,6 +71,13 @@ export const profitColorClass = (numericValue) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
export const numFitClass = (text) => {
|
||||
const len = String(text ?? '').length;
|
||||
if (len >= 13) return 'is-fit-xs';
|
||||
if (len >= 10) return 'is-fit-sm';
|
||||
return '';
|
||||
};
|
||||
|
||||
export const getVixLabel = (vix) => {
|
||||
if (vix < 12) return '극히 낮음 (안일 주의)';
|
||||
if (vix < 20) return '정상 (안정적)';
|
||||
|
||||
@@ -1078,6 +1078,28 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sub-form-hint {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
opacity: 0.7;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sub-profile-hint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: color-mix(in srgb, var(--accent-cyan) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent-cyan) 25%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
margin: 0 16px 4px;
|
||||
}
|
||||
.sub-profile-hint__icon { flex-shrink: 0; font-size: 14px; }
|
||||
|
||||
.sub-form-input {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--line);
|
||||
@@ -1178,3 +1200,457 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* === 신규: 자치구 5티어 + district 뱃지 (다크/네온 테마) =========== */
|
||||
.sub-chip--district {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--line);
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.sub-chip--tier {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
/* 페이지 accent 컬러 팔레트와 정렬: rose / orange / mint / cyan / purple */
|
||||
.sub-chip--tier-S {
|
||||
background: rgba(244, 63, 94, 0.14);
|
||||
color: #fda4af;
|
||||
border: 1px solid rgba(244, 63, 94, 0.4);
|
||||
box-shadow: 0 0 12px rgba(244, 63, 94, 0.18);
|
||||
}
|
||||
.sub-chip--tier-A {
|
||||
background: rgba(251, 146, 60, 0.14);
|
||||
color: #fdba74;
|
||||
border: 1px solid rgba(251, 146, 60, 0.4);
|
||||
box-shadow: 0 0 12px rgba(251, 146, 60, 0.16);
|
||||
}
|
||||
.sub-chip--tier-B {
|
||||
background: rgba(52, 211, 153, 0.14);
|
||||
color: #6ee7b7;
|
||||
border: 1px solid rgba(52, 211, 153, 0.4);
|
||||
box-shadow: 0 0 12px rgba(52, 211, 153, 0.16);
|
||||
}
|
||||
.sub-chip--tier-C {
|
||||
background: rgba(56, 189, 248, 0.14);
|
||||
color: #7dd3fc;
|
||||
border: 1px solid rgba(56, 189, 248, 0.4);
|
||||
box-shadow: 0 0 12px rgba(56, 189, 248, 0.16);
|
||||
}
|
||||
.sub-chip--tier-D {
|
||||
background: rgba(192, 132, 252, 0.14);
|
||||
color: #d8b4fe;
|
||||
border: 1px solid rgba(192, 132, 252, 0.4);
|
||||
box-shadow: 0 0 12px rgba(192, 132, 252, 0.16);
|
||||
}
|
||||
|
||||
/* === 신규: DistrictTierEditor (다크/glass + rose accent) =========== */
|
||||
.dte-pool {
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
background: var(--surface-card);
|
||||
transition: background 0.2s var(--ease-out), border-color 0.2s var(--ease-out), box-shadow 0.2s var(--ease-out);
|
||||
}
|
||||
.dte-pool--over {
|
||||
background: rgba(244, 63, 94, 0.06);
|
||||
border-color: rgba(244, 63, 94, 0.5);
|
||||
border-style: solid;
|
||||
box-shadow: 0 0 24px rgba(244, 63, 94, 0.12), inset 0 0 24px rgba(244, 63, 94, 0.06);
|
||||
}
|
||||
.dte-pool__title {
|
||||
margin: 0 0 10px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
color: var(--accent-subscription);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
}
|
||||
.dte-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.dte-chip {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
transition: background 0.15s var(--ease-out), border-color 0.15s var(--ease-out), transform 0.15s var(--ease-out), box-shadow 0.15s var(--ease-out);
|
||||
}
|
||||
.dte-chip:hover {
|
||||
border-color: rgba(244, 63, 94, 0.4);
|
||||
background: rgba(244, 63, 94, 0.08);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 12px rgba(244, 63, 94, 0.18);
|
||||
}
|
||||
.dte-chip:active { cursor: grabbing; transform: translateY(0); }
|
||||
.dte-chip__remove {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text-muted);
|
||||
margin-left: 6px;
|
||||
padding: 0 2px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
transition: color 0.15s, opacity 0.15s;
|
||||
}
|
||||
.dte-chip__remove:hover { color: var(--accent-subscription); opacity: 1; }
|
||||
|
||||
.dte-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.dte-zone {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px;
|
||||
min-height: 140px;
|
||||
background: var(--surface-card);
|
||||
transition: background 0.2s var(--ease-out), border-color 0.2s var(--ease-out), box-shadow 0.2s var(--ease-out);
|
||||
}
|
||||
.dte-zone--over {
|
||||
background: rgba(244, 63, 94, 0.06);
|
||||
border-color: rgba(244, 63, 94, 0.5);
|
||||
box-shadow: 0 0 24px rgba(244, 63, 94, 0.12), inset 0 0 24px rgba(244, 63, 94, 0.06);
|
||||
}
|
||||
.dte-zone__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 10px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.dte-zone__weight {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.dte-zone__chips {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* 모바일 read-only 뷰 */
|
||||
.dte-row {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line-subtle);
|
||||
}
|
||||
.dte-row:last-of-type { border-bottom: 0; }
|
||||
.dte-row__list {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.dte-empty {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
}
|
||||
.dte-mobile-hint {
|
||||
margin: 8px 0 0;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--line-subtle);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* === 신규: NotificationSettings (다크/rose glow) =================== */
|
||||
.ns-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.ns-row--column {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
.ns-row__label {
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.ns-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.sub-toggle {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.25s var(--ease-out), border-color 0.25s var(--ease-out), box-shadow 0.25s var(--ease-out);
|
||||
margin: 0;
|
||||
}
|
||||
.sub-toggle::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--text-dim);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.25s var(--ease-spring), background 0.25s var(--ease-out);
|
||||
}
|
||||
.sub-toggle:checked {
|
||||
background: rgba(244, 63, 94, 0.25);
|
||||
border-color: rgba(244, 63, 94, 0.5);
|
||||
box-shadow: 0 0 16px rgba(244, 63, 94, 0.3);
|
||||
}
|
||||
.sub-toggle:checked::before {
|
||||
transform: translateX(20px);
|
||||
background: var(--accent-subscription);
|
||||
box-shadow: 0 0 8px rgba(244, 63, 94, 0.6);
|
||||
}
|
||||
.sub-toggle__label {
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
input.sub-toggle:checked + .sub-toggle__label { color: var(--accent-subscription); }
|
||||
|
||||
.ns-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
margin: 6px 0 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ns-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--accent-subscription);
|
||||
border: 2px solid var(--bg-secondary);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 12px rgba(244, 63, 94, 0.5);
|
||||
transition: transform 0.15s var(--ease-out), box-shadow 0.15s var(--ease-out);
|
||||
}
|
||||
.ns-slider::-webkit-slider-thumb:hover { transform: scale(1.15); box-shadow: 0 0 18px rgba(244, 63, 94, 0.7); }
|
||||
.ns-slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--accent-subscription);
|
||||
border: 2px solid var(--bg-secondary);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 12px rgba(244, 63, 94, 0.5);
|
||||
}
|
||||
.ns-slider:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ns-slider:disabled::-webkit-slider-thumb { background: var(--text-muted); box-shadow: none; }
|
||||
.ns-scale {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.ns-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.6;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface-card);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 2px solid var(--accent-subscription);
|
||||
}
|
||||
|
||||
/* === 신규: 매칭 분석 섹션 (다크/glass) ============================== */
|
||||
.sub-match-analysis {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px 20px;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sub-match-analysis::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, var(--accent-subscription), transparent);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.sub-match-analysis__score {
|
||||
font-family: var(--font-display);
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-subscription);
|
||||
letter-spacing: -0.02em;
|
||||
text-shadow: 0 0 24px rgba(244, 63, 94, 0.35);
|
||||
}
|
||||
.sub-match-analysis__reasons {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
.sub-match-analysis__reasons li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
.sub-match-analysis__reasons li::marker {
|
||||
color: var(--accent-subscription);
|
||||
}
|
||||
.sub-match-analysis__elig {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 모바일 dte-grid → 1칼럼 */
|
||||
@media (max-width: 767px) {
|
||||
.dte-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* === 신규: 알림 대상 카운트 뱃지 ===================================== */
|
||||
.ns-pass-count {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 212, 255, 0.12);
|
||||
color: #00d4ff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ns-pass-count strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* === 캘린더 뷰 ========================================================= */
|
||||
.sub-calendar {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.sub-calendar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
.sub-calendar__weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.sub-calendar__weekday {
|
||||
text-align: center;
|
||||
padding: 6px 0;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sub-calendar__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
.sub-calendar__day {
|
||||
min-height: 58px;
|
||||
padding: 5px 4px 4px;
|
||||
border-right: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
cursor: default;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.sub-calendar__day:nth-child(7n) { border-right: none; }
|
||||
.sub-calendar__day.is-empty { background: var(--bg-tertiary); opacity: 0.35; }
|
||||
.sub-calendar__day.has-items { cursor: pointer; }
|
||||
.sub-calendar__day.has-items:hover { background: var(--surface-raised); }
|
||||
.sub-calendar__day.is-today .sub-calendar__day-num {
|
||||
background: var(--accent-cyan, #00d4ff);
|
||||
color: var(--bg-primary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.sub-calendar__day-num {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sub-calendar__dots {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.sub-calendar__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sub-calendar__more {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
import DistrictTierEditor from './components/DistrictTierEditor';
|
||||
import NotificationSettings from './components/NotificationSettings';
|
||||
import './Subscription.css';
|
||||
|
||||
// ── 상수 ───────────────────────────────────────────────────────────────────────
|
||||
@@ -30,9 +32,23 @@ const DEFAULT_PROFILE = {
|
||||
has_newborn: false, is_first_home: false, income_level: '',
|
||||
preferred_regions: '', preferred_types: '',
|
||||
min_area: '', max_area: '', max_price: '',
|
||||
// 신규 (자치구 5티어 + 알림 설정)
|
||||
preferred_districts: {},
|
||||
min_match_score: 70,
|
||||
notify_enabled: true,
|
||||
};
|
||||
|
||||
// ── 유틸 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 매칭 reasons에서 자치구 티어를 추출 ("자치구 S티어: 강남구 (+25)" → "S")
|
||||
function extractTier(reasons) {
|
||||
for (const r of reasons || []) {
|
||||
const m = r.match(/자치구 ([SABCD])티어/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const fmt = (d) => {
|
||||
if (!d) return '-';
|
||||
return new Date(d).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
|
||||
@@ -341,6 +357,17 @@ function AnnouncementCard({ item, isSelected, onClick, onBookmark }) {
|
||||
{item.match_score}점
|
||||
</span>
|
||||
)}
|
||||
{item.district && (
|
||||
<span className="sub-chip sub-chip--district">{item.district}</span>
|
||||
)}
|
||||
{(() => {
|
||||
const tier = extractTier(item.match_reasons);
|
||||
return tier ? (
|
||||
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
|
||||
{tier}티어
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onBookmark?.(item.id); }}
|
||||
@@ -588,6 +615,142 @@ function AnnouncementDetail({ item, onBookmark }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.match_score !== undefined && item.match_score !== null && (
|
||||
<div className="sub-match-analysis">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div>
|
||||
<p className="sub-panel__eyebrow">매칭 분석</p>
|
||||
<span className="sub-match-analysis__score">
|
||||
⭐ {item.match_score}<span style={{ fontSize: 14, color: "var(--text-muted)" }}> / 100</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.score_breakdown && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<p className="sub-panel__eyebrow" style={{ marginBottom: 8 }}>📊 점수 분석</p>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{[
|
||||
{ key: 'region', label: '지역', max: 35, color: '#00d4ff' },
|
||||
{ key: 'type', label: '유형', max: 10, color: '#8b5cf6' },
|
||||
{ key: 'area', label: '면적', max: 15, color: '#f59e0b' },
|
||||
{ key: 'price', label: '가격', max: 15, color: '#f43f5e' },
|
||||
{ key: 'eligibility', label: '자격', max: 25, color: '#34d399' },
|
||||
].map(({ key, label, max, color }) => {
|
||||
const v = item.score_breakdown[key] ?? 0;
|
||||
return (
|
||||
<div key={key} style={{ display: 'grid', gap: 3 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||
<span style={{ color: 'var(--text-bright)', fontWeight: 500 }}>{label}</span>
|
||||
<span>
|
||||
<span style={{ fontWeight: 700, color }}>{v}</span>
|
||||
<span style={{ color: 'var(--text-dim)' }}> / {max}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 5, borderRadius: 3, background: 'var(--surface-raised)', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 3, background: color,
|
||||
width: `${(v / max) * 100}%`, transition: 'width 0.4s',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.match_reasons && item.match_reasons.length > 0 && (
|
||||
<div>
|
||||
<p className="sub-panel__eyebrow" style={{ marginTop: 12 }}>💡 매칭 사유</p>
|
||||
<ul className="sub-match-analysis__reasons">
|
||||
{item.match_reasons.map((r, idx) => (
|
||||
<li key={idx}>{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.eligible_types && item.eligible_types.length > 0 && (
|
||||
<div>
|
||||
<p className="sub-panel__eyebrow" style={{ marginTop: 8 }}>✓ 신청 자격</p>
|
||||
<div className="sub-match-analysis__elig">
|
||||
{item.eligible_types.map(t => (
|
||||
<span key={t} className="sub-chip">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CalendarView ─────────────────────────────────────────────────────────────
|
||||
function CalendarView({ items, onDaySelect }) {
|
||||
const [cur, setCur] = useState(() => {
|
||||
const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), 1);
|
||||
});
|
||||
const year = cur.getFullYear(), month = cur.getMonth();
|
||||
|
||||
const dateMap = useMemo(() => {
|
||||
const map = {};
|
||||
for (const item of items) {
|
||||
const raw = item.receipt_start || item.spsply_start || item.gnrl_rank1_start;
|
||||
if (!raw || raw.length < 8) continue;
|
||||
const key = `${raw.slice(0,4)}-${raw.slice(4,6)}-${raw.slice(6,8)}`;
|
||||
(map[key] = map[key] || []).push(item);
|
||||
}
|
||||
return map;
|
||||
}, [items]);
|
||||
|
||||
const firstDow = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const cells = [];
|
||||
for (let i = 0; i < firstDow; i++) cells.push(null);
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
|
||||
while (cells.length % 7 !== 0) cells.push(null);
|
||||
|
||||
const todayD = new Date(), todayKey = `${todayD.getFullYear()}-${String(todayD.getMonth()+1).padStart(2,'0')}-${String(todayD.getDate()).padStart(2,'0')}`;
|
||||
|
||||
return (
|
||||
<div className="sub-calendar">
|
||||
<div className="sub-calendar__header">
|
||||
<button className="sub-filter-btn" onClick={() => setCur(new Date(year, month-1, 1))}>‹</button>
|
||||
<span>{year}년 {month+1}월</span>
|
||||
<button className="sub-filter-btn" onClick={() => setCur(new Date(year, month+1, 1))}>›</button>
|
||||
</div>
|
||||
<div className="sub-calendar__weekdays">
|
||||
{['일','월','화','수','목','금','토'].map(w => (
|
||||
<div key={w} className="sub-calendar__weekday">{w}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="sub-calendar__grid">
|
||||
{cells.map((d, i) => {
|
||||
const key = d ? `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}` : null;
|
||||
const dayItems = key ? (dateMap[key] || []) : [];
|
||||
const isToday = key === todayKey;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`sub-calendar__day${!d ? ' is-empty' : ''}${isToday ? ' is-today' : ''}${dayItems.length > 0 ? ' has-items' : ''}`}
|
||||
onClick={() => dayItems.length > 0 && onDaySelect(dayItems, `${year}년 ${month+1}월 ${d}일`)}
|
||||
>
|
||||
{d && <span className="sub-calendar__day-num">{d}</span>}
|
||||
{dayItems.length > 0 && (
|
||||
<div className="sub-calendar__dots">
|
||||
{dayItems.slice(0, 3).map((it, j) => (
|
||||
<span key={j} className="sub-calendar__dot" style={{ background: STATUS_CONFIG[it.status]?.color || '#888' }} />
|
||||
))}
|
||||
{dayItems.length > 3 && <span className="sub-calendar__more">+{dayItems.length - 3}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -603,8 +766,10 @@ function AnnouncementsTab() {
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [viewMode, setViewMode] = useState('list'); // 'list' | 'calendar'
|
||||
const [calendarDay, setCalendarDay] = useState(null); // { label, items }
|
||||
|
||||
const size = 20;
|
||||
const size = viewMode === 'calendar' ? 200 : 20;
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
@@ -624,7 +789,7 @@ function AnnouncementsTab() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [page, statusFilter, regionFilter, bookmarkFilter]);
|
||||
useEffect(() => { load(); }, [page, statusFilter, regionFilter, bookmarkFilter, viewMode]);
|
||||
|
||||
const handleSelect = async (item) => {
|
||||
setSelected(item.id);
|
||||
@@ -694,6 +859,14 @@ function AnnouncementsTab() {
|
||||
onChange={(e) => { setRegionFilter(e.target.value); setPage(1); }}
|
||||
style={{ width: 160, padding: '6px 12px', fontSize: 12 }}
|
||||
/>
|
||||
<button
|
||||
className={`sub-filter-btn${viewMode === 'calendar' ? ' is-active' : ''}`}
|
||||
onClick={() => { setViewMode(v => v === 'calendar' ? 'list' : 'calendar'); setPage(1); setCalendarDay(null); }}
|
||||
style={{ fontSize: 12 }}
|
||||
title="캘린더 뷰 전환"
|
||||
>
|
||||
📅 캘린더
|
||||
</button>
|
||||
<button
|
||||
className="sub-filter-btn"
|
||||
onClick={handleDeleteClosed}
|
||||
@@ -709,6 +882,42 @@ function AnnouncementsTab() {
|
||||
<div className="sub-empty">불러오는 중...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="sub-empty">조건에 맞는 공고가 없습니다.</div>
|
||||
) : viewMode === 'calendar' ? (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<CalendarView
|
||||
items={items}
|
||||
onDaySelect={(dayItems, label) => setCalendarDay({ items: dayItems, label })}
|
||||
/>
|
||||
{calendarDay && (
|
||||
<div className="sub-panel">
|
||||
<div className="sub-panel__head">
|
||||
<div>
|
||||
<p className="sub-panel__eyebrow">{calendarDay.label}</p>
|
||||
<h3>공고 {calendarDay.items.length}건</h3>
|
||||
</div>
|
||||
<button className="sub-filter-btn" onClick={() => setCalendarDay(null)} style={{ fontSize: 11 }}>닫기</button>
|
||||
</div>
|
||||
<div className="sub-panel__body" style={{ display: 'grid', gap: 8 }}>
|
||||
{calendarDay.items.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="sub-card"
|
||||
style={{ cursor: 'pointer', padding: '10px 14px' }}
|
||||
onClick={() => { setViewMode('list'); handleSelect(item); }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-bright)' }}>{item.house_nm}</span>
|
||||
<span className="sub-badge" style={{ background: STATUS_CONFIG[item.status]?.bg, color: STATUS_CONFIG[item.status]?.color, flexShrink: 0 }}>
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-dim)' }}>{item.region_name} · 접수 {item.receipt_start}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="sub-list-layout">
|
||||
{/* Card Grid */}
|
||||
@@ -869,6 +1078,17 @@ function MatchesTab() {
|
||||
</span>
|
||||
)}
|
||||
{match.ann_status && <StatusBadge status={match.ann_status} />}
|
||||
{match.district && (
|
||||
<span className="sub-chip sub-chip--district">{match.district}</span>
|
||||
)}
|
||||
{(() => {
|
||||
const tier = extractTier(match.match_reasons);
|
||||
return tier ? (
|
||||
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
|
||||
{tier}티어
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<p className="sub-card__address" style={{ margin: 0 }}>
|
||||
{match.region_name || '-'}
|
||||
@@ -902,6 +1122,24 @@ function MatchesTab() {
|
||||
).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
{match.score_breakdown && (
|
||||
<div style={{ display: 'flex', gap: 2, marginTop: 4 }}>
|
||||
{[
|
||||
{ key: 'region', max: 35, color: '#00d4ff' },
|
||||
{ key: 'type', max: 10, color: '#8b5cf6' },
|
||||
{ key: 'area', max: 15, color: '#f59e0b' },
|
||||
{ key: 'price', max: 15, color: '#f43f5e' },
|
||||
{ key: 'eligibility', max: 25, color: '#34d399' },
|
||||
].map(({ key, max, color }) => {
|
||||
const v = match.score_breakdown[key] ?? 0;
|
||||
return (
|
||||
<div key={key} style={{ flex: max, height: 4, borderRadius: 2, background: 'var(--surface-raised)', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 2, background: color, width: `${(v / max) * 100}%` }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', flexShrink: 0, display: 'grid', gap: 6 }}>
|
||||
<div>
|
||||
@@ -955,6 +1193,7 @@ function MatchesTab() {
|
||||
// ── ProfileTab ────────────────────────────────────────────────────────────────
|
||||
function ProfileTab() {
|
||||
const [profile, setProfile] = useState({ ...DEFAULT_PROFILE });
|
||||
const [passCount, setPassCount] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState('');
|
||||
@@ -963,13 +1202,17 @@ function ProfileTab() {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await apiGet('/api/realestate/profile');
|
||||
const [data, dash] = await Promise.all([
|
||||
apiGet('/api/realestate/profile'),
|
||||
apiGet('/api/realestate/dashboard').catch(() => null),
|
||||
]);
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const display = { ...DEFAULT_PROFILE, ...data };
|
||||
if (Array.isArray(display.preferred_regions)) display.preferred_regions = display.preferred_regions.join(', ');
|
||||
if (Array.isArray(display.preferred_types)) display.preferred_types = display.preferred_types.join(', ');
|
||||
setProfile(display);
|
||||
}
|
||||
if (dash?.pass_count != null) setPassCount(dash.pass_count);
|
||||
} catch (e) {
|
||||
console.error('Profile load error:', e);
|
||||
} finally {
|
||||
@@ -1012,6 +1255,13 @@ function ProfileTab() {
|
||||
if (payload.preferred_regions.length === 0) payload.preferred_regions = null;
|
||||
if (payload.preferred_types.length === 0) payload.preferred_types = null;
|
||||
|
||||
// 신규: preferred_districts (객체), min_match_score, notify_enabled
|
||||
payload.preferred_districts = profile.preferred_districts && typeof profile.preferred_districts === "object"
|
||||
? profile.preferred_districts
|
||||
: {};
|
||||
payload.min_match_score = profile.min_match_score ?? null;
|
||||
payload.notify_enabled = profile.notify_enabled ?? null;
|
||||
|
||||
const updated = await apiPut('/api/realestate/profile', payload);
|
||||
if (updated && Object.keys(updated).length > 0) {
|
||||
// Convert arrays back to comma-separated strings for display
|
||||
@@ -1106,6 +1356,26 @@ function ProfileTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 프로필 완성도 힌트 */}
|
||||
{(() => {
|
||||
const missing = [];
|
||||
if (!profile.income_level) missing.push('소득 수준');
|
||||
if (!profile.min_area || !profile.max_area) missing.push('희망 면적');
|
||||
if (!profile.max_price) missing.push('최대 예산');
|
||||
const hasDistricts = profile.preferred_districts &&
|
||||
Object.values(profile.preferred_districts).some(arr => arr?.length > 0);
|
||||
if (!hasDistricts) missing.push('자치구 티어');
|
||||
if (missing.length === 0) return null;
|
||||
return (
|
||||
<div className="sub-profile-hint">
|
||||
<span className="sub-profile-hint__icon">💡</span>
|
||||
<span>
|
||||
<strong>매칭 정확도 개선 가능</strong> — {missing.join(', ')} 입력 시 더 정확한 점수를 산출합니다.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="sub-modal__form">
|
||||
{/* 기본 정보 */}
|
||||
<div className="sub-form-section" style={{ borderBottom: '1px solid var(--line)' }}>
|
||||
@@ -1223,10 +1493,13 @@ function ProfileTab() {
|
||||
<input
|
||||
className="sub-form-input"
|
||||
type="number"
|
||||
min="0"
|
||||
max="300"
|
||||
value={profile.income_level || ''}
|
||||
onChange={e => handleChange('income_level', e.target.value)}
|
||||
placeholder="도시근로자 평균 대비 %"
|
||||
placeholder="도시근로자 월평균 대비 %"
|
||||
/>
|
||||
<span className="sub-form-hint">청년 ≤140 / 신혼·생애최초 ≤160 / 신생아 ≤200 · 미입력 시 검증 생략</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1289,6 +1562,20 @@ function ProfileTab() {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자치구 5티어 */}
|
||||
<DistrictTierEditor
|
||||
value={profile.preferred_districts}
|
||||
onChange={(next) => setProfile(prev => ({ ...prev, preferred_districts: next }))}
|
||||
/>
|
||||
|
||||
{/* 알림 설정 */}
|
||||
<NotificationSettings
|
||||
minScore={profile.min_match_score ?? 70}
|
||||
notifyEnabled={profile.notify_enabled ?? true}
|
||||
onChange={(patch) => setProfile(prev => ({ ...prev, ...patch }))}
|
||||
passCount={passCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
170
src/pages/subscription/components/DistrictTierEditor.jsx
Normal file
170
src/pages/subscription/components/DistrictTierEditor.jsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const SEOUL_DISTRICTS = [
|
||||
"강남구","강동구","강북구","강서구","관악구",
|
||||
"광진구","구로구","금천구","노원구","도봉구",
|
||||
"동대문구","동작구","마포구","서대문구","서초구",
|
||||
"성동구","성북구","송파구","양천구","영등포구",
|
||||
"용산구","은평구","종로구","중구","중랑구",
|
||||
];
|
||||
|
||||
const TIERS = [
|
||||
{ key: "S", label: "S", weight: "100%" },
|
||||
{ key: "A", label: "A", weight: "80%" },
|
||||
{ key: "B", label: "B", weight: "60%" },
|
||||
{ key: "C", label: "C", weight: "40%" },
|
||||
{ key: "D", label: "D", weight: "20%" },
|
||||
];
|
||||
|
||||
const EMPTY_TIERS = { S: [], A: [], B: [], C: [], D: [] };
|
||||
|
||||
function useIsDesktop() {
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches
|
||||
);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const mq = window.matchMedia("(min-width: 768px)");
|
||||
const handler = (e) => setIsDesktop(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
return isDesktop;
|
||||
}
|
||||
|
||||
export default function DistrictTierEditor({ value, onChange }) {
|
||||
const isDesktop = useIsDesktop();
|
||||
const [dragOver, setDragOver] = useState(null); // 현재 hover 중인 zone key
|
||||
|
||||
const current = value && Object.keys(value).length > 0 ? value : EMPTY_TIERS;
|
||||
|
||||
const unassigned = SEOUL_DISTRICTS.filter(
|
||||
d => !TIERS.some(t => (current[t.key] || []).includes(d))
|
||||
);
|
||||
|
||||
const moveDistrict = (district, targetTier /* null = 미할당 */) => {
|
||||
const next = { S: [], A: [], B: [], C: [], D: [] };
|
||||
for (const t of Object.keys(next)) {
|
||||
next[t] = (current[t] || []).filter(d => d !== district);
|
||||
}
|
||||
if (targetTier) {
|
||||
next[targetTier] = [...next[targetTier], district];
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const onDragStart = (e, district) => {
|
||||
e.dataTransfer.setData("text/district", district);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
const onDragOver = (e, key) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (dragOver !== key) setDragOver(key);
|
||||
};
|
||||
const onDragLeave = (e) => {
|
||||
if (e.currentTarget.contains(e.relatedTarget)) return;
|
||||
setDragOver(null);
|
||||
};
|
||||
const onDrop = (e, targetTier /* null = 미할당 */) => {
|
||||
e.preventDefault();
|
||||
const district = e.dataTransfer.getData("text/district");
|
||||
setDragOver(null);
|
||||
if (district) moveDistrict(district, targetTier);
|
||||
};
|
||||
|
||||
if (!isDesktop) {
|
||||
return (
|
||||
<div className="sub-panel">
|
||||
<div className="sub-panel__head">
|
||||
<p className="sub-panel__eyebrow">자치구 우선순위</p>
|
||||
<h3>지역 5티어</h3>
|
||||
</div>
|
||||
<div className="sub-panel__body" style={{ display: "grid", gap: 10 }}>
|
||||
{TIERS.map(t => (
|
||||
<div key={t.key} className="dte-row dte-row--readonly">
|
||||
<span className={`sub-chip sub-chip--tier sub-chip--tier-${t.key}`}>
|
||||
{t.label} {t.weight}
|
||||
</span>
|
||||
<span className="dte-row__list">
|
||||
{(current[t.key] || []).length === 0
|
||||
? <span className="dte-empty">(없음)</span>
|
||||
: (current[t.key] || []).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="dte-mobile-hint">✏️ 자치구 분류는 PC에서 편집할 수 있어요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sub-panel">
|
||||
<div className="sub-panel__head">
|
||||
<p className="sub-panel__eyebrow">자치구 우선순위</p>
|
||||
<h3>지역 5티어 (드래그해서 분류)</h3>
|
||||
</div>
|
||||
<div className="sub-panel__body" style={{ display: "grid", gap: 12 }}>
|
||||
{/* 미할당 풀 */}
|
||||
<div
|
||||
className={`dte-pool ${dragOver === "_unassigned" ? "dte-pool--over" : ""}`}
|
||||
onDragOver={(e) => onDragOver(e, "_unassigned")}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, null)}
|
||||
>
|
||||
<p className="dte-pool__title">미할당 ({unassigned.length})</p>
|
||||
<div className="dte-chips">
|
||||
{unassigned.map(d => (
|
||||
<span
|
||||
key={d}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, d)}
|
||||
className="sub-chip sub-chip--district dte-chip"
|
||||
>
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5티어 그리드 */}
|
||||
<div className="dte-grid">
|
||||
{TIERS.map(t => (
|
||||
<div
|
||||
key={t.key}
|
||||
className={`dte-zone ${dragOver === t.key ? "dte-zone--over" : ""}`}
|
||||
onDragOver={(e) => onDragOver(e, t.key)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, t.key)}
|
||||
>
|
||||
<div className={`dte-zone__head sub-chip--tier-${t.key}`}>
|
||||
{t.label} <span className="dte-zone__weight">{t.weight}</span>
|
||||
</div>
|
||||
<div className="dte-zone__chips">
|
||||
{(current[t.key] || []).map(d => (
|
||||
<span
|
||||
key={d}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, d)}
|
||||
className="sub-chip sub-chip--district dte-chip"
|
||||
>
|
||||
{d}
|
||||
<button
|
||||
type="button"
|
||||
className="dte-chip__remove"
|
||||
onClick={() => moveDistrict(d, null)}
|
||||
aria-label={`${d} 미할당으로`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user