Compare commits
7 Commits
ec3ca5fcfa
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
| 95d74c1913 | |||
| d8bc6af062 | |||
| 226e368347 | |||
| 310679de61 | |||
| 916d16c235 | |||
| 96a5d97ff7 | |||
| 2ef43b070a |
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"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,7 +22,3 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.env.local
|
||||
|
||||
# Superpowers visual companion (mockup files)
|
||||
.superpowers/
|
||||
|
||||
109
CLAUDE.md
109
CLAUDE.md
@@ -17,8 +17,7 @@
|
||||
| `/lotto` | `Lotto` | 로또 추천/통계 |
|
||||
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
||||
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
|
||||
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
|
||||
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
|
||||
| `/realestate` | `Subscription` | 청약 자격·일정 관리 |
|
||||
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
||||
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
||||
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
|
||||
@@ -26,9 +25,6 @@
|
||||
| `/lab/day-calc` | `DayCalc` | 날짜 계산기 |
|
||||
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
|
||||
| `/todo` | `Todo` | 태스크 보드 |
|
||||
| `/blog-lab` | `BlogMarketing` | 블로그 마케팅 수익화 대시보드 |
|
||||
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅) |
|
||||
| `/portfolio` | `Portfolio` | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개) |
|
||||
|
||||
라우트 정의: `src/routes.jsx` / 라우터 설정: `src/Router.jsx`
|
||||
|
||||
@@ -65,7 +61,7 @@ proxy: {
|
||||
}
|
||||
```
|
||||
|
||||
- `/api/*` → NAS 백엔드 (nginx가 서비스별 라우팅: lotto, personal, stock-lab, music-lab 등)
|
||||
- `/api/*` → NAS 백엔드
|
||||
- `/media/*` → NAS 미디어 파일 (여행 사진 `/media/travel/`, 음악 `/media/music/`)
|
||||
- 개발 서버 포트: **3007**
|
||||
|
||||
@@ -86,12 +82,6 @@ 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 }` |
|
||||
@@ -101,31 +91,14 @@ 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` — personal 서비스 |
|
||||
| TODO | PUT/DELETE | `/api/todos/:id`, `/api/todos/done` — personal 서비스 |
|
||||
| 블로그 | GET/POST | `/api/blog/posts` — personal 서비스 |
|
||||
| 블로그 | PUT/DELETE | `/api/blog/posts/:id` — personal 서비스 |
|
||||
| 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` |
|
||||
| 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: [...] }` |
|
||||
| AI 음악 라이브러리 | DELETE | `/api/music/library/:id` |
|
||||
| 여행 | GET | `/api/travel/regions`, `/api/travel/albums`, `/api/travel/photos` |
|
||||
| 여행 | POST | `/api/travel/sync` |
|
||||
| 여행 | PUT | `/api/travel/albums/:album/cover`, `/api/travel/albums/:album/region` |
|
||||
| 여행 | PUT | `/api/travel/regions/:id` |
|
||||
| 블로그마케팅 | POST | `/api/blog-marketing/research`, `/api/blog-marketing/generate` |
|
||||
| 블로그마케팅 | GET | `/api/blog-marketing/posts`, `/api/blog-marketing/dashboard` |
|
||||
| 블로그마케팅 | POST | `/api/blog-marketing/market/:id`, `/api/blog-marketing/review/:id` |
|
||||
| 에이전트 | GET | `/api/agent-office/agents`, `/api/agent-office/states` |
|
||||
| 에이전트 | POST | `/api/agent-office/command`, `/api/agent-office/approve` |
|
||||
| 에이전트 | WS | `/api/agent-office/ws` |
|
||||
| 부동산 | GET | `/api/realestate/announcements`, `/api/realestate/matches` |
|
||||
| 부동산 | 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 서비스 |
|
||||
|
||||
---
|
||||
|
||||
@@ -249,32 +222,7 @@ handleGenerate()
|
||||
|
||||
## Lotto 고도화 (`/lotto`)
|
||||
|
||||
`src/pages/lotto/Functions.jsx`는 3탭 구조 (`브리핑 / 분석·통계 / 구매·성과`)로 리팩토링되었습니다.
|
||||
|
||||
| 탭 | 파일 | 설명 |
|
||||
|----|------|------|
|
||||
| 이번 주 브리핑 | `tabs/BriefingTab.jsx` | AI 큐레이터 브리핑 표시 (`components/briefing/` 하위 컴포넌트) |
|
||||
| 분석·통계 | `tabs/AnalysisTab.jsx` | 시뮬레이션 추천·통계·ReportPanel·수동 추천 |
|
||||
| 구매·성과 | `tabs/PurchaseTab.jsx` | 구매 내역 CRUD + 성과 통계 |
|
||||
|
||||
### 브리핑 전용 컴포넌트 (`components/briefing/`)
|
||||
|
||||
| 컴포넌트 | 설명 |
|
||||
|----------|------|
|
||||
| `BriefingTab.jsx` | 탭 루트, 브리핑 로드 + 트리거 |
|
||||
| `BriefingHeader.jsx` | 회차·생성일시 헤더 |
|
||||
| `BriefingSummary.jsx` | 내러티브 요약 표시 |
|
||||
| `PickSetCard.jsx` | 번호 세트 1장 카드 |
|
||||
| `BriefingEmpty.jsx` | 브리핑 없을 때 빈 상태 |
|
||||
| `CuratorUsageFooter.jsx` | 토큰·비용 집계 푸터 |
|
||||
|
||||
### 신규 api.js 헬퍼
|
||||
|
||||
- `getLatestBriefing()` — `GET /api/lotto/briefing/latest`
|
||||
- `getCuratorUsage(days)` — `GET /api/lotto/curator/usage?days=N`
|
||||
- `triggerLottoCurate()` — `POST /api/agent-office/command` (lotto_agent curate 명령)
|
||||
|
||||
### 기존 섹션 (AnalysisTab 내)
|
||||
`src/pages/lotto/Functions.jsx`에 4개 신규 섹션 추가:
|
||||
|
||||
| 섹션 | API | 설명 |
|
||||
|------|-----|------|
|
||||
@@ -287,46 +235,9 @@ handleGenerate()
|
||||
|
||||
## Travel 갤러리 (`/travel`)
|
||||
|
||||
테마: "Dark Room" (배경 `#0f0c09`, 서체 Cormorant Garamond + Space Mono)
|
||||
|
||||
### 파일 구조
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `src/pages/travel/Travel.jsx` | 메인 페이지 — 앨범 카드 목록 + MiniMap |
|
||||
| `src/pages/travel/AlbumCard.jsx` | 앨범 썸네일 카드 (커버 이미지 + 사진 수) |
|
||||
| `src/pages/travel/AlbumDetail.jsx` | 앨범 상세 오버레이 — 사진/영상 탭 + 지역 편집 |
|
||||
| `src/pages/travel/MasonryGrid.jsx` | CSS columns 기반 Masonry 레이아웃 + 무한 스크롤 |
|
||||
| `src/pages/travel/HeroLightbox.jsx` | 전체화면 사진 뷰어 — 스와이프/키보드 네비게이션 |
|
||||
| `src/pages/travel/MiniMap.jsx` | 접이식 Leaflet 지도 — GeoJSON 지역 + 핀 마커 |
|
||||
| `src/pages/travel/RegionPinPicker.jsx` | 지도 핀 위치 지정 모달 (Leaflet 클릭 → 좌표 저장) |
|
||||
| `src/pages/travel/VideoTab.jsx` | 영상 탭 (준비 중) |
|
||||
|
||||
### 핵심 기능
|
||||
|
||||
- **지역 관리**: GeoJSON 기반 지역 선택 → 앨범 필터링 + 지역 변경 + 핀 좌표 지정
|
||||
- **앨범 카드**: 커버 사진, 지역 라벨, 사진 수 표시, 접근성 accent 색상
|
||||
- **Masonry 그리드**: 40장 단위 청크 로딩, IntersectionObserver 기반 무한 스크롤
|
||||
- **Lightbox**: 앨범 커버 지정, 스와이프/키보드 네비게이션, 추가 로딩 지원
|
||||
- **MiniMap**: Polygon(기존 지역) + CircleMarker(커스텀 핀) 이중 렌더링
|
||||
- **지역 편집**: AlbumDetail에서 인라인 편집 + 자동완성 + "위치 지정" 버튼
|
||||
|
||||
### API 연동
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/travel/regions` | GeoJSON (커스텀 지역 포함) |
|
||||
| GET | `/api/travel/photos?region=X&page=N&size=40` | 사진 페이지네이션 |
|
||||
| GET | `/api/travel/albums` | 앨범 목록 + cover + region |
|
||||
| POST | `/api/travel/sync` | 폴더 동기화 |
|
||||
| PUT | `/api/travel/albums/{album}/cover` | 커버 지정 |
|
||||
| PUT | `/api/travel/albums/{album}/region` | 지역 변경 |
|
||||
| PUT | `/api/travel/regions/{id}` | 핀 좌표 저장 |
|
||||
|
||||
### 미디어 URL
|
||||
- 사진: `/media/travel/{album}/{filename}`
|
||||
- 썸네일: `/media/travel/.thumb/{album}/{filename}`
|
||||
- `vite.config.js` `/media` 프록시로 처리, 프로덕션 nginx에서 직접 서빙
|
||||
- 테마: "Dark Room" (배경 `#0f0c09`, 서체 Cormorant Garamond + Space Mono)
|
||||
- 사진 URL: `/media/travel/...` 형식 → `vite.config.js` `/media` 프록시로 처리
|
||||
- 프로덕션 nginx에도 `location /media/` 프록시 블록 필요
|
||||
|
||||
---
|
||||
|
||||
|
||||
138
README.md
138
README.md
@@ -1,6 +1,6 @@
|
||||
# Web UI
|
||||
|
||||
개인 대시보드 — 블로그, 로또, 주식, 부동산, 여행, AI 음악, AI 에이전트, 할 일 등을 한 곳에서 관리하는 개인 웹 UI입니다.
|
||||
개인 대시보드 — 블로그, 로또, 주식, 부동산, 여행, 할 일 등을 한 곳에서 관리하는 개인 웹 UI입니다.
|
||||
|
||||
## 기술 스택
|
||||
|
||||
@@ -11,13 +11,12 @@
|
||||
| 지도 | react-leaflet + Leaflet |
|
||||
| 차트 | Recharts |
|
||||
| 3D | Three.js |
|
||||
| 제스처 | react-swipeable |
|
||||
| 스타일 | 커스텀 CSS (CSS 변수 기반 사이버펑크 다크 테마) |
|
||||
| 배포 | Synology NAS (Docker + nginx 리버스 프록시) |
|
||||
|
||||
---
|
||||
|
||||
## 페이지 구성 (13개 라우트)
|
||||
## 페이지 구성
|
||||
|
||||
### Home (`/`)
|
||||
|
||||
@@ -40,18 +39,15 @@
|
||||
|
||||
---
|
||||
|
||||
### Lotto (`/lotto`) — 14 컴포넌트
|
||||
### Lotto (`/lotto`)
|
||||
|
||||
로또 번호 추천 및 통계 실험실.
|
||||
|
||||
- **3탭 구조**: 이번 주 브리핑 / 분석·통계 / 구매·성과
|
||||
- AI 큐레이터 브리핑 (5세트 + 내러티브 + 토큰·비용 집계)
|
||||
- 최신 로또 당첨 결과 조회
|
||||
- 가중치·최근 회차·회피 수 파라미터 기반 번호 추천
|
||||
- 몬테카를로 시뮬레이션 최적 번호 표시
|
||||
- 전략 진화 (EMA+Softmax) 기반 메타 추천
|
||||
- 주간 리포트 + ConfidenceRing 시각화
|
||||
- 구매 이력 CRUD + 성과 통계 (수익률·당첨 현황)
|
||||
- 프리셋으로 빠른 추천 생성, 번호 원클릭 복사
|
||||
- 프리셋으로 빠른 추천 생성
|
||||
- 추천 히스토리 목록 확인 및 삭제
|
||||
- 번호 원클릭 복사
|
||||
|
||||
---
|
||||
|
||||
@@ -64,93 +60,65 @@
|
||||
- 미국 10년물 금리, WTI/Brent 유가 등 매크로 지표
|
||||
- 국내 / 해외 주식 뉴스 탭 분류 및 필터링
|
||||
|
||||
### Stock Trade (`/stock/trade`) — 7 컴포넌트
|
||||
### Stock Trade (`/stock/trade`)
|
||||
|
||||
포트폴리오 관리 및 트레이딩 데스크.
|
||||
|
||||
- **포트폴리오 탭**: 보유 종목 수익률, 자산 구성 차트 (파이/바 차트)
|
||||
- **AI 탭**: AI 시장 분석 요약 및 투자 인사이트
|
||||
- **리포트 탭**: 자산 스냅샷 히스토리 및 수익 추이
|
||||
- **어드바이저 탭**: 투자 조언 및 리밸런싱 제안
|
||||
- 종목 추가/편집/삭제 CRUD, 현금 잔고(예수금) 관리
|
||||
- 매도 히스토리 드로어 (실현손익 추적)
|
||||
- 종목 추가/편집/삭제 CRUD
|
||||
- 현금 잔고(예수금) 관리, 브로커별 분리
|
||||
|
||||
---
|
||||
|
||||
### Realestate (`/realestate`) — 2 섹션
|
||||
### Realestate (`/realestate`)
|
||||
|
||||
부동산 청약 통합 관리.
|
||||
부동산 청약 통합 관리 — 청약 대시보드와 관심 단지 정보 두 화면으로 구성.
|
||||
|
||||
#### 청약 대시보드 (`/realestate`)
|
||||
|
||||
- 관심 청약 카드 목록 + 상세 패널 (요건/일정/자금 섹션)
|
||||
- 전체 청약 일정 타임라인 (청약 → 계약 → 중도금 → 잔금)
|
||||
- 가점 계산 엔진 (무주택 32점 + 부양가족 35점 + 통장 17점 = 84점 만점)
|
||||
- **청약 목록 탭**: 관심 청약 카드 목록 + 상세 패널 (요건/일정/자금 섹션)
|
||||
- **일정 탭**: 전체 청약 일정 타임라인 (청약 → 계약 → 중도금 → 잔금)
|
||||
- **자금 탭**: 단지별 자금 계획 및 총합 분석
|
||||
- 가점 계산 엔진 (무주택기간 최대 32점, 부양가족 최대 35점, 통장기간 최대 17점 = 84점 만점)
|
||||
- 내 청약 조건 프로필 입력 및 단지별 요건 충족 여부 자동 비교
|
||||
- 청약 유형 분류: 줍줍 / 특공 / 일반
|
||||
- API 미구현 시 localStorage fallback으로 데이터 유지
|
||||
|
||||
#### 부동산 정보 (`/realestate/property`)
|
||||
|
||||
- 관심 아파트 단지 카드 그리드 + Leaflet 지도 통합 뷰
|
||||
- D-day 카운트다운, 평당가 비교 바 차트 (Recharts)
|
||||
- 모달 기반 단지 추가/편집, 네이버 부동산 바로가기 연동
|
||||
- 관심 아파트 단지 카드 그리드 + 지도 통합 뷰 (react-leaflet)
|
||||
- 단지별 상태 마커: 청약예정 / 청약중 / 결과발표 / 완료
|
||||
- D-day 카운트다운 및 우선순위 배지
|
||||
- 평당가 비교 바 차트 (Recharts)
|
||||
- 일정 탭: 전체 단지 청약 일정 타임라인
|
||||
- 분석 탭: 단지별 평당가 비교표
|
||||
- 모달 기반 단지 추가/편집 (단지명, 주소, 좌표, 평형, 분양가, 네이버 부동산 URL)
|
||||
- 네이버 부동산 바로가기 링크 연동
|
||||
|
||||
---
|
||||
|
||||
### Travel (`/travel`) — 8 컴포넌트
|
||||
### Travel (`/travel`)
|
||||
|
||||
여행 사진 갤러리 (Dark Room 테마).
|
||||
여행 사진 갤러리.
|
||||
|
||||
- **MiniMap**: GeoJSON 기반 접이식 세계 지도 — Polygon(기존 지역) + CircleMarker(핀)
|
||||
- **AlbumCard**: 앨범 썸네일 카드 그리드 (커버 이미지 + 지역 라벨 + 사진 수)
|
||||
- **AlbumDetail**: 앨범 상세 오버레이 — 사진/영상 탭 + 지역 인라인 편집
|
||||
- **MasonryGrid**: CSS columns Masonry 레이아웃 + IntersectionObserver 무한 스크롤
|
||||
- **HeroLightbox**: 전체화면 사진 뷰어 — 스와이프/키보드 네비 + 앨범 커버 지정
|
||||
- **RegionPinPicker**: 커스텀 지역 좌표 지정 모달 (Leaflet 클릭 → 핀 저장)
|
||||
- 40장 단위 청크 로딩, PullToRefresh 지원
|
||||
- 지도 기반 지역 선택 (GeoJSON)
|
||||
- 선택 지역의 사진 목록 로딩 및 캐시
|
||||
- 스크롤 기반 이미지 추가 로딩 (chunked lazy load)
|
||||
- 썸네일 / 모달 뷰, 키보드 및 스와이프 네비게이션
|
||||
- 앨범 및 파일 메타 정보 표시
|
||||
|
||||
---
|
||||
|
||||
### Music — Sonic Forge (`/lab/music`) — 8 컴포넌트
|
||||
|
||||
AI 음악 생성 스튜디오.
|
||||
|
||||
- 듀얼 프로바이더: Suno (보컬/가사) + 로컬 MusicGen (인스트루멘탈)
|
||||
- 장르/무드/악기/BPM/키/스케일 설정, 스타일 부스트
|
||||
- 생성 진행 폴링 (3초 간격), 라이브러리 자동 등록
|
||||
- 가사 관리 + 타임스탬프 동기 재생 (가라오케)
|
||||
- 커버 이미지 생성, WAV 변환, 12스템 분리
|
||||
- SonicRadar 시각 효과 + WaveformCanvas 오실로스코프
|
||||
|
||||
---
|
||||
|
||||
### Blog Marketing (`/blog-lab`)
|
||||
|
||||
AI 블로그 마케팅 자동화 대시보드.
|
||||
|
||||
- 키워드 리서치 (네이버 검색 + 상위 블로그 크롤링)
|
||||
- AI 글 생성 → 마케팅 강화 → 품질 리뷰 (6기준 x 10점)
|
||||
- 발행 관리 + 브랜드커넥트 링크 + 수익 추적
|
||||
- 비동기 작업 상태 폴링
|
||||
|
||||
---
|
||||
|
||||
### Agent Office (`/agent-office`) — 5 컴포넌트
|
||||
|
||||
AI 에이전트 가상 오피스.
|
||||
|
||||
- 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업 수행
|
||||
- WebSocket 실시간 상태 동기화 (에이전트 FSM: idle → working → reporting)
|
||||
- 에이전트별 명령 전송 + 작업 승인/거부
|
||||
- 채팅 패널 + 문서 패널
|
||||
|
||||
---
|
||||
|
||||
### Lab (`/lab`) — 3 컴포넌트
|
||||
### Lab (`/lab`)
|
||||
|
||||
실험적 UI/UX 효과 테스트 공간.
|
||||
|
||||
- **SwordStream**: Three.js 1,500개 파티클 3D 애니메이션 (호버/오빗 모드)
|
||||
- **DayCalc**: 날짜 계산 유틸리티
|
||||
- Three.js 기반 실시간 3D 파티클 애니메이션 (1,500개 오브젝트)
|
||||
- 호버 모드: 마우스 추적 및 자연스러운 흐름
|
||||
- 오빗 모드: 클릭 시 나선형 궤도 회전
|
||||
- 동적 스케일, 조명 효과
|
||||
|
||||
---
|
||||
|
||||
@@ -161,23 +129,7 @@ AI 에이전트 가상 오피스.
|
||||
- 칸반 레이아웃: 할 일 → 진행 중 → 완료
|
||||
- 드래그 앤 드롭으로 상태 변경
|
||||
- 태스크 추가/삭제, 완료 항목 일괄 정리
|
||||
|
||||
---
|
||||
|
||||
## 공통 컴포넌트 (`src/components/`)
|
||||
|
||||
| 컴포넌트 | 설명 |
|
||||
|----------|------|
|
||||
| `Navbar` | 상단 네비게이션 바 |
|
||||
| `BottomNav` | 모바일 하단 네비게이션 |
|
||||
| `PageHeader` | 페이지 헤더 + 브레드크럼 |
|
||||
| `SwipeableView` | 스와이프 탭 컨테이너 |
|
||||
| `PullToRefresh` | 풀투리프레시 제스처 |
|
||||
| `MobileSheet` | 모바일 바텀시트 모달 |
|
||||
| `FAB` | 플로팅 액션 버튼 |
|
||||
| `FearGreedGauge` | 공포·탐욕 게이지 |
|
||||
| `Loading` | 로딩 스피너 |
|
||||
| `Icons` | SVG 아이콘 라이브러리 |
|
||||
- 상태별 카운트 및 타임스탬프 표시
|
||||
|
||||
---
|
||||
|
||||
@@ -209,15 +161,5 @@ NAS_SSH_TARGET=user@gahusb.synology.me NAS_SSH_PORT=22 npm run release:nas
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from './api';
|
||||
|
||||
apiGet('/api/stock/indices');
|
||||
apiPost('/api/travel/sync');
|
||||
apiPost('/api/subscription/items', { ... });
|
||||
```
|
||||
|
||||
## 프로젝트 통계
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| 페이지 라우트 | 13개 |
|
||||
| JSX 컴포넌트 | 62+ |
|
||||
| 공통 컴포넌트 | 10개 |
|
||||
| API 헬퍼 함수 | 65+ |
|
||||
| 외부 라이브러리 | React, Router, Leaflet, Recharts, Three.js, react-swipeable |
|
||||
|
||||
126
STATUS.md
126
STATUS.md
@@ -1,126 +0,0 @@
|
||||
# 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/`
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,358 +0,0 @@
|
||||
# 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단위로 고정)
|
||||
@@ -1,822 +0,0 @@
|
||||
# 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,558 +0,0 @@
|
||||
# AI News Sentiment Node — Design
|
||||
|
||||
**작성일**: 2026-05-13
|
||||
**작성자**: gahusb
|
||||
**상태**: Approved for implementation
|
||||
**선행 spec**: `2026-05-12-stock-screener-board-design.md` (§14 — AI 뉴스 호재/악재 노드 후속 슬라이스)
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
스크리너의 8번째 점수 노드 `AiNewsSentiment` 를 추가한다. 평일 **08:00 KST** 에 시총 상위 100종목의 네이버 종목 뉴스를 스크래핑하고 Claude Haiku로 호재/악재를 정량화하여, 그날의 sentiment를 (a) 텔레그램으로 호재/악재 Top 5 알림으로 전달하고, (b) 16:30 스크리너 자동 잡의 가중합에 percentile_rank 형태로 기여한다.
|
||||
|
||||
**Why**: 기존 7개 점수 노드는 모두 수치 기반(가격/거래량/외국인 수급)이며, 시장 정서(뉴스 호재/악재)는 반영되지 않는다. 트레이더 의사결정에 큰 영향을 주는 호재/악재 시그널을 LLM으로 정량화하면 정량 노드와 정성 노드를 한 점수 체계로 통합할 수 있다. 장 시작 전 알림으로 즉시 가치 전달.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
**포함 (이번 슬라이스)**:
|
||||
- 평일 08:00 KST agent-office cron → stock-lab `/snapshot/refresh-news-sentiment` 호출
|
||||
- 시총 상위 100종목 × 네이버 종목 뉴스 (`/item/news_news.naver?code=XXX`) 스크래핑
|
||||
- 종목당 Claude Haiku 1콜 (총 100콜 asyncio.gather 병렬, 동시성 10)
|
||||
- `news_sentiment(ticker, date, score_raw, reason, news_count, tokens_input, tokens_output, model, created_at)` 테이블
|
||||
- 8번째 ScoreNode `AiNewsSentiment` 등록 (registry, schema, ScreenContext, 가중합 통합)
|
||||
- 호재 Top 5 + 악재 Top 5 텔레그램 메시지 (장 시작 전 발송)
|
||||
- 프론트 캔버스 모드에 8번째 노드 추가 (`SCORE_KEYS` 한 줄 + `INITIAL_NODE_POSITIONS` 좌표 한 줄)
|
||||
|
||||
**범위 외 (NOT)**:
|
||||
- 뉴스 URL 단위 캐싱 (비용이 충분히 낮음)
|
||||
- 16:00 추가 cron (MVP 일 1회)
|
||||
- 시장 전체 뉴스 종목 매핑 LLM (시총 상위 100 명시적 매핑)
|
||||
- 백테스트 (sentiment 점수가 실수익에 미친 영향) — 별도 후속 슬라이스
|
||||
- 가중치 자동 조정 — spec §14 별도 슬라이스
|
||||
- 종목별 sentiment 트렌드 차트 — 데이터 누적 후 후속 슬라이스
|
||||
- 종목 5-10위 외 sentiment 가시화 — Top 5 알림 외 별도 대시보드 없음
|
||||
|
||||
---
|
||||
|
||||
## 3. 아키텍처 개요
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
[08:00 KST 평일] │ agent-office cron │
|
||||
│ on_ai_news_schedule() │
|
||||
└──────────────┬──────────────┘
|
||||
│ HTTP POST
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ stock-lab: /api/stock/screener/snapshot/ │
|
||||
│ refresh-news-sentiment │
|
||||
│ │
|
||||
│ ai_news/pipeline.refresh_daily(asof): │
|
||||
│ 1. krx_master 시총 상위 100 ticker 조회 │
|
||||
│ 2. asyncio.gather(Semaphore=10) 100 종목 병렬: │
|
||||
│ a. scraper.fetch_news(ticker) │
|
||||
│ b. analyzer.score_sentiment(ticker, news[]) │
|
||||
│ c. → {score: float, reason: str, ...} │
|
||||
│ 3. news_sentiment 일괄 upsert │
|
||||
│ 4. Top 5 호재/악재 추출 → 텔레그램 페이로드 빌드 │
|
||||
│ 5. agent-office /telegram/send 호출 │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
[16:30 KST 평일] ┌─────────────────────────────┐
|
||||
│ agent-office on_screener_ │
|
||||
│ schedule (변경 없음) │
|
||||
└──────────────┬──────────────┘
|
||||
│ HTTP POST
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ stock-lab: /api/stock/screener/run mode=auto │
|
||||
│ │
|
||||
│ Screener.run(ctx): │
|
||||
│ ctx.news_sentiment = SELECT * FROM news_sentiment │
|
||||
│ WHERE date = asof │
|
||||
│ AiNewsSentiment.compute(ctx, params) │
|
||||
│ → percentile_rank(score_raw) for 100 tickers │
|
||||
│ → 가중합에 ai_news weight × percentile 점수 기여 │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**의존성 추가**: `anthropic` Python SDK (stock-lab requirements.txt). `ANTHROPIC_API_KEY` 는 docker-compose.yml에 이미 stock-lab 환경변수로 존재.
|
||||
|
||||
---
|
||||
|
||||
## 4. 컴포넌트 분해 (신규 파일)
|
||||
|
||||
### 4.1 stock-lab
|
||||
|
||||
```
|
||||
web-backend/stock-lab/app/
|
||||
screener/
|
||||
ai_news/ ← 신규 모듈
|
||||
__init__.py
|
||||
scraper.py ← 네이버 finance 종목 뉴스 스크래핑
|
||||
analyzer.py ← Claude Haiku 호재/악재 분석
|
||||
pipeline.py ← refresh_daily() 메인 (스크래핑+병렬 LLM+DB upsert)
|
||||
telegram.py ← Top 5/Top 5 메시지 빌더
|
||||
nodes/
|
||||
ai_news.py ← 8번째 ScoreNode 클래스
|
||||
schema.py ← (수정) news_sentiment 테이블 DDL 추가
|
||||
registry.py ← (수정) NODE_REGISTRY["ai_news"] 등록
|
||||
engine.py ← (수정) ScreenContext에 news_sentiment 로딩
|
||||
router.py ← (수정) POST /snapshot/refresh-news-sentiment 라우트 추가
|
||||
requirements.txt ← (수정) anthropic 추가
|
||||
tests/
|
||||
test_ai_news_scraper.py ← 네이버 HTML mock 파싱
|
||||
test_ai_news_analyzer.py ← anthropic mock 응답
|
||||
test_ai_news_pipeline.py ← 5종목 mini integration
|
||||
test_ai_news_node.py ← percentile_rank + min_news_count 필터
|
||||
```
|
||||
|
||||
### 4.2 agent-office
|
||||
|
||||
```
|
||||
web-backend/agent-office/app/
|
||||
agents/stock.py ← (수정) on_ai_news_schedule 메서드 추가
|
||||
scheduler.py ← (수정) cron mon-fri 08:00 등록
|
||||
service_proxy.py ← (수정) refresh_ai_news_sentiment() helper 추가
|
||||
```
|
||||
|
||||
### 4.3 frontend
|
||||
|
||||
```
|
||||
web-ui/src/pages/stock/screener/
|
||||
components/canvas/constants/
|
||||
canvasLayout.js ← (수정) AI 노드 추가 (NODE_IDS / NAME_MAP / LABEL / POSITIONS / SCORE_KEYS)
|
||||
canvasLayout.test.js ← (수정) 카운트 8 점수 노드, 18 엣지로 갱신
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. DB 스키마 (1개 신규 테이블)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS news_sentiment (
|
||||
ticker TEXT NOT NULL,
|
||||
date TEXT NOT NULL, -- YYYY-MM-DD
|
||||
score_raw REAL NOT NULL, -- LLM 원점수 -10 ~ +10
|
||||
reason TEXT NOT NULL DEFAULT '', -- LLM 한 줄 근거
|
||||
news_count INTEGER NOT NULL DEFAULT 0, -- 분석에 사용된 뉴스 수
|
||||
tokens_input INTEGER NOT NULL DEFAULT 0, -- 비용 모니터링
|
||||
tokens_output INTEGER NOT NULL DEFAULT 0,
|
||||
model TEXT NOT NULL DEFAULT 'claude-haiku-4-5-20251001',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
PRIMARY KEY (ticker, date)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_news_sentiment_date ON news_sentiment(date DESC);
|
||||
```
|
||||
|
||||
`schema.py` 의 `ensure_screener_schema(conn)` 함수에 이 DDL 추가. WAL + busy_timeout 패턴은 stock-lab `_conn()` 표준화로 이미 적용됨.
|
||||
|
||||
**기본 가중치 시드**: `DEFAULT_WEIGHTS["ai_news"] = 0.5` 추가 (다른 7노드의 default와 동일). 기존 settings 행이 있는 환경에서는 마이그레이션 1회 — `ensure_screener_schema()` 가 settings의 weights_json에 ai_news 키 누락 시 0.5로 보충하는 1회성 patch 적용.
|
||||
|
||||
---
|
||||
|
||||
## 6. ScoreNode 구현
|
||||
|
||||
```python
|
||||
# stock-lab/app/screener/nodes/ai_news.py
|
||||
import pandas as pd
|
||||
from .base import ScoreNode, percentile_rank
|
||||
|
||||
class AiNewsSentiment(ScoreNode):
|
||||
name = "ai_news"
|
||||
label = "AI 뉴스 호재/악재"
|
||||
default_params = {"min_news_count": 1}
|
||||
param_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min_news_count": {
|
||||
"type": "integer", "default": 1, "minimum": 0,
|
||||
"description": "최소 분석 뉴스 수. 미만이면 NaN.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def compute(self, ctx, params):
|
||||
df = getattr(ctx, "news_sentiment", None)
|
||||
if df is None or df.empty:
|
||||
return pd.Series(dtype=float)
|
||||
df = df[df["news_count"] >= params["min_news_count"]]
|
||||
if df.empty:
|
||||
return pd.Series(dtype=float)
|
||||
return percentile_rank(df.set_index("ticker")["score_raw"])
|
||||
```
|
||||
|
||||
`ScreenContext` dataclass에 `news_sentiment: Optional[pd.DataFrame] = None` 필드 추가 (default None 으로 기존 호출자 호환성 유지). `ScreenContext.load(conn, asof)` 에 로딩 한 줄 추가:
|
||||
|
||||
```python
|
||||
news_sentiment = pd.read_sql_query(
|
||||
"SELECT ticker, score_raw, news_count FROM news_sentiment WHERE date = ?",
|
||||
conn, params=(asof.isoformat(),),
|
||||
)
|
||||
return ScreenContext(..., news_sentiment=news_sentiment)
|
||||
```
|
||||
|
||||
기존 테스트 fixture에서 `ScreenContext(...)` 를 직접 생성하는 케이스는 default=None 으로 자동 호환. AiNewsSentiment.compute() 는 `getattr(ctx, "news_sentiment", None)` 로 안전 fallback.
|
||||
|
||||
---
|
||||
|
||||
## 7. 파이프라인 (`ai_news/pipeline.py`)
|
||||
|
||||
```python
|
||||
async def refresh_daily(conn, asof, *, tickers=None, model=DEFAULT_MODEL,
|
||||
concurrency=10, news_per_ticker=5):
|
||||
"""
|
||||
Returns:
|
||||
{"asof": ..., "updated": N, "failures": [...], "duration_sec": ...,
|
||||
"tokens_input": ..., "tokens_output": ..., "top_pos": [...], "top_neg": [...]}
|
||||
"""
|
||||
if tickers is None:
|
||||
tickers = _top_market_cap_tickers(conn, n=100)
|
||||
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
async with httpx.AsyncClient(...) as http_client, AsyncAnthropic(...) as llm:
|
||||
tasks = [_process_ticker(t, sem, http_client, llm, news_per_ticker, model)
|
||||
for t in tickers]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
successes = [r for r in results if isinstance(r, dict)]
|
||||
failures = [r for r in results if isinstance(r, BaseException)]
|
||||
|
||||
_upsert_news_sentiment(conn, asof, successes)
|
||||
|
||||
top_pos = sorted(successes, key=lambda r: -r["score_raw"])[:5]
|
||||
top_neg = sorted(successes, key=lambda r: r["score_raw"])[:5]
|
||||
|
||||
return {
|
||||
"asof": asof.isoformat(),
|
||||
"updated": len(successes),
|
||||
"failures": [str(e) for e in failures],
|
||||
"duration_sec": ...,
|
||||
"tokens_input": sum(r["tokens_input"] for r in successes),
|
||||
"tokens_output": sum(r["tokens_output"] for r in successes),
|
||||
"top_pos": top_pos,
|
||||
"top_neg": top_neg,
|
||||
}
|
||||
|
||||
|
||||
async def _process_ticker(ticker, sem, http_client, llm, news_per_ticker, model):
|
||||
async with sem:
|
||||
await asyncio.sleep(0.2) # rate limit
|
||||
news = await scraper.fetch_news(http_client, ticker, n=news_per_ticker)
|
||||
if not news:
|
||||
return {"ticker": ticker, "score_raw": 0.0,
|
||||
"reason": "no news", "news_count": 0,
|
||||
"tokens_input": 0, "tokens_output": 0}
|
||||
return await analyzer.score_sentiment(llm, ticker, news, model=model)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Scraper (`ai_news/scraper.py`)
|
||||
|
||||
```python
|
||||
NAVER_NEWS_URL = "https://finance.naver.com/item/news_news.naver"
|
||||
|
||||
async def fetch_news(client, ticker, n=5):
|
||||
r = await client.get(NAVER_NEWS_URL, params={"code": ticker, "page": 1})
|
||||
if r.status_code != 200:
|
||||
return []
|
||||
soup = BeautifulSoup(r.text, "lxml")
|
||||
rows = soup.select("table.type5 tbody tr")[:n]
|
||||
out = []
|
||||
for row in rows:
|
||||
title_el = row.select_one("td.title a")
|
||||
date_el = row.select_one("td.date")
|
||||
if not title_el or not date_el:
|
||||
continue
|
||||
out.append({
|
||||
"title": title_el.get_text(strip=True),
|
||||
"date": date_el.get_text(strip=True),
|
||||
})
|
||||
return out
|
||||
```
|
||||
|
||||
Rate limit: pipeline 의 `Semaphore(10) + 0.2초 sleep` 으로 보호.
|
||||
|
||||
---
|
||||
|
||||
## 9. Analyzer (`ai_news/analyzer.py`)
|
||||
|
||||
```python
|
||||
DEFAULT_MODEL = os.getenv("AI_NEWS_MODEL", "claude-haiku-4-5-20251001")
|
||||
|
||||
PROMPT_TEMPLATE = """다음은 종목 {name}({ticker})에 대한 최근 뉴스 {n}개의 헤드라인입니다.
|
||||
|
||||
{news_block}
|
||||
|
||||
이 뉴스들이 종목에 호재인지 악재인지 평가하세요.
|
||||
score: -10(매우 강한 악재) ~ +10(매우 강한 호재) 사이의 실수. 0은 중립.
|
||||
reason: 30자 이내 한 줄 근거.
|
||||
|
||||
JSON으로만 응답하세요:
|
||||
{{"score": <float>, "reason": "<string>"}}"""
|
||||
|
||||
async def score_sentiment(llm, ticker, news, *, model=DEFAULT_MODEL, name=None):
|
||||
news_block = "\n".join(f"- {n['title']}" for n in news)
|
||||
prompt = PROMPT_TEMPLATE.format(
|
||||
name=name or ticker, ticker=ticker,
|
||||
n=len(news), news_block=news_block,
|
||||
)
|
||||
resp = await llm.messages.create(
|
||||
model=model, max_tokens=200,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
try:
|
||||
text = resp.content[0].text
|
||||
data = json.loads(text)
|
||||
return {
|
||||
"ticker": ticker,
|
||||
"score_raw": float(data["score"]),
|
||||
"reason": str(data["reason"])[:200],
|
||||
"news_count": len(news),
|
||||
"tokens_input": resp.usage.input_tokens,
|
||||
"tokens_output": resp.usage.output_tokens,
|
||||
}
|
||||
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
||||
log.warning("ai_news parse fail for %s: %s", ticker, e)
|
||||
return {
|
||||
"ticker": ticker, "score_raw": 0.0,
|
||||
"reason": f"parse fail: {e!s}",
|
||||
"news_count": len(news),
|
||||
"tokens_input": resp.usage.input_tokens,
|
||||
"tokens_output": resp.usage.output_tokens,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 텔레그램 메시지 (`ai_news/telegram.py`)
|
||||
|
||||
```python
|
||||
def build_telegram_payload(*, asof, top_pos, top_neg,
|
||||
tokens_input, tokens_output, model):
|
||||
cost_won = int(tokens_input * 0.0013 + tokens_output * 0.0065) # ₩ 환산
|
||||
lines = [
|
||||
f"🌅 *AI 뉴스 분석* ({asof} 08:00)",
|
||||
"",
|
||||
"📈 *호재 Top 5*",
|
||||
]
|
||||
for i, r in enumerate(top_pos, 1):
|
||||
lines.append(
|
||||
f"{i}\\. {_escape(r['ticker'])} \\({r['score_raw']:+.1f}\\) — "
|
||||
f"{_escape(r['reason'])}"
|
||||
)
|
||||
lines += ["", "📉 *악재 Top 5*"]
|
||||
for i, r in enumerate(top_neg, 1):
|
||||
lines.append(
|
||||
f"{i}\\. {_escape(r['ticker'])} \\({r['score_raw']:+.1f}\\) — "
|
||||
f"{_escape(r['reason'])}"
|
||||
)
|
||||
lines += [
|
||||
"",
|
||||
f"_분석: 시총 상위 100종목 · 토큰 {tokens_input:,} in / {tokens_output:,} out · "
|
||||
f"약 ₩{cost_won:,}_",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
```
|
||||
|
||||
agent-office 가 텔레그램 발송 책임: stock-lab `/refresh-news-sentiment` 응답을 받아 `messaging.send_raw(text, parse_mode="MarkdownV2")` 호출.
|
||||
|
||||
---
|
||||
|
||||
## 11. agent-office 통합
|
||||
|
||||
### 11.1 `agents/stock.py`
|
||||
|
||||
```python
|
||||
async def on_ai_news_schedule(self):
|
||||
"""평일 08:00 KST cron."""
|
||||
try:
|
||||
result = await service_proxy.refresh_ai_news_sentiment()
|
||||
except httpx.HTTPError as e:
|
||||
await self.telegram.send_raw(f"⚠️ AI 뉴스 분석 실패: {e!s}")
|
||||
return
|
||||
|
||||
if result.get("updated", 0) == 0:
|
||||
await self.telegram.send_raw("⚠️ AI 뉴스: 0종목 분석됨 (스크래핑/LLM 전체 실패)")
|
||||
return
|
||||
|
||||
failure_rate = len(result.get("failures", [])) / 100
|
||||
if failure_rate > 0.3:
|
||||
await self.telegram.send_raw(
|
||||
f"⚠️ AI 뉴스 실패율 {failure_rate:.0%} — 어제 데이터 사용 가능성"
|
||||
)
|
||||
|
||||
payload = build_telegram_payload(
|
||||
asof=result["asof"],
|
||||
top_pos=result["top_pos"], top_neg=result["top_neg"],
|
||||
tokens_input=result["tokens_input"],
|
||||
tokens_output=result["tokens_output"],
|
||||
model=DEFAULT_MODEL,
|
||||
)
|
||||
await self.telegram.send_raw(payload, parse_mode="MarkdownV2")
|
||||
```
|
||||
|
||||
### 11.2 `scheduler.py`
|
||||
|
||||
```python
|
||||
scheduler.add_job(
|
||||
stock_agent.on_ai_news_schedule,
|
||||
"cron", day_of_week="mon-fri", hour=8, minute=0,
|
||||
id="stock_ai_news_sentiment",
|
||||
timezone="Asia/Seoul",
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 에러 처리
|
||||
|
||||
| 상황 | 처리 |
|
||||
|------|------|
|
||||
| 네이버 뉴스 페이지 404/타임아웃 | 해당 종목 score_raw=0 + reason="no news", failures 별도 카운트 |
|
||||
| BeautifulSoup 파싱 실패 (HTML 구조 변경) | 동일 처리 (failures 카운트) |
|
||||
| LLM JSON 파싱 실패 | score_raw=0 + reason="parse fail", tokens는 그래도 누적 (실제 호출됨) |
|
||||
| anthropic API 5xx | 자동 retry 1회 (SDK 기본), 실패 시 failures 카운트 |
|
||||
| 전체 cron 실패 (네트워크 등) | agent-office 에러 텔레그램 + 16:30 잡은 어제 sentiment 데이터 사용 (date 비교로 자동) |
|
||||
| 실패율 > 30% | 텔레그램 경고 알림. 단 부분 데이터는 그대로 DB 반영 |
|
||||
| 16:30 시점 news_sentiment 비어 있음 | AiNewsSentiment.compute() 가 빈 Series 반환 → 가중합에서 이 노드 자동 제외 |
|
||||
| LLM이 -10/+10 범위 벗어난 값 응답 | clamp `max(-10, min(10, score))` 적용 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 동시성 & rate limit
|
||||
|
||||
- `asyncio.Semaphore(10)` — 동시 10종목 처리 (네이버 차단 회피)
|
||||
- 종목 처리 사이 0.2초 sleep (semaphore 안에서)
|
||||
- 100종목 ÷ 10 동시 × 평균 3초/종목 = **~30-60초 총 소요**
|
||||
- agent-office httpx timeout = 180초 (충분한 여유)
|
||||
- stock-lab _conn() 의 WAL + busy_timeout=120s 로 16:30 잡과 동시 실행 시 lock 보호
|
||||
|
||||
---
|
||||
|
||||
## 14. 비용 모니터링
|
||||
|
||||
- 종목당 평균: input ~500 tokens, output ~50 tokens
|
||||
- 일 비용: 50K input × $1/M + 5K output × $5/M = **$0.075/일**
|
||||
- 월 비용: **~$1.6** (텔레그램 메시지 하단에 매일 ₩72 형태로 표시)
|
||||
- `news_sentiment.tokens_input/output` 컬럼으로 누적 추적 가능
|
||||
- 환산: 1 USD ≈ ₩1,300, input $0.0013/K, output $0.0065/K (장기 평균)
|
||||
|
||||
---
|
||||
|
||||
## 15. 프론트엔드 변경
|
||||
|
||||
캔버스 모드에 8번째 점수 노드 추가. 아래 한 파일만 수정:
|
||||
|
||||
```js
|
||||
// canvasLayout.js
|
||||
export const NODE_IDS = {
|
||||
...,
|
||||
AI_NEWS: 'score-ai-news', // 신규
|
||||
...,
|
||||
};
|
||||
export const NODE_KIND_MAP = { ..., [NODE_IDS.AI_NEWS]: 'score', ... };
|
||||
export const SCORE_NODE_NAME_MAP = { ..., [NODE_IDS.AI_NEWS]: 'ai_news' };
|
||||
export const SCORE_NODE_LABEL = {
|
||||
...,
|
||||
[NODE_IDS.AI_NEWS]: { icon: '🤖', title: 'AI 뉴스' },
|
||||
};
|
||||
export const INITIAL_NODE_POSITIONS = {
|
||||
...,
|
||||
// 기존 7개 score y: 0,90,180,270,360,450,540 → 8개 y: 0,90,...,630
|
||||
[NODE_IDS.AI_NEWS]: { x: 480, y: 630 },
|
||||
};
|
||||
const SCORE_KEYS = [..., 'AI_NEWS']; // 한 줄 추가
|
||||
```
|
||||
|
||||
폼 모드 `NodePanel` 은 백엔드 `/api/stock/screener/nodes` 응답 기반이라 백엔드 등록만으로 자동 반영.
|
||||
|
||||
테스트 갱신:
|
||||
- `canvasLayout.test.js`: 8 score 노드, 18 엣지 (1+8+8+1), Object.keys(SCORE_NODE_NAME_MAP) === 8
|
||||
|
||||
---
|
||||
|
||||
## 16. 테스트 전략
|
||||
|
||||
### 16.1 backend 단위 테스트
|
||||
|
||||
| 파일 | 검증 |
|
||||
|------|------|
|
||||
| `test_ai_news_scraper.py` | 네이버 HTML mock 파싱 (3종목 fixture, 빈 HTML, 404 응답) |
|
||||
| `test_ai_news_analyzer.py` | anthropic mock — success / JSON 파싱 실패 / score 범위 클램프 |
|
||||
| `test_ai_news_pipeline.py` | 5종목 mini integration (scraper/analyzer monkeypatch) — top_pos/top_neg 정렬 검증, failures 격리 검증 |
|
||||
| `test_ai_news_node.py` | AiNewsSentiment.compute() — percentile_rank 결과, min_news_count 필터, 빈 컨텍스트 |
|
||||
| `test_screener_schema.py` | news_sentiment DDL 생성 확인 (기존 테스트 보강) |
|
||||
| `test_screener_router.py` | POST /snapshot/refresh-news-sentiment 라우팅 검증 (mock pipeline) |
|
||||
|
||||
### 16.2 frontend 회귀 테스트
|
||||
|
||||
| 파일 | 검증 |
|
||||
|------|------|
|
||||
| `canvasLayout.test.js` (수정) | SCORE_NODE_NAME_MAP 8 entries, EDGES 18, AI_NEWS가 gate→score→combine 경로 가짐 |
|
||||
|
||||
### 16.3 수동 검증 체크리스트
|
||||
|
||||
배포 전 NAS에서:
|
||||
- [ ] 08:00 cron 트리거 (수동 `agent-office.on_ai_news_schedule()`)
|
||||
- [ ] news_sentiment 테이블에 100종목 행 생성 확인
|
||||
- [ ] 텔레그램 메시지 호재/악재 Top 5 + 비용 라인 정상 표시
|
||||
- [ ] 16:30 스크리너 잡이 ai_news 점수 가중합에 반영 (스크리너 결과의 scores.ai_news 컬럼 확인)
|
||||
- [ ] 캔버스 모드에 🤖 AI 뉴스 노드 표시, 활성/비활성 토글 동작
|
||||
- [ ] LLM 실패 시뮬레이션 (ANTHROPIC_API_KEY 잘못 설정 후 cron) → fail-soft 동작
|
||||
|
||||
---
|
||||
|
||||
## 17. 배포
|
||||
|
||||
- **백엔드**: stock-lab + agent-office 동시 변경 → git push → Gitea webhook → 자동 deployer rsync + docker compose build
|
||||
- **DB 마이그레이션**: `ensure_screener_schema(conn)` 의 `CREATE TABLE IF NOT EXISTS` 로 자동 (기존 패턴)
|
||||
- **환경변수**: stock-lab docker-compose.yml 에 `AI_NEWS_MODEL` (옵션) 추가 가능. 기본값 `claude-haiku-4-5-20251001`
|
||||
- **프론트**: web-ui에서 `npm run release:nas` (캔버스 노드 1개 추가는 작은 변경)
|
||||
|
||||
---
|
||||
|
||||
## 18. 후속 슬라이스 후보 (이번 슬라이스 NOT)
|
||||
|
||||
본 슬라이스 완료 후 자연스럽게 이어질 작업:
|
||||
|
||||
1. **URL 단위 캐싱** — 뉴스 분석 비용 ~70% 절감
|
||||
2. **장중 16:00 추가 sentiment cron** — 16:30 스크리너에 더 신선한 데이터 공급
|
||||
3. **종목별 sentiment 트렌드 차트** — 데이터 1-2주 누적 후 시각화
|
||||
4. **시총 200~500 확장** — 중소형주 sentiment 커버리지
|
||||
5. **백테스트** — sentiment 점수가 실수익에 미친 영향 회귀
|
||||
6. **다국어/거시 뉴스 통합** — 글로벌 시장 영향 변수 추가
|
||||
7. **알림 토글** — 운영 중 텔레그램 알림 일시 정지 옵션
|
||||
8. **종목별 sentiment 페이지** — 상세 뉴스 + 점수 + LLM 근거 가시화
|
||||
|
||||
---
|
||||
|
||||
## 19. 리스크와 완화
|
||||
|
||||
| 리스크 | 완화 |
|
||||
|--------|------|
|
||||
| 네이버 finance HTML 구조 변경 | 단위 테스트로 빠른 감지. fail-soft (해당 종목 skip). 운영 알림 (실패율 > 30%) |
|
||||
| LLM 응답이 JSON 깨짐 | 종목당 1콜 + JSON-mode prompt + 파싱 실패 시 단일 종목만 skip. lotto curator에서 검증된 패턴 |
|
||||
| 네이버 차단 (429) | Semaphore(10) + 0.2초 sleep + httpx User-Agent. 향후 429 응답 시 exponential backoff 추가 |
|
||||
| anthropic API 비용 폭증 | 일 1회 100종목 = $0.075 상한. 토큰 모니터링 컬럼 + 텔레그램 표시로 즉시 감지 |
|
||||
| 08:00 cron이 16:30 잡과 lock 충돌 | _conn() WAL + busy_timeout=120s 로 흡수. 두 cron 시간 8.5시간 차이로 실질 충돌 없음 |
|
||||
| 16:30 시점 news_sentiment 비어 있음 (cron 실패) | AiNewsSentiment.compute() 가 빈 Series → 가중합에서 자동 제외. 다른 7노드 점수만 사용 |
|
||||
|
||||
---
|
||||
|
||||
## 20. 완료 조건 (Definition of Done)
|
||||
|
||||
- [ ] 평일 08:00 KST agent-office cron 등록, 수동 트리거로 실행 검증
|
||||
- [ ] news_sentiment 테이블에 100종목 데이터 일별 생성
|
||||
- [ ] 텔레그램에 호재/악재 Top 5 + 비용 라인 표시
|
||||
- [ ] 16:30 스크리너 잡에서 ai_news 점수가 가중합에 반영 (scores.ai_news 노출)
|
||||
- [ ] 캔버스 모드에 8번째 노드 🤖 AI 뉴스 표시, 가중치/활성/파라미터 편집 동작
|
||||
- [ ] 폼 모드 NodePanel에 AI 뉴스 자동 노출 (백엔드 메타 기반)
|
||||
- [ ] 16.1 단위 테스트 모두 통과
|
||||
- [ ] 16.3 수동 검증 체크리스트 모두 통과
|
||||
- [ ] LLM 실패 시 fail-soft 동작 (전체 잡은 성공으로 끝나고, 실패 종목만 누락)
|
||||
@@ -1,505 +0,0 @@
|
||||
# Stock Screener — Node Canvas Mode Design
|
||||
|
||||
**작성일**: 2026-05-13
|
||||
**작성자**: gahusb
|
||||
**상태**: Approved for implementation
|
||||
**선행 spec**: `2026-05-12-stock-screener-board-design.md` (§14 — react-flow 노드 캔버스 후속 슬라이스)
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
`/stock/screener` 페이지에 **n8n 스타일 노드 캔버스 모드**를 추가한다. 폼 모드와 토글로 전환하며, 같은 settings state를 공유한다. 백엔드는 변경하지 않는다 — 캔버스는 시각화 + 편집 UI일 뿐, 결과적으로는 동일한 `weights / node_params / gate_params` 를 `/api/stock/screener/run` 에 전송한다.
|
||||
|
||||
**Why**: 사용자가 슬라이더만 들여다보는 폼 모드는 "어떤 노드가 어떤 단계에서 무엇을 하는지"의 파이프라인 감각이 약하다. n8n/Figma류 캔버스 시각화는 데이터 흐름을 한눈에 보여줘 강세주 분석 모델의 구조적 이해를 돕는다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
**포함 (이번 슬라이스)**:
|
||||
- 헤더 토글 (`폼 ↔ 캔버스`) — 데스크탑 전용
|
||||
- 11개 노드의 미니 파이프라인 시각화 (고정 토폴로지)
|
||||
- 점수 노드 카드 위 가중치/활성/핵심 파라미터 인라인 편집 + 설명 표시
|
||||
- floating 미니 툴바 (실행 / 저장 실행 / 설정 영구 저장 / 레이아웃 리셋)
|
||||
- 노드 위치 localStorage 저장 + 초기화 버튼
|
||||
- 모바일에서는 캔버스 토글 숨김, 폼 강제
|
||||
|
||||
**범위 외 (NOT)**:
|
||||
- 노드 추가/삭제 UI (토폴로지 고정)
|
||||
- 노드 간 연결선 사용자 편집
|
||||
- 자유 그래프 모드 (별도 후속 슬라이스)
|
||||
- 캔버스 안 결과 노드에 결과 표시 (외부 테이블에만 표시)
|
||||
- 노드 캔버스 화면 자체에서의 대화형 백테스트
|
||||
- dagre 등 자동 레이아웃 알고리즘
|
||||
|
||||
---
|
||||
|
||||
## 3. 아키텍처 개요
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Screener.jsx (entrypoint) │
|
||||
│ - useScreenerMode (form|canvas) │
|
||||
│ - useIsMobile() → 강제 form │
|
||||
└────────────┬────────────────┘
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
│ │ │
|
||||
form mode canvas mode shared result area
|
||||
(기존 그대로) (신규) (기존 그대로)
|
||||
│ │ │
|
||||
┌──────────┴──┐ ┌─────────┴──────┐ ┌────┴──────┐
|
||||
│ GatePanel │ │ ScreenerCanvas │ │ ResultTable
|
||||
│ NodePanel │ │ + CanvasToolbar│ │ TelegramPreview
|
||||
│ GlobalControls│ │ + Node cards │ │ RunHistoryList
|
||||
└──────────────┘ └─────────────────┘ └───────────┘
|
||||
↑ ↑ ↑
|
||||
└────────────────┴────────────────┘
|
||||
공유 state: useScreenerSettings,
|
||||
useScreenerRun, useScreenerHistory
|
||||
```
|
||||
|
||||
**의존성 추가**: `@xyflow/react` (구 react-flow, MIT, ~50KB gzipped).
|
||||
|
||||
**백엔드 변경 없음**. 캔버스는 settings를 동일한 형태로 만들고, 동일한 `/run` 엔드포인트를 호출한다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 화면 레이아웃
|
||||
|
||||
### 4.1 데스크탑 — 캔버스 모드
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ Header: 스크리너 [폼] [캔버스] │
|
||||
│ 최근 자동잡: 2026-05-13 · 분석 기준일: 2026-05-13│
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ ╔═════════════════════════════════════════════════════╗ │
|
||||
│ ║ ┌─ floating toolbar ──────────────────────────┐ ║ │
|
||||
│ ║ │ ▶ 실행 💾 저장 실행 📌 설정 저장 🔄 ⛶ │ ║ │
|
||||
│ ║ └──────────────────────────────────────────────┘ ║ │
|
||||
│ ║ ║ │
|
||||
│ ║ ┌─────┐ ┌──────┐ ┌───────┐ ║ │
|
||||
│ ║ │📥KRX│→ │🛡️위생│ ┬→│외국인 │ ┐ ║ │
|
||||
│ ║ │data │ │gate │ ├→│거래량 │ │ ┌─────────────┐ ║ │
|
||||
│ ║ └─────┘ └──────┘ ├→│모멘텀 │ ┼→ │⚙️가중합+TopN │→ │📊│║│
|
||||
│ ║ ├→│52w고가│ │ │ +ATR 사이저 │ ║ │
|
||||
│ ║ ├→│RS │ │ └─────────────┘ ║ │
|
||||
│ ║ ├→│이평선│ ┤ ║ │
|
||||
│ ║ └→│VCP │ ┘ ║ │
|
||||
│ ║ ║ │
|
||||
│ ║ (캔버스 영역: 화면 높이의 약 60-65%) ║ │
|
||||
│ ╚═══════════════════════════════════════════════════════╝ │
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ ResultTable (기존 그대로) — 비교 모드 그대로 │
|
||||
│ TelegramPreview (기존 그대로) │
|
||||
│ RunHistoryList (기존 그대로 — 우측 사이드) │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**그리드 구성 (캔버스 모드)**:
|
||||
|
||||
- Row 1 — 헤더 (높이 자동)
|
||||
- Row 2 — 캔버스 영역 (`min-height: 60vh`, `max-height: 70vh`)
|
||||
- Row 3 — 2-column: 좌측 `ResultTable + TelegramPreview` (flex 1), 우측 `RunHistoryList` (width 300px)
|
||||
|
||||
폼 모드의 3-column 그리드(좌 사이드/센터/우 사이드)와 달리, 캔버스 모드는 캔버스가 가로 전체를 쓰고 결과 영역만 2-column으로 분리. `RunHistoryList` 의 위치는 두 모드 모두 "우측 결과 사이드"로 일관.
|
||||
|
||||
### 4.2 데스크탑 — 폼 모드
|
||||
|
||||
기존 layout 그대로. 헤더에 토글 [폼] [캔버스]만 추가.
|
||||
|
||||
### 4.3 모바일 (<768px)
|
||||
|
||||
기존 모바일 카드 layout 그대로. 헤더 토글 자체를 렌더하지 않음. localStorage에 `mode='canvas'`로 저장돼 있어도 무시.
|
||||
|
||||
---
|
||||
|
||||
## 5. 노드 종류
|
||||
|
||||
총 11개 노드, 4개 카테고리.
|
||||
|
||||
| 카테고리 | 노드 | 편집 | 색상 | 표시 정보 |
|
||||
|----------|------|------|------|-----------|
|
||||
| **데이터** | `📥 KRX 데이터` | 불가 | 회색 | "~2,800종목 · FDR" |
|
||||
| **게이트** | `🛡️ 위생 게이트` | 가능 | 노랑 | 파라미터 (min_market_cap 등) + 활성/비활성 |
|
||||
| **점수** | `📈 외국인` | 가능 | 컬러 | 가중치 + 핵심 파라미터 + 설명 |
|
||||
| **점수** | `📊 거래량 급증` | 가능 | 컬러 | 동일 |
|
||||
| **점수** | `🚀 모멘텀` | 가능 | 컬러 | 동일 |
|
||||
| **점수** | `🔝 52w 고가` | 가능 | 컬러 | 동일 |
|
||||
| **점수** | `💪 RS Rating` | 가능 | 컬러 | 동일 |
|
||||
| **점수** | `📉 이평선 정렬` | 가능 | 컬러 | 동일 |
|
||||
| **점수** | `🌀 VCP-lite` | 가능 | 컬러 | 동일 |
|
||||
| **결합** | `⚙️ 가중합+TopN+ATR` | 불가 | 회색 | "TopN=10 · ATR×2" 등 현재 settings 요약 |
|
||||
| **결과** | `📊 결과` | 불가 | 회색 | "마지막 실행: 2026-05-13 · 8종목 통과" |
|
||||
|
||||
점수 노드의 컬러는 기존 `NODE_META` 의 accent color 시스템과 동기화 — 폼 모드에서 쓰던 색상이 캔버스에서도 동일하게 적용.
|
||||
|
||||
---
|
||||
|
||||
## 6. 노드 카드 디자인
|
||||
|
||||
### 6.1 점수 노드 카드 (편집 가능)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ 📈 거래량 급증 ⓘ │ ← 호버 시 풀 설명 툴팁
|
||||
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │
|
||||
│ "20일 평균 대비 2배 이상" │ ← 항상 표시되는 한 줄 요약
|
||||
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │
|
||||
│ 가중치 [█████░░░░░] 0.5 │ ← 슬라이더 (0~1, step 0.05)
|
||||
│ ☑ 활성 │ ← 체크박스. uncheck = weight 0
|
||||
│ │
|
||||
│ ▾ 파라미터 (펼치면) │
|
||||
│ lookback_days: [ 20 ] 일 │
|
||||
│ multiplier: [2.0 ] │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 한 줄 요약: 기존 `NODE_META[name].summary` (없으면 `description` 첫 줄)
|
||||
- 풀 설명 (호버 툴팁): 기존 `NODE_META[name].description`
|
||||
- 파라미터 폼: `param_schema` 기반 자동 생성 (기존 `NodeCard.jsx` 와 동일 로직 재사용)
|
||||
|
||||
### 6.2 게이트 노드 카드 (편집 가능, 노랑)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ 🛡️ 위생 게이트 ⓘ │
|
||||
│ "통과해야 점수 단계 진입" │
|
||||
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │
|
||||
│ ☑ 활성 │
|
||||
│ ▾ 파라미터 │
|
||||
│ min_market_cap: [50] 억원 │
|
||||
│ exclude_spac: ☑ │
|
||||
│ ... │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.3 고정 노드 카드 (정보 표시만, 회색)
|
||||
|
||||
```
|
||||
┌────────────────────┐
|
||||
│ 📥 KRX 데이터 │
|
||||
│ ~2,800종목 · FDR │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
결합 노드는 동적으로 현재 settings를 요약 표시:
|
||||
```
|
||||
┌────────────────────────────┐
|
||||
│ ⚙️ 가중합 + TopN + ATR │
|
||||
│ Top 10 · RR 2.0 · ATR×2 │ ← settings에서 계산해서 표시
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
결과 노드도 동적:
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ 📊 결과 │
|
||||
│ 마지막 실행: 14:32 │
|
||||
│ 8 / 12 종목 통과 │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 캔버스 인터랙션
|
||||
|
||||
| 동작 | 결과 |
|
||||
|------|------|
|
||||
| 노드 드래그 | 위치 변경 → 드래그 종료 시 `screener-canvas-layout-v1` localStorage에 저장 |
|
||||
| 슬라이더 변경 | `useScreenerSettings.setLocal({...settings, weights: {...}})` → `dirty=true` |
|
||||
| 체크박스 (활성) | weight 토글: uncheck 시 weight=0 저장, check 시 이전 값 복원 (default = 0.5) |
|
||||
| 파라미터 ▾ 펼치기 | 카드 높이 동적 확장 |
|
||||
| 마우스 휠 | 줌 (React Flow 기본) |
|
||||
| 드래그 (빈 공간) | 팬 (React Flow 기본) |
|
||||
| ⛶ fitView 버튼 | 전체 노드 화면 맞춤 |
|
||||
| 🔄 레이아웃 리셋 | `INITIAL_NODE_POSITIONS` 로 복귀, localStorage 키 삭제 |
|
||||
| ▶ 실행 | 기존 `runPreview(settings)` → 결과는 하단 ResultTable |
|
||||
| 💾 저장 실행 | 기존 `runSave(settings)` → DB 영구화 |
|
||||
| 📌 설정 저장 | 기존 `save()` (settings 영구화) |
|
||||
|
||||
엣지 연결선은 사용자가 편집할 수 없음 (고정). React Flow 인스턴스 prop `nodesConnectable={false}`, `edgesUpdatable={false}`.
|
||||
|
||||
---
|
||||
|
||||
## 8. 컴포넌트 분해 (신규 파일)
|
||||
|
||||
```
|
||||
src/pages/stock/screener/
|
||||
Screener.jsx ← 모드 토글 추가, canvas 모드 분기 렌더
|
||||
hooks/
|
||||
useScreenerMode.js ← 신규: 'form' | 'canvas' state + localStorage
|
||||
useCanvasLayout.js ← 신규: 노드 위치 read/write/reset
|
||||
(기존 hooks 그대로)
|
||||
components/
|
||||
ModeToggle.jsx ← 신규: [폼][캔버스] 세그먼트 컨트롤 (헤더용)
|
||||
canvas/
|
||||
CanvasLayout.jsx ← 신규: 캔버스 + 결과 영역 그리드 (4.1 그리드 구성)
|
||||
ScreenerCanvas.jsx ← React Flow 루트 컨테이너
|
||||
CanvasToolbar.jsx ← floating Panel (실행/저장/리셋/fitView)
|
||||
nodes/
|
||||
ScoreNodeCard.jsx ← 점수 노드 카드 (편집)
|
||||
GateNodeCard.jsx ← 게이트 노드 카드 (편집)
|
||||
FixedNodeCard.jsx ← 데이터/결합/결과 카드 (정보만)
|
||||
constants/
|
||||
canvasLayout.js ← INITIAL_NODE_POSITIONS / EDGES / NODE_KIND_MAP
|
||||
(기존 components 그대로 — 폼 모드에서 계속 사용)
|
||||
```
|
||||
|
||||
기존 컴포넌트(`GatePanel`, `NodePanel`, `GlobalControls`, `ResultTable`, `TelegramPreview`, `RunHistoryList`)는 **변경 없음**. 결과 영역은 모드와 무관하게 동일.
|
||||
|
||||
### 8.1 `Screener.jsx` 변경점
|
||||
|
||||
```jsx
|
||||
const { mode, setMode } = useScreenerMode();
|
||||
const isMobile = useIsMobile();
|
||||
const effectiveMode = isMobile ? 'form' : mode;
|
||||
|
||||
return (
|
||||
<div className="screener-page">
|
||||
<header className="screener-header">
|
||||
<h1>스크리너</h1>
|
||||
{!isMobile && (
|
||||
<ModeToggle value={mode} onChange={setMode} />
|
||||
)}
|
||||
</header>
|
||||
|
||||
{effectiveMode === 'form' ? (
|
||||
<FormLayout {...sharedProps} /> /* 기존 grid layout */
|
||||
) : (
|
||||
<CanvasLayout {...sharedProps} /> /* 신규 — 캔버스 + 동일 결과 영역 */
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 데이터 / state 설계
|
||||
|
||||
### 9.1 localStorage 키
|
||||
|
||||
| 키 | shape | 설명 |
|
||||
|----|-------|------|
|
||||
| `screener-mode-v1` | `'form' \| 'canvas'` | 마지막 사용 모드 |
|
||||
| `screener-canvas-layout-v1` | `{ [nodeId: string]: { x: number, y: number } }` | 노드별 좌표 |
|
||||
|
||||
### 9.2 `useScreenerMode`
|
||||
|
||||
```js
|
||||
export function useScreenerMode() {
|
||||
const [mode, setModeState] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('screener-mode-v1') || 'form';
|
||||
} catch { return 'form'; }
|
||||
});
|
||||
const setMode = (m) => {
|
||||
setModeState(m);
|
||||
try { localStorage.setItem('screener-mode-v1', m); } catch {}
|
||||
};
|
||||
return { mode, setMode };
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 `useCanvasLayout`
|
||||
|
||||
```js
|
||||
export function useCanvasLayout(initialPositions) {
|
||||
const STORAGE_KEY = 'screener-canvas-layout-v1';
|
||||
const [positions, setPositions] = useState(() => readOrInit(initialPositions));
|
||||
|
||||
const updateNodePosition = (nodeId, pos) => {
|
||||
setPositions((prev) => {
|
||||
const next = { ...prev, [nodeId]: pos };
|
||||
writeSafe(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const reset = () => {
|
||||
setPositions(initialPositions);
|
||||
try { localStorage.removeItem(STORAGE_KEY); } catch {}
|
||||
};
|
||||
return { positions, updateNodePosition, reset };
|
||||
}
|
||||
```
|
||||
|
||||
`readOrInit` 은 JSON.parse 실패하거나 노드 ID가 누락된 경우 누락된 ID에 대해서만 `initialPositions` 값을 보충.
|
||||
|
||||
### 9.4 `canvasLayout.js` 상수
|
||||
|
||||
```js
|
||||
export const NODE_IDS = {
|
||||
DATA: 'data',
|
||||
GATE: 'gate-hygiene',
|
||||
FOREIGN: 'score-foreign-buy',
|
||||
VOLUME: 'score-volume-surge',
|
||||
MOMENTUM: 'score-momentum',
|
||||
HIGH52W: 'score-high52w',
|
||||
RS: 'score-rs-rating',
|
||||
MA: 'score-ma-alignment',
|
||||
VCP: 'score-vcp-lite',
|
||||
COMBINE: 'combine',
|
||||
RESULT: 'result',
|
||||
};
|
||||
|
||||
export const INITIAL_NODE_POSITIONS = {
|
||||
[NODE_IDS.DATA]: { x: 40, y: 280 },
|
||||
[NODE_IDS.GATE]: { x: 240, y: 280 },
|
||||
[NODE_IDS.FOREIGN]: { x: 480, y: 0 },
|
||||
[NODE_IDS.VOLUME]: { x: 480, y: 90 },
|
||||
[NODE_IDS.MOMENTUM]: { x: 480, y: 180 },
|
||||
[NODE_IDS.HIGH52W]: { x: 480, y: 270 },
|
||||
[NODE_IDS.RS]: { x: 480, y: 360 },
|
||||
[NODE_IDS.MA]: { x: 480, y: 450 },
|
||||
[NODE_IDS.VCP]: { x: 480, y: 540 },
|
||||
[NODE_IDS.COMBINE]: { x: 800, y: 280 },
|
||||
[NODE_IDS.RESULT]: { x: 1080, y: 280 },
|
||||
};
|
||||
|
||||
export const EDGES = [
|
||||
{ id: 'e-data-gate', source: NODE_IDS.DATA, target: NODE_IDS.GATE },
|
||||
...['FOREIGN','VOLUME','MOMENTUM','HIGH52W','RS','MA','VCP'].map((k) => ({
|
||||
id: `e-gate-${k.toLowerCase()}`, source: NODE_IDS.GATE, target: NODE_IDS[k],
|
||||
})),
|
||||
...['FOREIGN','VOLUME','MOMENTUM','HIGH52W','RS','MA','VCP'].map((k) => ({
|
||||
id: `e-${k.toLowerCase()}-combine`, source: NODE_IDS[k], target: NODE_IDS.COMBINE,
|
||||
})),
|
||||
{ id: 'e-combine-result', source: NODE_IDS.COMBINE, target: NODE_IDS.RESULT },
|
||||
];
|
||||
```
|
||||
|
||||
총 엣지 수: 1(data→gate) + 7(gate→점수) + 7(점수→combine) + 1(combine→result) = **16개**.
|
||||
|
||||
---
|
||||
|
||||
## 10. 시각 디자인 디테일
|
||||
|
||||
| 요소 | 스타일 |
|
||||
|------|--------|
|
||||
| 캔버스 배경 | `bg-screener-canvas` (다크 그리드, 점선 `#1f2937`) |
|
||||
| 고정 노드 카드 | 배경 `#1f2937`, 텍스트 `#9ca3af`, 200×64 |
|
||||
| 게이트 카드 | accent `#facc15` (노랑) 좌측 4px stripe, 220×auto |
|
||||
| 점수 카드 | accent = 기존 `NODE_META[name].color`, 240×auto |
|
||||
| 비활성 점수 카드 | opacity 0.45 + grayscale 0.6 |
|
||||
| 엣지 (active) | `#fbbf24` 1.5px, 약한 그라데이션 |
|
||||
| 엣지 (해당 점수 노드 weight=0) | `#374151` 1px, 점선 |
|
||||
| 미니맵 | **사용하지 않음** (캔버스 크기가 작아 불필요) |
|
||||
| Controls (줌/리셋) | React Flow `<Controls />` 좌하단, 미니멀 |
|
||||
| floating toolbar | 좌상단, `position: absolute`, `backdrop-filter: blur(8px)`, 반투명 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 모바일/엣지 케이스
|
||||
|
||||
| 케이스 | 처리 |
|
||||
|--------|------|
|
||||
| 모바일 진입 (≤768px) | 토글 미렌더, `effectiveMode = 'form'` 강제 |
|
||||
| 데스크탑 → 모바일 리사이즈 중 | `useIsMobile` 가 자동 감지 → 폼으로 폴백 |
|
||||
| localStorage 파싱 실패 | catch + reset → 초기 위치/모드로 복귀 |
|
||||
| 노드 ID 누락 (마이그레이션) | 누락 노드만 `INITIAL_NODE_POSITIONS` 값 사용, 나머지는 저장값 유지 |
|
||||
| 노드 ID 신규 추가 (후속) | 같은 누락 처리 로직으로 자동 흡수 |
|
||||
| React Flow 초기 렌더 깜빡임 | `fitView` 초기 옵션 + `defaultViewport` 명시로 흡수 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 테스트 전략
|
||||
|
||||
캔버스는 시각화 위주라 E2E 테스트 비용이 크므로 **단위 테스트 중심**으로 간다.
|
||||
|
||||
### 12.1 단위 테스트 (web-ui)
|
||||
|
||||
| 파일 | 검증 |
|
||||
|------|------|
|
||||
| `useScreenerMode.test.js` | 초기값 'form', set 후 localStorage 반영, 손상 시 fallback |
|
||||
| `useCanvasLayout.test.js` | 초기 positions 반환, updateNodePosition 후 localStorage 반영, reset 후 storage 삭제, 손상 시 initial 반환, 누락 ID 시 initial 보충 |
|
||||
| `canvasLayout.test.js` | EDGES 정합성: 모든 점수 노드가 gate 입력과 combine 출력을 가짐, source/target ID가 NODE_IDS 안에 존재 |
|
||||
| `ScoreNodeCard.test.jsx` | 슬라이더 onChange 호출, 비활성 체크박스 시 weight=0, 활성 복원 시 default 0.5 |
|
||||
|
||||
### 12.2 통합 (가볍게)
|
||||
|
||||
- `Screener.test.jsx` 회귀: 폼 모드 기본 렌더 후 토글로 캔버스 진입, 다시 폼으로 — settings state 유지 확인
|
||||
|
||||
### 12.3 수동 검증 체크리스트
|
||||
|
||||
배포 전 데스크탑 브라우저:
|
||||
- [ ] 토글 폼↔캔버스 전환 시 가중치 동기화
|
||||
- [ ] 캔버스에서 슬라이더 → `dirty` 표시 정상
|
||||
- [ ] `▶ 실행` → 하단 ResultTable 갱신
|
||||
- [ ] 노드 드래그 → 새로고침 후 위치 복원
|
||||
- [ ] `🔄` 리셋 → 초기 위치로 복귀
|
||||
- [ ] 모바일 (DevTools 360×640) → 토글 미표시, 폼 강제
|
||||
|
||||
---
|
||||
|
||||
## 13. 성능
|
||||
|
||||
| 항목 | 평가 |
|
||||
|------|------|
|
||||
| 번들 사이즈 | `@xyflow/react` ~50KB gzipped + 노드 카드 컴포넌트 ~5KB. 전체 web-ui 번들 영향 미미 |
|
||||
| 렌더 비용 | 11개 노드, 16개 엣지 — React Flow 권장 한계 대비 매우 작음 |
|
||||
| localStorage I/O | 노드 드래그 종료(`onNodeDragStop`) 시점에만 write, 드래그 중 빈번한 write 없음 |
|
||||
| 모바일 폴백 | useIsMobile 분기로 캔버스 컴포넌트 자체를 mount하지 않음 → 모바일 번들 부담 없음 (lazy import 검토 가치 있음) |
|
||||
|
||||
`@xyflow/react` 는 데스크탑 진입 시에만 필요하므로 **`React.lazy` + `Suspense` 로 분리 import** 권장 (Plan에서 task로 명시).
|
||||
|
||||
---
|
||||
|
||||
## 14. 후속 슬라이스 후보 (이번 슬라이스 NOT)
|
||||
|
||||
이번 캔버스 슬라이스가 완료된 이후 자연스럽게 이어질 수 있는 작업들:
|
||||
|
||||
1. **노드 추가/삭제 UI** — 캔버스 우클릭 메뉴로 점수 노드 추가/제거 (백엔드 registry 동적 등록 필요)
|
||||
2. **자유 그래프 모드** — 토폴로지 자체를 사용자가 구성 (엔진 재설계 동반)
|
||||
3. **캔버스 안 결과 노드 펼치기** — 결과 노드 클릭 시 in-canvas 결과 표
|
||||
4. **캔버스 백테스트 시각화** — 노드별 기여도 히트맵 (후속 백테스트 슬라이스와 연동)
|
||||
5. **노드 그룹화** — 점수 노드 7개를 묶어 접기/펼치기
|
||||
6. **키보드 단축키** — Space=실행, Cmd+S=저장, R=리셋
|
||||
|
||||
---
|
||||
|
||||
## 15. 리스크와 완화
|
||||
|
||||
| 리스크 | 완화 |
|
||||
|--------|------|
|
||||
| `@xyflow/react` API 변경 (v11 → v12 transition 중) | spec 작성 시점 안정 버전(`12.x`) 고정, package.json에 명시 |
|
||||
| 캔버스 모드에서 폼 모드 settings와 동기화 깨짐 | 같은 hook 인스턴스 공유 + Screener.jsx 한 컴포넌트가 두 layout 분기 렌더 → 동일 state 자동 공유 |
|
||||
| 노드 카드가 너무 커서 캔버스 빽빽 | spec 6장의 카드 폭(220~240px), 점수 노드 세로 90px 간격으로 사전 검증된 좌표 사용 |
|
||||
| localStorage 무한 누적 | 키는 정해진 1개씩만 사용, 마이그레이션 시 키 명에 -v1 suffix |
|
||||
| 모바일 사용자 혼란 | 토글 자체를 렌더하지 않음 → 캔버스 모드 존재 자체를 알지 못함 → 학습 부담 0 |
|
||||
|
||||
---
|
||||
|
||||
## 16. API/백엔드 영향
|
||||
|
||||
**없음**. 본 슬라이스는 프론트엔드 전용. 기존 API:
|
||||
- `GET /api/stock/screener/nodes`
|
||||
- `GET/PUT /api/stock/screener/settings`
|
||||
- `POST /api/stock/screener/run`
|
||||
|
||||
를 그대로 사용한다. settings의 shape도 변경 없음.
|
||||
|
||||
---
|
||||
|
||||
## 17. 배포
|
||||
|
||||
- 프론트만 변경 → `npm run release:nas` 또는 `scripts\deploy.bat --frontend`
|
||||
- 백엔드 배포 불필요
|
||||
- 마이그레이션 불필요 (DB 변경 없음, localStorage는 점진적 적용)
|
||||
|
||||
---
|
||||
|
||||
## 18. 완료 조건 (Definition of Done)
|
||||
|
||||
- [ ] 데스크탑에서 헤더 [폼][캔버스] 토글이 보이고 정상 전환
|
||||
- [ ] 캔버스 모드에 11개 노드, 16개 엣지가 사전 정의된 위치로 표시
|
||||
- [ ] 점수 노드 카드에서 가중치 슬라이더/활성 체크박스/핵심 파라미터 편집 동작
|
||||
- [ ] 카드 ⓘ 호버 시 설명 툴팁 표시, 한 줄 요약 항상 표시
|
||||
- [ ] floating 툴바 4개 버튼 (실행/저장 실행/설정 저장/레이아웃 리셋) 모두 동작
|
||||
- [ ] 노드 드래그 → localStorage 저장 → 새로고침 후 복원
|
||||
- [ ] 🔄 리셋 → 초기 좌표 복귀 + localStorage 삭제
|
||||
- [ ] 모바일 (≤768px)에서 토글 미렌더, 폼 강제
|
||||
- [ ] 폼/캔버스 모드 전환해도 settings, 미리보기 히스토리, 결과 유지
|
||||
- [ ] 12.1의 단위 테스트 모두 통과
|
||||
- [ ] 12.3의 수동 검증 체크리스트 통과
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/main_logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>가후습 개인기록</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
2588
package-lock.json
generated
2588
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -10,26 +10,19 @@
|
||||
"deploy:nas": "node scripts/deploy-nas.cjs",
|
||||
"release:nas": "npm run build && npm run deploy:nas",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"recharts": "^3.7.0",
|
||||
"three": "^0.182.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.2.79",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
@@ -37,9 +30,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"rimraf": "^6.1.2",
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^2.1.9"
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,121 +1,58 @@
|
||||
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";
|
||||
// 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 dstWin = "Z:\\docker\\webpage\\frontend\\";
|
||||
const dstMac = "/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) {
|
||||
// PowerShell single-quote literal로 path 전달 — backslash over-escape 회피
|
||||
const cmd =
|
||||
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $src='dist'; $dst='${dstWin}'; 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 }"`;
|
||||
'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 }"';
|
||||
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) {
|
||||
// 제어문자·줄바꿈 제거 (잘못된 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단계: 원격 디렉토리 초기화
|
||||
const sshCmd = sshPort ? `ssh -p ${sshPort}` : "ssh";
|
||||
execSync(
|
||||
`${sshBase} ${cleanTarget} "rm -rf '${cleanPath}'/* 2>/dev/null; mkdir -p '${cleanPath}'"`,
|
||||
`rsync -r --delete --delete-delay -e \"${sshCmd}\" ${src}/ ${sshTarget}:${sshPath}`,
|
||||
{ stdio: "inherit" }
|
||||
);
|
||||
// 2단계: 빌드 산출물 tar로 전송 → 원격에서 압축 해제
|
||||
execSync(
|
||||
`cd ${src} && tar czf - . | ${sshBase} ${cleanTarget} "cd '${cleanPath}' && tar xzf -"`,
|
||||
{ stdio: "inherit" }
|
||||
);
|
||||
console.log("Deploy complete.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// SMB 마운트 경로 fallback
|
||||
if (!fs.existsSync(dst)) {
|
||||
console.error("NAS path not found: " + dst);
|
||||
printSshHint();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// rsync on macOS + SMB/NAS can be flaky; use ditto after a safe clean.
|
||||
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(
|
||||
"macOS SMB → Synology 쓰기 실패는 흔한 이슈입니다. SSH 배포를 사용하세요.\n"
|
||||
"NAS write test failed. Files may be locked or permissions are read-only."
|
||||
);
|
||||
printSshHint();
|
||||
process.exit(1);
|
||||
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;
|
||||
}
|
||||
|
||||
const sleep = (ms) =>
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
const retry = (fn, attempts = 6) => {
|
||||
@@ -159,15 +96,3 @@ 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("──────────────────────────────────────────────────");
|
||||
}
|
||||
|
||||
13
src/App.css
13
src/App.css
@@ -62,7 +62,6 @@
|
||||
@media (max-width: 768px) {
|
||||
.site-main {
|
||||
padding: 16px;
|
||||
padding-bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 16px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,15 +491,3 @@
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Accessibility: Reduced Motion ──────────────────────────────────── */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Navbar from './components/Navbar';
|
||||
import BottomNav from './components/BottomNav';
|
||||
import PageHeader from './components/PageHeader';
|
||||
import Loading from './components/Loading';
|
||||
import { useIsMobile } from './hooks/useIsMobile';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Navbar />
|
||||
@@ -21,7 +17,6 @@ function App() {
|
||||
</React.Suspense>
|
||||
</main>
|
||||
</div>
|
||||
{isMobile && <BottomNav />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
105
src/api.js
105
src/api.js
@@ -598,109 +598,4 @@ export const getPendingTasks = () => apiGet('/api/agent-office/tasks
|
||||
export const sendAgentCommand = (agent, action, params={}) => apiPost('/api/agent-office/command', { agent, action, params });
|
||||
export const approveAgentTask = (agent, task_id, approved, feedback='') => apiPost('/api/agent-office/approve', { agent, task_id, approved, feedback });
|
||||
export const getAgentStates = () => apiGet('/api/agent-office/states');
|
||||
export const getActivityFeed = (limit=50, offset=0) => apiGet(`/api/agent-office/activity?limit=${limit}&offset=${offset}`);
|
||||
export const getAgentTokenUsage = (id, days=1) => apiGet(`/api/agent-office/agents/${id}/token-usage?days=${days}`);
|
||||
|
||||
// --- Lotto Briefing ---
|
||||
|
||||
export async function getLatestBriefing() {
|
||||
const r = await fetch('/api/lotto/briefing/latest');
|
||||
if (r.status === 404) return null;
|
||||
if (!r.ok) throw new Error(`briefing fetch failed: ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function getCuratorUsage(days = 30) {
|
||||
const r = await fetch(`/api/lotto/curator/usage?days=${days}`);
|
||||
if (!r.ok) throw new Error(`usage fetch failed: ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function triggerLottoCurate() {
|
||||
const r = await fetch('/api/agent-office/command', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ agent: 'lotto', action: 'curate_now', params: {} }),
|
||||
});
|
||||
if (!r.ok) throw new Error(`curate trigger failed: ${r.status}`);
|
||||
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}`);
|
||||
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
/* BottomNav — mobile bottom navigation */
|
||||
|
||||
.bottom-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--bottom-nav-h);
|
||||
padding-bottom: var(--safe-area-bottom);
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--line);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
z-index: 300;
|
||||
align-items: stretch;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bottom-nav {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* Primary nav items */
|
||||
.bottom-nav__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
gap: 3px;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-body);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
transition: color 0.18s var(--ease-out);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.bottom-nav__item:hover,
|
||||
.bottom-nav__item.is-active,
|
||||
.bottom-nav__item--active {
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.bottom-nav__item:hover .bottom-nav__icon,
|
||||
.bottom-nav__item.is-active .bottom-nav__icon,
|
||||
.bottom-nav__item--active .bottom-nav__icon {
|
||||
filter: drop-shadow(0 0 6px var(--neon-cyan-dim));
|
||||
}
|
||||
|
||||
/* Icon wrapper */
|
||||
.bottom-nav__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
transition: filter 0.18s var(--ease-out);
|
||||
}
|
||||
|
||||
.bottom-nav__icon svg,
|
||||
.bottom-nav__icon > * {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* Label */
|
||||
.bottom-nav__label {
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- More overlay backdrop ---- */
|
||||
.bottom-nav__more-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 298;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.22s var(--ease-out);
|
||||
}
|
||||
|
||||
.bottom-nav__more-overlay.is-open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* ---- More panel ---- */
|
||||
.bottom-nav__more-panel {
|
||||
position: fixed;
|
||||
bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom));
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 299;
|
||||
padding: 16px 12px 12px;
|
||||
background: var(--surface-raised);
|
||||
border-top: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.25s var(--ease-out);
|
||||
}
|
||||
|
||||
.bottom-nav__more-panel.is-open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* More panel item */
|
||||
.bottom-nav__more-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px 4px;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
transition: color 0.18s var(--ease-out), border-color 0.18s var(--ease-out);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bottom-nav__more-item:hover,
|
||||
.bottom-nav__more-item.is-active {
|
||||
color: var(--neon-cyan);
|
||||
border-color: var(--neon-cyan-dim);
|
||||
}
|
||||
|
||||
.bottom-nav__more-item:hover .bottom-nav__icon,
|
||||
.bottom-nav__more-item.is-active .bottom-nav__icon {
|
||||
filter: drop-shadow(0 0 6px var(--neon-cyan-dim));
|
||||
}
|
||||
|
||||
/* Reduce motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bottom-nav__item,
|
||||
.bottom-nav__icon,
|
||||
.bottom-nav__more-overlay,
|
||||
.bottom-nav__more-panel,
|
||||
.bottom-nav__more-item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { navLinks } from '../routes';
|
||||
import './BottomNav.css';
|
||||
|
||||
const PRIMARY_PATHS = ['/', '/lotto', '/stock', '/travel'];
|
||||
|
||||
// Vertical dots (three circles) icon for "more"
|
||||
function MoreDotsIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 22 22"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="4.5" r="1.8" />
|
||||
<circle cx="11" cy="11" r="1.8" />
|
||||
<circle cx="11" cy="17.5" r="1.8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const primaryLinks = navLinks.filter((link) =>
|
||||
PRIMARY_PATHS.includes(link.path)
|
||||
);
|
||||
// Preserve the order defined in PRIMARY_PATHS
|
||||
const orderedPrimaryLinks = PRIMARY_PATHS.map((p) =>
|
||||
primaryLinks.find((l) => l.path === p)
|
||||
).filter(Boolean);
|
||||
|
||||
const moreLinks = navLinks.filter(
|
||||
(link) => !PRIMARY_PATHS.includes(link.path)
|
||||
);
|
||||
|
||||
export default function BottomNav() {
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
const openMore = useCallback(() => setMoreOpen(true), []);
|
||||
const closeMore = useCallback(() => setMoreOpen(false), []);
|
||||
const toggleMore = useCallback(() => setMoreOpen((prev) => !prev), []);
|
||||
|
||||
// Highlight the "more" button when the current path belongs to moreLinks
|
||||
const isMoreActive =
|
||||
moreOpen || moreLinks.some((link) => location.pathname === link.path);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`bottom-nav__more-overlay${moreOpen ? ' is-open' : ''}`}
|
||||
onClick={closeMore}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* More panel */}
|
||||
<div
|
||||
className={`bottom-nav__more-panel${moreOpen ? ' is-open' : ''}`}
|
||||
role="menu"
|
||||
aria-label="더보기 메뉴"
|
||||
>
|
||||
{moreLinks.map((link) => (
|
||||
<NavLink
|
||||
key={link.id}
|
||||
to={link.path}
|
||||
className={({ isActive }) =>
|
||||
`bottom-nav__more-item${isActive ? ' is-active' : ''}`
|
||||
}
|
||||
onClick={closeMore}
|
||||
role="menuitem"
|
||||
>
|
||||
<span className="bottom-nav__icon">{link.icon}</span>
|
||||
<span className="bottom-nav__label">{link.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom nav bar */}
|
||||
<nav className="bottom-nav" aria-label="하단 내비게이션">
|
||||
{orderedPrimaryLinks.map((link) => (
|
||||
<NavLink
|
||||
key={link.id}
|
||||
to={link.path}
|
||||
end={link.path === '/'}
|
||||
className={({ isActive }) =>
|
||||
`bottom-nav__item${isActive ? ' is-active' : ''}`
|
||||
}
|
||||
>
|
||||
<span className="bottom-nav__icon">{link.icon}</span>
|
||||
<span className="bottom-nav__label">{link.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{/* More button */}
|
||||
<button
|
||||
type="button"
|
||||
className={`bottom-nav__item${isMoreActive ? ' is-active' : ''}`}
|
||||
onClick={toggleMore}
|
||||
aria-expanded={moreOpen}
|
||||
aria-haspopup="menu"
|
||||
aria-label="더보기"
|
||||
>
|
||||
<span className="bottom-nav__icon">
|
||||
<MoreDotsIcon />
|
||||
</span>
|
||||
<span className="bottom-nav__label">더보기</span>
|
||||
</button>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/* FAB — Floating Action Button (mobile-only) */
|
||||
|
||||
.fab {
|
||||
display: none;
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom) + 16px);
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--grad-accent);
|
||||
border: none;
|
||||
color: #000;
|
||||
font-size: 24px;
|
||||
z-index: 250;
|
||||
box-shadow: 0 0 0 1px var(--neon-cyan-dim), 0 4px 16px rgba(0, 255, 255, 0.25);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: transform 0.15s var(--ease-out), box-shadow 0.15s var(--ease-out);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.fab {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.fab:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.fab__icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Variant: positioned above a music mini-player */
|
||||
.fab--above-player {
|
||||
bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom) + 16px + 56px);
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fab {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import './FAB.css';
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg
|
||||
className="fab__icon"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function FAB({ onClick, icon, label = '액션', className = '' }) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (!isMobile) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`fab ${className}`}
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
>
|
||||
{icon ?? <PlusIcon />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -102,16 +102,6 @@ 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(
|
||||
<>
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
.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%);
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
/* MobileSheet — bottom sheet modal */
|
||||
|
||||
/* Backdrop */
|
||||
.mobile-sheet__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 400;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s var(--ease-out);
|
||||
}
|
||||
|
||||
.mobile-sheet__backdrop.is-open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Sheet */
|
||||
.mobile-sheet {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 90vh;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--line);
|
||||
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||
z-index: 401;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
touch-action: none;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s var(--ease-out);
|
||||
}
|
||||
|
||||
.mobile-sheet.is-open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Snap variants */
|
||||
.mobile-sheet.snap-half {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
/* Drag handle area */
|
||||
.mobile-sheet__handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 0 8px;
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-sheet__handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mobile-sheet__handle-bar {
|
||||
display: block;
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
background: var(--text-muted);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.mobile-sheet__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-sheet__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.mobile-sheet__close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: color 0.18s var(--ease-out);
|
||||
}
|
||||
|
||||
.mobile-sheet__close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Scrollable body */
|
||||
.mobile-sheet__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
padding-bottom: calc(20px + var(--safe-area-bottom));
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mobile-sheet__backdrop,
|
||||
.mobile-sheet {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.mobile-sheet__close {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import './MobileSheet.css';
|
||||
|
||||
export default function MobileSheet({ open, onClose, title, snap = 'full', children }) {
|
||||
const [dragY, setDragY] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const startYRef = useRef(null);
|
||||
const sheetRef = useRef(null);
|
||||
|
||||
// Lock body scroll when open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Reset drag state on close
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setDragY(0);
|
||||
setIsDragging(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleHandleTouchStart = (e) => {
|
||||
startYRef.current = e.touches[0].clientY;
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleHandleTouchMove = (e) => {
|
||||
if (startYRef.current === null) return;
|
||||
const delta = e.touches[0].clientY - startYRef.current;
|
||||
if (delta < 0) return; // no drag up
|
||||
setDragY(delta);
|
||||
};
|
||||
|
||||
const handleHandleTouchEnd = () => {
|
||||
setIsDragging(false);
|
||||
if (dragY > 100) {
|
||||
setDragY(0);
|
||||
onClose?.();
|
||||
} else {
|
||||
setDragY(0);
|
||||
}
|
||||
startYRef.current = null;
|
||||
};
|
||||
|
||||
const sheetTransform = open
|
||||
? `translateY(${isDragging ? dragY : 0}px)`
|
||||
: 'translateY(100%)';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`mobile-sheet__backdrop ${open ? 'is-open' : ''}`}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
ref={sheetRef}
|
||||
className={`mobile-sheet snap-${snap} ${open ? 'is-open' : ''}`}
|
||||
style={{
|
||||
transform: sheetTransform,
|
||||
transition: isDragging ? 'none' : undefined,
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
className="mobile-sheet__handle"
|
||||
onTouchStart={handleHandleTouchStart}
|
||||
onTouchMove={handleHandleTouchMove}
|
||||
onTouchEnd={handleHandleTouchEnd}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="mobile-sheet__handle-bar" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mobile-sheet__header">
|
||||
<span className="mobile-sheet__title">{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-sheet__close"
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M3 3l12 12M15 3L3 15"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="mobile-sheet__body">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -334,6 +334,26 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.is-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 데스크톱: 토글 버튼 숨김 ────────────────────────────────────────── */
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.sidebar-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar__overlay {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +1,92 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { navLinks } from '../routes.jsx';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import mainLogo from '../assets/main_logo.png';
|
||||
import './Navbar.css';
|
||||
|
||||
const Navbar = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const closeMenu = () => setMenuOpen(false);
|
||||
|
||||
// 모바일에서는 BottomNav가 대체하므로 사이드바 미렌더링
|
||||
if (isMobile) return null;
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = menuOpen ? 'hidden' : '';
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar__brand">
|
||||
<img src={mainLogo} alt="Logo" className="sidebar__logo" />
|
||||
<div className="sidebar__brand-text">
|
||||
<p className="sidebar__brand-name">Jaeoh</p>
|
||||
<p className="sidebar__brand-sub">MANAGEMENT ROOM</p>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
{/* 모바일 오버레이 */}
|
||||
<div
|
||||
className={`sidebar__overlay${menuOpen ? ' is-visible' : ''}`}
|
||||
onClick={closeMenu}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="sidebar__divider" />
|
||||
{/* 모바일 토글 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-toggle"
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
aria-label="메뉴 열기/닫기"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
<span className={`sidebar-toggle__icon${menuOpen ? ' is-open' : ''}`}>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<nav className="sidebar__nav">
|
||||
<p className="sidebar__section-label">NAVIGATION</p>
|
||||
{navLinks.map((link) => (
|
||||
<NavLink
|
||||
key={link.id}
|
||||
to={link.path}
|
||||
className={({ isActive }) =>
|
||||
`sidebar__item${isActive ? ' is-active' : ''}`
|
||||
}
|
||||
style={{ '--item-accent': link.accent }}
|
||||
end={link.path === '/'}
|
||||
>
|
||||
<span className="sidebar__item-icon">{link.icon}</span>
|
||||
<span className="sidebar__item-label">{link.label}</span>
|
||||
<span className="sidebar__item-dot" />
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar__footer">
|
||||
<div className="sidebar__divider" />
|
||||
<div className="sidebar__footer-content">
|
||||
<div className="sidebar__status">
|
||||
<span className="sidebar__status-dot" />
|
||||
<span className="sidebar__status-text">System Online</span>
|
||||
{/* 사이드바 본체 */}
|
||||
<aside className={`sidebar${menuOpen ? ' is-open' : ''}`}>
|
||||
{/* 브랜드 섹션 */}
|
||||
<div className="sidebar__brand">
|
||||
<img src={mainLogo} alt="Logo" className="sidebar__logo" />
|
||||
<div className="sidebar__brand-text">
|
||||
<p className="sidebar__brand-name">Jaeoh</p>
|
||||
<p className="sidebar__brand-sub">MANAGEMENT ROOM</p>
|
||||
</div>
|
||||
<p className="sidebar__version">v2.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="sidebar__divider" />
|
||||
|
||||
{/* 네비게이션 */}
|
||||
<nav className="sidebar__nav">
|
||||
<p className="sidebar__section-label">NAVIGATION</p>
|
||||
{navLinks.map((link) => (
|
||||
<NavLink
|
||||
key={link.id}
|
||||
to={link.path}
|
||||
onClick={closeMenu}
|
||||
className={({ isActive }) =>
|
||||
`sidebar__item${isActive ? ' is-active' : ''}`
|
||||
}
|
||||
style={{ '--item-accent': link.accent }}
|
||||
end={link.path === '/'}
|
||||
>
|
||||
<span className="sidebar__item-icon">{link.icon}</span>
|
||||
<span className="sidebar__item-label">{link.label}</span>
|
||||
<span className="sidebar__item-dot" />
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* 사이드바 푸터 */}
|
||||
<div className="sidebar__footer">
|
||||
<div className="sidebar__divider" />
|
||||
<div className="sidebar__footer-content">
|
||||
<div className="sidebar__status">
|
||||
<span className="sidebar__status-dot" />
|
||||
<span className="sidebar__status-text">System Online</span>
|
||||
</div>
|
||||
<p className="sidebar__version">v2.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
/* PullToRefresh — pull-down-to-refresh wrapper */
|
||||
|
||||
.pull-to-refresh {
|
||||
position: relative;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
/* Indicator circle */
|
||||
.pull-to-refresh__indicator {
|
||||
position: absolute;
|
||||
top: -48px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s var(--ease-out);
|
||||
z-index: 10;
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.pull-to-refresh__indicator.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.pull-to-refresh__spinner {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--line);
|
||||
border-top-color: var(--neon-cyan);
|
||||
border-radius: 50%;
|
||||
animation: ptr-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ptr-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Arrow chevron */
|
||||
.pull-to-refresh__arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
transition: transform 0.2s var(--ease-out);
|
||||
}
|
||||
|
||||
.pull-to-refresh__arrow.is-ready {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.pull-to-refresh__content {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pull-to-refresh__spinner {
|
||||
animation: none;
|
||||
border-top-color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.pull-to-refresh__arrow {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.pull-to-refresh__indicator {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.pull-to-refresh__content {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useRef, useState, useCallback } from 'react';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import './PullToRefresh.css';
|
||||
|
||||
const THRESHOLD = 60;
|
||||
const MAX_PULL = 120;
|
||||
const RESISTANCE = 0.5;
|
||||
const CONTENT_SHIFT_FACTOR = 0.3;
|
||||
|
||||
export default function PullToRefresh({ onRefresh, children, className = '' }) {
|
||||
const isMobile = useIsMobile();
|
||||
const [pullY, setPullY] = useState(0);
|
||||
const [state, setState] = useState('idle'); // idle | pulling | ready | refreshing
|
||||
const startYRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (el.scrollTop > 0) return; // only trigger at top
|
||||
startYRef.current = e.touches[0].clientY;
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e) => {
|
||||
if (startYRef.current === null) return;
|
||||
const delta = e.touches[0].clientY - startYRef.current;
|
||||
if (delta <= 0) {
|
||||
setPullY(0);
|
||||
setState('idle');
|
||||
return;
|
||||
}
|
||||
const clamped = Math.min(delta * RESISTANCE, MAX_PULL);
|
||||
setPullY(clamped);
|
||||
setState(clamped >= THRESHOLD ? 'ready' : 'pulling');
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(async () => {
|
||||
if (startYRef.current === null) return;
|
||||
startYRef.current = null;
|
||||
if (state === 'ready') {
|
||||
setState('refreshing');
|
||||
setPullY(THRESHOLD);
|
||||
try {
|
||||
await onRefresh?.();
|
||||
} finally {
|
||||
setState('idle');
|
||||
setPullY(0);
|
||||
}
|
||||
} else {
|
||||
setState('idle');
|
||||
setPullY(0);
|
||||
}
|
||||
}, [state, onRefresh]);
|
||||
|
||||
if (!isMobile) {
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
|
||||
const indicatorVisible = state !== 'idle';
|
||||
const contentShift = pullY * CONTENT_SHIFT_FACTOR;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`pull-to-refresh ${className}`}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<div
|
||||
className={`pull-to-refresh__indicator ${indicatorVisible ? 'is-visible' : ''}`}
|
||||
style={{ transform: `translateY(${pullY}px)` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{state === 'refreshing' ? (
|
||||
<span className="pull-to-refresh__spinner" />
|
||||
) : (
|
||||
<span className={`pull-to-refresh__arrow ${state === 'ready' ? 'is-ready' : ''}`}>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M9 3v10M4 8l5 5 5-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="pull-to-refresh__content"
|
||||
style={{ transform: `translateY(${contentShift}px)`, transition: state === 'idle' ? 'transform 0.3s var(--ease-out)' : 'none' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
/* SwipeableView — swipeable tab container */
|
||||
|
||||
.swipeable-view {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Tab bar */
|
||||
.swipeable-view__tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.swipeable-view__tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Individual tab button */
|
||||
.swipeable-view__tab {
|
||||
flex: 1;
|
||||
min-width: fit-content;
|
||||
padding: 8px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: calc(var(--radius-md) - 2px);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color 0.18s var(--ease-out), background 0.18s var(--ease-out);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.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 */
|
||||
.swipeable-view__track {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
transition: transform 0.3s var(--ease-out);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.swipeable-view__track.is-swiping {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Each panel */
|
||||
.swipeable-view__panel {
|
||||
flex: 0 0 100%;
|
||||
min-width: 0;
|
||||
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 {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.swipeable-view__tab {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import './SwipeableView.css';
|
||||
|
||||
export default function SwipeableView({
|
||||
tabs = [],
|
||||
activeIndex: controlledIndex,
|
||||
onTabChange,
|
||||
showTabs = true,
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [internalIndex, setInternalIndex] = useState(0);
|
||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||
const [isSwiping, setIsSwiping] = useState(false);
|
||||
const trackRef = useRef(null);
|
||||
|
||||
const activeIndex = controlledIndex !== undefined ? controlledIndex : internalIndex;
|
||||
|
||||
const setIndex = (idx) => {
|
||||
const clamped = Math.max(0, Math.min(tabs.length - 1, idx));
|
||||
if (controlledIndex === undefined) setInternalIndex(clamped);
|
||||
onTabChange?.(clamped);
|
||||
};
|
||||
|
||||
const handlers = useSwipeable({
|
||||
onSwiping: ({ deltaX }) => {
|
||||
if (!isMobile) return;
|
||||
setIsSwiping(true);
|
||||
setSwipeOffset(deltaX);
|
||||
},
|
||||
onSwipedLeft: ({ deltaX }) => {
|
||||
if (!isMobile) return;
|
||||
setIsSwiping(false);
|
||||
setSwipeOffset(0);
|
||||
if (Math.abs(deltaX) > 30) setIndex(activeIndex + 1);
|
||||
},
|
||||
onSwipedRight: ({ deltaX }) => {
|
||||
if (!isMobile) return;
|
||||
setIsSwiping(false);
|
||||
setSwipeOffset(0);
|
||||
if (Math.abs(deltaX) > 30) setIndex(activeIndex - 1);
|
||||
},
|
||||
onTouchEndOrOnMouseUp: () => {
|
||||
setIsSwiping(false);
|
||||
setSwipeOffset(0);
|
||||
},
|
||||
trackMouse: false,
|
||||
trackTouch: true,
|
||||
delta: 30,
|
||||
preventScrollOnSwipe: false,
|
||||
});
|
||||
|
||||
const trackTranslate = -activeIndex * 100 + (isSwiping ? (swipeOffset / (trackRef.current?.offsetWidth || 1)) * 100 : 0);
|
||||
|
||||
return (
|
||||
<div className="swipeable-view">
|
||||
{showTabs && (
|
||||
<div className="swipeable-view__tabs" role="tablist">
|
||||
{tabs.map((tab, i) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
role="tab"
|
||||
aria-selected={i === activeIndex}
|
||||
className={`swipeable-view__tab ${i === activeIndex ? 'is-active' : ''}`}
|
||||
onClick={() => setIndex(i)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
{...(isMobile ? handlers : {})}
|
||||
ref={trackRef}
|
||||
className={`swipeable-view__track ${isSwiping ? 'is-swiping' : ''}`}
|
||||
style={{ transform: `translateX(${trackTranslate}%)` }}
|
||||
>
|
||||
{tabs.map((tab, i) => (
|
||||
<div
|
||||
key={tab.key}
|
||||
role="tabpanel"
|
||||
aria-hidden={i !== activeIndex}
|
||||
className="swipeable-view__panel"
|
||||
>
|
||||
{tab.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`);
|
||||
const handler = (e) => setIsMobile(e.matches);
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
@@ -72,8 +72,6 @@
|
||||
/* ── Layout ──────────────────────────────────────────────────────── */
|
||||
--sidebar-w: 240px;
|
||||
--topbar-h: 56px;
|
||||
--bottom-nav-h: 64px;
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
|
||||
/* ── Typography ──────────────────────────────────────────────────── */
|
||||
--font-display: 'Space Grotesk', 'Noto Sans KR', system-ui, serif;
|
||||
@@ -115,10 +113,6 @@ html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
@@ -246,6 +240,5 @@ select option {
|
||||
body {
|
||||
overflow: auto;
|
||||
background-attachment: scroll;
|
||||
padding-bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,477 +1,331 @@
|
||||
/* src/pages/agent-office/AgentOffice.css */
|
||||
|
||||
/* ===== Root Layout ===== */
|
||||
.ao-root {
|
||||
.ao-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #0d0d1a;
|
||||
color: #ffffff;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Courier New', monospace;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ===== Top Bar ===== */
|
||||
.ao-topbar {
|
||||
.ao-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
padding: 12px 20px;
|
||||
background: #1a1a2e;
|
||||
border-bottom: 1px solid #333;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid #2a2a4a;
|
||||
}
|
||||
.ao-topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.ao-topbar-title {
|
||||
font-weight: bold;
|
||||
font-size: 15px;
|
||||
|
||||
.ao-title {
|
||||
font-size: 1.4rem;
|
||||
color: #8b5cf6;
|
||||
margin: 0;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.ao-topbar-status {
|
||||
font-size: 11px;
|
||||
}
|
||||
.ao-topbar-status.connected { color: #22c55e; }
|
||||
.ao-topbar-status.disconnected { color: #ef4444; }
|
||||
.ao-topbar-right {
|
||||
|
||||
.ao-status {
|
||||
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;
|
||||
}
|
||||
|
||||
/* ===== 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;
|
||||
font-size: 0.85rem;
|
||||
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;
|
||||
|
||||
.ao-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.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;
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
.ao-dot--on { background: #34d399; }
|
||||
.ao-dot--off { background: #f87171; }
|
||||
|
||||
.ao-workspace {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ===== Log Tab ===== */
|
||||
.ao-log-tab {
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
.ao-canvas-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.ao-log-item {
|
||||
|
||||
.ao-agent-bar {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 20px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.ao-agent-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
padding: 3px 0;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: #ccc;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ao-agent-chip:hover { border-color: #8b5cf6; }
|
||||
.ao-agent-chip--selected { border-color: #8b5cf6; background: rgba(139, 92, 246, 0.15); }
|
||||
.ao-agent-chip--alert { animation: ao-pulse 1s infinite; }
|
||||
|
||||
@keyframes ao-pulse {
|
||||
0%, 100% { border-color: #fbbf24; }
|
||||
50% { border-color: #f59e0b; box-shadow: 0 0 8px rgba(251, 191, 36, 0.4); }
|
||||
}
|
||||
|
||||
.ao-chip-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.ao-chip-dot--idle { background: #666; }
|
||||
.ao-chip-dot--working { background: #818cf8; }
|
||||
.ao-chip-dot--waiting { background: #fbbf24; }
|
||||
.ao-chip-dot--reporting { background: #34d399; }
|
||||
.ao-chip-dot--break { background: #a78bfa; }
|
||||
|
||||
.ao-chip-badge {
|
||||
background: #f87171;
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ao-pending-count {
|
||||
color: #fbbf24;
|
||||
font-size: 0.75rem;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.ao-chat-panel {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 60px;
|
||||
width: 340px;
|
||||
max-height: calc(100% - 80px);
|
||||
background: rgba(26, 26, 46, 0.95);
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
overflow-y: auto;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.ao-chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #2a2a4a;
|
||||
}
|
||||
|
||||
.ao-chat-title {
|
||||
flex: 1;
|
||||
font-weight: bold;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.ao-chat-state {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.ao-chat-state--idle { background: #333; }
|
||||
.ao-chat-state--working { background: #3730a3; }
|
||||
.ao-chat-state--waiting { background: #92400e; }
|
||||
.ao-chat-state--break { background: #4c1d95; }
|
||||
|
||||
.ao-chat-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ao-chat-close:hover { color: #fff; }
|
||||
|
||||
.ao-chat-detail {
|
||||
padding: 8px 16px;
|
||||
color: #aaa;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ao-chat-approval {
|
||||
padding: 12px 16px;
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
border-top: 1px solid #2a2a4a;
|
||||
border-bottom: 1px solid #2a2a4a;
|
||||
}
|
||||
.ao-chat-approval p {
|
||||
margin: 0 0 8px;
|
||||
color: #fbbf24;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.ao-chat-approval-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ao-btn {
|
||||
padding: 6px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
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-chat-commands {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.ao-cmd-btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #ccc;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ao-cmd-btn:hover { border-color: #8b5cf6; background: rgba(139, 92, 246, 0.1); }
|
||||
|
||||
.ao-chat-input-area {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 16px 12px;
|
||||
}
|
||||
.ao-chat-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
background: #111;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ao-chat-input:focus { border-color: #8b5cf6; outline: none; }
|
||||
|
||||
.ao-chat-result {
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid #2a2a4a;
|
||||
}
|
||||
.ao-chat-result h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
}
|
||||
.ao-chat-result pre {
|
||||
font-size: 0.75rem;
|
||||
color: #aaa;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ao-history-panel {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 60px;
|
||||
width: 340px;
|
||||
max-height: calc(100% - 80px);
|
||||
background: rgba(26, 26, 46, 0.95);
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
overflow-y: auto;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.ao-history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #2a2a4a;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ao-history-list { padding: 8px; }
|
||||
.ao-history-empty { text-align: center; color: #666; padding: 20px; }
|
||||
|
||||
.ao-history-item {
|
||||
padding: 10px 12px;
|
||||
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; }
|
||||
.ao-history-item:last-child { border-bottom: none; }
|
||||
|
||||
/* ===== Common ===== */
|
||||
.ao-empty {
|
||||
color: #555;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: 13px;
|
||||
.ao-history-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.ao-history-type { font-size: 0.85rem; color: #ccc; }
|
||||
.ao-history-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
}
|
||||
.ao-history-time {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.ao-history-detail {
|
||||
margin-top: 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ao-history-detail summary {
|
||||
cursor: pointer;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
.ao-history-detail pre {
|
||||
color: #aaa;
|
||||
white-space: pre-wrap;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
/* ===== Mobile (< 768px) ===== */
|
||||
@media (max-width: 768px) {
|
||||
.ao-topbar-right { gap: 6px; }
|
||||
.ao-topbar-select { font-size: 11px; padding: 2px 6px; }
|
||||
|
||||
.ao-main {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ao-canvas {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Side panel → bottom sheet */
|
||||
.ao-sidepanel {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
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);
|
||||
}
|
||||
.ao-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 20px;
|
||||
background: #1a1a2e;
|
||||
border-top: 1px solid #2a2a4a;
|
||||
}
|
||||
|
||||
.ao-tool-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #aaa;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ao-tool-btn:hover { border-color: #8b5cf6; color: #e0e0e0; }
|
||||
|
||||
@@ -1,101 +1,85 @@
|
||||
// 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 React, { useRef, useState, useCallback, useEffect } from 'react';
|
||||
import { useAgentManager } from './hooks/useAgentManager';
|
||||
import { useOfficeCanvas } from './hooks/useOfficeCanvas';
|
||||
import ChatPanel from './components/ChatPanel';
|
||||
import TaskHistory from './components/TaskHistory';
|
||||
import './AgentOffice.css';
|
||||
|
||||
export default function AgentOffice() {
|
||||
const {
|
||||
agents, pendingTasks, notifications, connected,
|
||||
refreshTrigger, clearNotifications
|
||||
} = useAgentManager();
|
||||
|
||||
const {
|
||||
canvasRef, updateAgentState, setAgentNotification,
|
||||
setTheme, setZoom, hitTest, getZoom, wasDragging
|
||||
} = useOfficeCanvas();
|
||||
|
||||
export function Component() {
|
||||
const canvasContainerRef = useRef(null);
|
||||
const [selectedAgent, setSelectedAgent] = useState(null);
|
||||
const [theme, setThemeState] = useState(localStorage.getItem('agent-office-theme') || 'modern');
|
||||
const [zoom, setZoomState] = useState(2);
|
||||
const [showHistory, setShowHistory] = useState(null);
|
||||
|
||||
const { agents, pendingTasks, connected, sendCommand, sendApproval } = useAgentManager();
|
||||
|
||||
const handleAgentClick = useCallback((agentId) => {
|
||||
setSelectedAgent(prev => prev === agentId ? null : agentId);
|
||||
}, []);
|
||||
|
||||
const { updateAgentState, moveAgent } = useOfficeCanvas(canvasContainerRef, handleAgentClick);
|
||||
|
||||
// WebSocket 상태 → 캔버스 동기화
|
||||
useEffect(() => {
|
||||
for (const [id, agentState] of Object.entries(agents)) {
|
||||
updateAgentState(id, agentState.state, agentState.detail);
|
||||
for (const [id, info] of Object.entries(agents)) {
|
||||
updateAgentState(id, info.state, info.detail);
|
||||
}
|
||||
}, [agents, updateAgentState]);
|
||||
|
||||
// 알림 → 캔버스 동기화
|
||||
useEffect(() => {
|
||||
for (const [id, count] of Object.entries(notifications)) {
|
||||
setAgentNotification(id, count);
|
||||
}
|
||||
}, [notifications, setAgentNotification]);
|
||||
|
||||
// 캔버스 클릭 핸들러
|
||||
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-root">
|
||||
<TopBar
|
||||
connected={connected}
|
||||
theme={theme}
|
||||
onThemeChange={handleThemeChange}
|
||||
zoom={zoom}
|
||||
onZoomChange={handleZoomChange}
|
||||
/>
|
||||
<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-main">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="ao-canvas"
|
||||
onClick={handleCanvasClick}
|
||||
/>
|
||||
<div className="ao-workspace">
|
||||
<div className="ao-canvas-container" ref={canvasContainerRef} />
|
||||
|
||||
<div className="ao-agent-bar">
|
||||
{Object.entries(agents).map(([id, info]) => (
|
||||
<button
|
||||
key={id}
|
||||
className={`ao-agent-chip ${info.state === 'waiting' ? 'ao-agent-chip--alert' : ''} ${selectedAgent === id ? 'ao-agent-chip--selected' : ''}`}
|
||||
onClick={() => handleAgentClick(id)}
|
||||
>
|
||||
<span className={`ao-chip-dot ao-chip-dot--${info.state}`} />
|
||||
{id}
|
||||
{info.state === 'waiting' && <span className="ao-chip-badge">!</span>}
|
||||
</button>
|
||||
))}
|
||||
{pendingTasks.length > 0 && (
|
||||
<span className="ao-pending-count">{pendingTasks.length} pending</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedAgent && (
|
||||
<SidePanel
|
||||
<ChatPanel
|
||||
agentId={selectedAgent}
|
||||
agentState={agents[selectedAgent]}
|
||||
pendingTask={pendingTask}
|
||||
onCommand={sendCommand}
|
||||
onApproval={sendApproval}
|
||||
onClose={() => setSelectedAgent(null)}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showHistory && (
|
||||
<TaskHistory
|
||||
agentId={showHistory}
|
||||
onClose={() => setShowHistory(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ao-toolbar">
|
||||
{Object.keys(agents).map(id => (
|
||||
<button key={id} className="ao-tool-btn"
|
||||
onClick={() => setShowHistory(prev => prev === id ? null : id)}>
|
||||
📋 {id} 이력
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Component() {
|
||||
return <AgentOffice />;
|
||||
}
|
||||
|
||||
@@ -1,72 +1,45 @@
|
||||
{
|
||||
"cols": 32,
|
||||
"rows": 20,
|
||||
"tileSize": 32,
|
||||
"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]
|
||||
],
|
||||
"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]
|
||||
]
|
||||
},
|
||||
"furniture": [
|
||||
{"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}
|
||||
{"type": "desk", "x": 2, "y": 1, "label": "Stock"},
|
||||
{"type": "desk", "x": 7, "y": 1, "label": "Music"},
|
||||
{"type": "desk", "x": 12, "y": 1, "label": "Claude"},
|
||||
{"type": "desk", "x": 17, "y": 1, "label": "(빈)"},
|
||||
{"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"}
|
||||
],
|
||||
"waypoints": {
|
||||
"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}
|
||||
"stock_desk": {"x": 2, "y": 2},
|
||||
"music_desk": {"x": 7, "y": 2},
|
||||
"claude_desk": {"x": 12, "y": 2},
|
||||
"meeting_table": {"x": 9, "y": 7},
|
||||
"break_room": {"x": 2, "y": 11},
|
||||
"ceo_desk": {"x": 16, "y": 11}
|
||||
},
|
||||
"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"
|
||||
"colors": {
|
||||
"1": "#3a3a50",
|
||||
"2": "#4a3a2a"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,261 +1,84 @@
|
||||
// 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;
|
||||
import { drawAgent, getAnimSpeed } from './SpriteSheet';
|
||||
|
||||
export class AgentSprite {
|
||||
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)
|
||||
constructor(agentId, waypoints) {
|
||||
this.agentId = agentId;
|
||||
this.waypoints = waypoints;
|
||||
this.state = 'idle';
|
||||
this.detail = '';
|
||||
this.notificationCount = 0;
|
||||
|
||||
// 애니메이션
|
||||
this.animState = 'idle'; // 렌더링용 상태
|
||||
this.direction = 'down';
|
||||
this.animFrame = 0;
|
||||
this.animTimer = 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.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;
|
||||
this.frameIndex = 0;
|
||||
this._lastFrameTime = 0;
|
||||
this._moveSpeed = 0.05;
|
||||
}
|
||||
|
||||
/** 매 프레임 호출 */
|
||||
update(dt) {
|
||||
// 이동 처리
|
||||
if (this.path.length > 0) {
|
||||
this._updateMovement(dt);
|
||||
} else if (this._wandering) {
|
||||
this._updateWander(dt);
|
||||
}
|
||||
|
||||
// 애니메이션 프레임 업데이트
|
||||
this._updateAnimation(dt);
|
||||
}
|
||||
|
||||
_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 {
|
||||
// 보간
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
_onArrival() {
|
||||
const atDesk = Math.abs(this.x - this.deskCol) < 0.5 && Math.abs(this.y - this.deskRow) < 0.5;
|
||||
this._isAtDesk = atDesk;
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
_updateWander(dt) {
|
||||
if (this._isResting) {
|
||||
this._restTimer -= dt;
|
||||
if (this._restTimer <= 0) {
|
||||
this._isResting = false;
|
||||
this._startWandering();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
setState(newState, detail = '') {
|
||||
this.state = newState;
|
||||
this.detail = detail || '';
|
||||
this.detail = detail;
|
||||
this.frameIndex = 0;
|
||||
}
|
||||
|
||||
// 배회 중단
|
||||
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;
|
||||
moveTo(target) {
|
||||
const wp = this.waypoints[target];
|
||||
if (wp) {
|
||||
this.targetX = wp.x;
|
||||
this.targetY = wp.y;
|
||||
}
|
||||
}
|
||||
|
||||
_moveToDesk() {
|
||||
this._navigateTo(this.deskCol, this.deskRow);
|
||||
moveToDesk() {
|
||||
this.targetX = this.deskPos.x;
|
||||
this.targetY = this.deskPos.y;
|
||||
}
|
||||
|
||||
_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]);
|
||||
update(now) {
|
||||
const speed = getAnimSpeed(this.state);
|
||||
if (now - this._lastFrameTime > speed) {
|
||||
this.frameIndex++;
|
||||
this._lastFrameTime = now;
|
||||
}
|
||||
|
||||
const dx = this.targetX - this.x;
|
||||
const dy = this.targetY - this.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist > 0.1) {
|
||||
const step = Math.min(this._moveSpeed, dist);
|
||||
this.x += (dx / dist) * step;
|
||||
this.y += (dy / dist) * step;
|
||||
} else {
|
||||
this.x = this.targetX;
|
||||
this.y = this.targetY;
|
||||
}
|
||||
}
|
||||
|
||||
_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;
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
_startWandering() {
|
||||
this._startWanderingAfterDelay(WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN));
|
||||
}
|
||||
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;
|
||||
|
||||
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
|
||||
);
|
||||
return canvasX >= cx - hitW && canvasX <= cx + hitW &&
|
||||
canvasY >= cy - hitH && canvasY <= cy + hitH;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
// 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,229 +1,36 @@
|
||||
// src/pages/agent-office/canvas/OfficeRenderer.js
|
||||
import { drawTileMap } from './TileMap';
|
||||
import { AgentSprite } from './AgentSprite';
|
||||
import { getCharLabel } from './SpriteSheet';
|
||||
|
||||
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' }
|
||||
const STATUS_ICONS = {
|
||||
idle: null,
|
||||
working: null,
|
||||
waiting: '❗',
|
||||
reporting: '📋',
|
||||
break: '☕',
|
||||
};
|
||||
|
||||
export class OfficeRenderer {
|
||||
constructor(canvas) {
|
||||
constructor(canvas, mapData) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
|
||||
// 맵 & 렌더러
|
||||
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.mapData = mapData;
|
||||
this.renderInfo = null;
|
||||
this.agents = {};
|
||||
this._animId = null;
|
||||
this._lastDpr = window.devicePixelRatio || 1;
|
||||
this._onClick = null;
|
||||
|
||||
// 드래그 감지
|
||||
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);
|
||||
const agentIds = ['stock', 'music'];
|
||||
for (const id of agentIds) {
|
||||
this.agents[id] = new AgentSprite(id, mapData.waypoints);
|
||||
}
|
||||
}
|
||||
|
||||
/** 줌/팬/클릭 이벤트 핸들러 */
|
||||
_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);
|
||||
this._loop = this._loop.bind(this);
|
||||
this._animId = requestAnimationFrame(this._loop);
|
||||
}
|
||||
|
||||
/** 게임 루프 중지 */
|
||||
stop() {
|
||||
if (this._animId) {
|
||||
cancelAnimationFrame(this._animId);
|
||||
@@ -231,86 +38,92 @@ 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;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
_loop(timestamp) {
|
||||
const dt = Math.min((timestamp - this._lastTime) / 1000, 0.1); // cap dt to avoid spiral
|
||||
this._lastTime = timestamp;
|
||||
const { ctx, canvas, mapData } = this;
|
||||
|
||||
this._update(dt);
|
||||
this._render();
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = '#1a1a2e';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
this._animId = requestAnimationFrame((t) => this._loop(t));
|
||||
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);
|
||||
}
|
||||
|
||||
this._animId = requestAnimationFrame(this._loop);
|
||||
}
|
||||
|
||||
_update(dt) {
|
||||
for (const sprite of this.agents.values()) {
|
||||
sprite.update(dt);
|
||||
}
|
||||
}
|
||||
_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;
|
||||
|
||||
_render() {
|
||||
const ctx = this.ctx;
|
||||
const dpr = window.devicePixelRatio || 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;
|
||||
}
|
||||
// setTransform 방식으로 누적 없이 항상 올바른 변환 적용
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
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)
|
||||
});
|
||||
const icon = STATUS_ICONS[sprite.state];
|
||||
if (icon) {
|
||||
ctx.font = `${14 * scale}px serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(icon, cx, cy - 15 * scale);
|
||||
}
|
||||
|
||||
// Y좌표 정렬
|
||||
renderables.sort((a, b) => a.zY - b.zY);
|
||||
for (const item of renderables) {
|
||||
item.draw(ctx);
|
||||
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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
// 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)];
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
89
src/pages/agent-office/canvas/SpriteSheet.js
Normal file
89
src/pages/agent-office/canvas/SpriteSheet.js
Normal file
@@ -0,0 +1,89 @@
|
||||
const PIXEL_CHARS = {
|
||||
stock: { body: '#4488cc', accent: '#cc4444', label: '주식', hair: '#332222' },
|
||||
music: { body: '#44aa88', accent: '#ffaa00', label: '음악', hair: '#443322' },
|
||||
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 === '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;
|
||||
}
|
||||
@@ -1,80 +1,90 @@
|
||||
// src/pages/agent-office/canvas/TileMap.js
|
||||
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';
|
||||
|
||||
/**
|
||||
* 타일맵 렌더러 — 바닥, 벽, 그리드를 테마 팔레트로 렌더링
|
||||
* 가구는 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;
|
||||
}
|
||||
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);
|
||||
|
||||
/**
|
||||
* 바닥 + 벽 렌더링
|
||||
* @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;
|
||||
const offsetX = (width - cols * tileSize * scale) / 2;
|
||||
const offsetY = (height - rows * tileSize * scale) / 2;
|
||||
|
||||
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;
|
||||
ctx.save();
|
||||
ctx.translate(offsetX, offsetY);
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
// 화면 밖이면 스킵 (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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** 화면 좌표 → 타일 좌표 변환 */
|
||||
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 };
|
||||
ctx.fillStyle = WALL_COLOR;
|
||||
ctx.fillRect(0, 0, cols * tileSize, 4);
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
/** 타일 좌표 → 화면 좌표 (타일 중앙) */
|
||||
tileToScreen(col, row, scale, offsetX, offsetY) {
|
||||
const ts = this.tileSize * scale;
|
||||
return {
|
||||
x: col * ts + offsetX + ts / 2,
|
||||
y: row * ts + offsetY + ts / 2
|
||||
};
|
||||
}
|
||||
ctx.restore();
|
||||
return { scale, offsetX, offsetY, tileSize };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
// 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 }));
|
||||
}
|
||||
106
src/pages/agent-office/components/ChatPanel.jsx
Normal file
106
src/pages/agent-office/components/ChatPanel.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const AGENT_COMMANDS = {
|
||||
stock: [
|
||||
{ action: 'fetch_news', label: '뉴스 수집', icon: '📰' },
|
||||
{ action: 'list_alerts', label: '알람 목록', icon: '🔔' },
|
||||
],
|
||||
music: [
|
||||
{ action: 'compose', label: '작곡 시작', icon: '🎵', needsInput: true },
|
||||
{ action: 'credits', label: '크레딧 확인', icon: '💳' },
|
||||
],
|
||||
};
|
||||
|
||||
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 }
|
||||
: { 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">
|
||||
{agentId === 'stock' ? '주식 트레이더' :
|
||||
agentId === 'music' ? '음악 프로듀서' : 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' ? '프롬프트 입력...' : '메시지 입력...'}
|
||||
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,164 +0,0 @@
|
||||
// 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,45 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
62
src/pages/agent-office/components/TaskHistory.jsx
Normal file
62
src/pages/agent-office/components/TaskHistory.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getAgentTasks } 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 TaskHistory = ({ agentId, onClose }) => {
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId) return;
|
||||
setLoading(true);
|
||||
getAgentTasks(agentId, 30)
|
||||
.then(data => setTasks(data.tasks || []))
|
||||
.catch(() => setTasks([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [agentId]);
|
||||
|
||||
return (
|
||||
<div className="ao-history-panel">
|
||||
<div className="ao-history-header">
|
||||
<span>작업 이력 — {agentId}</span>
|
||||
<button className="ao-chat-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="ao-history-list">
|
||||
{loading && <p className="ao-history-empty">로딩 중...</p>}
|
||||
{!loading && 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">
|
||||
{task.created_at?.replace('T', ' ').slice(0, 19)}
|
||||
</div>
|
||||
{task.result_data && (
|
||||
<details className="ao-history-detail">
|
||||
<summary>결과 보기</summary>
|
||||
<pre>{JSON.stringify(task.result_data, null, 2)}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskHistory;
|
||||
@@ -1,60 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// 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,84 +1,74 @@
|
||||
// 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({}); // { agentId: { state, detail, task_id } }
|
||||
const [pendingTasks, setPendingTasks] = useState([]); // [{id, agent_id, task_type, input_data}]
|
||||
const [notifications, setNotifications] = useState({}); // { agentId: count }
|
||||
const [agents, setAgents] = useState({});
|
||||
const [pendingTasks, setPendingTasks] = useState([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0); // 탭 데이터 리프레시용
|
||||
|
||||
const wsRef = useRef(null);
|
||||
const reconnectRef = useRef(null);
|
||||
const reconnectTimer = useRef(null);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${protocol}://${window.location.host}/api/agent-office/ws`);
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/agent-office/ws`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => setConnected(true);
|
||||
ws.onopen = () => {
|
||||
setConnected(true);
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
reconnectTimer.current = setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = () => { ws.close(); };
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'init': {
|
||||
// 에이전트 초기 상태 세팅
|
||||
const agentMap = {};
|
||||
for (const a of msg.agents) {
|
||||
agentMap[a.agent_id] = { state: a.state, detail: a.detail || '', task_id: a.task_id };
|
||||
agentMap[a.agent_id] = { state: a.state, detail: a.detail };
|
||||
}
|
||||
setAgents(agentMap);
|
||||
setPendingTasks(msg.pending || []);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'agent_state':
|
||||
setAgents(prev => ({
|
||||
...prev,
|
||||
[msg.agent]: { state: msg.state, detail: msg.detail || '', task_id: msg.task_id }
|
||||
[msg.agent]: { state: msg.state, detail: msg.detail, taskId: msg.task_id },
|
||||
}));
|
||||
// idle 전환 시 데이터 리프레시
|
||||
if (msg.state === 'idle') {
|
||||
setRefreshTrigger(n => n + 1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'task_complete':
|
||||
setRefreshTrigger(n => n + 1);
|
||||
break;
|
||||
|
||||
case 'notification':
|
||||
setNotifications(prev => ({
|
||||
setAgents(prev => ({
|
||||
...prev,
|
||||
[msg.agent]: (prev[msg.agent] || 0) + 1
|
||||
[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 },
|
||||
}));
|
||||
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 (reconnectRef.current) clearTimeout(reconnectRef.current);
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
@@ -94,18 +84,5 @@ export function useAgentManager() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearNotifications = useCallback((agentId) => {
|
||||
setNotifications(prev => ({ ...prev, [agentId]: 0 }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
agents,
|
||||
pendingTasks,
|
||||
notifications,
|
||||
connected,
|
||||
refreshTrigger,
|
||||
sendCommand,
|
||||
sendApproval,
|
||||
clearNotifications
|
||||
};
|
||||
return { agents, pendingTasks, connected, sendCommand, sendApproval };
|
||||
}
|
||||
|
||||
@@ -1,64 +1,62 @@
|
||||
// src/pages/agent-office/hooks/useOfficeCanvas.js
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import { OfficeRenderer } from '../canvas/OfficeRenderer.js';
|
||||
import { OfficeRenderer } from '../canvas/OfficeRenderer';
|
||||
import officeMap from '../assets/office-map.json';
|
||||
|
||||
export function useOfficeCanvas() {
|
||||
const canvasRef = useRef(null);
|
||||
export function useOfficeCanvas(containerRef, onAgentClick) {
|
||||
const rendererRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const renderer = new OfficeRenderer(canvasRef.current);
|
||||
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);
|
||||
rendererRef.current = renderer;
|
||||
|
||||
const resize = () => {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
renderer.resize(rect.width, rect.height);
|
||||
};
|
||||
|
||||
resize();
|
||||
renderer.start();
|
||||
|
||||
const handleResize = () => renderer.resize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
renderer.setOnClick((agentId) => {
|
||||
if (onAgentClick) onAgentClick(agentId);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
renderer.destroy();
|
||||
rendererRef.current = null;
|
||||
renderer.stop();
|
||||
canvas.removeEventListener('click', handleClick);
|
||||
window.removeEventListener('resize', resize);
|
||||
if (containerRef.current && canvas.parentNode === containerRef.current) {
|
||||
containerRef.current.removeChild(canvas);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [containerRef, onAgentClick]);
|
||||
|
||||
const updateAgentState = useCallback((agentId, state, detail) => {
|
||||
rendererRef.current?.updateAgentState(agentId, state, detail);
|
||||
}, []);
|
||||
|
||||
const setAgentNotification = useCallback((agentId, count) => {
|
||||
rendererRef.current?.setAgentNotification(agentId, count);
|
||||
const moveAgent = useCallback((agentId, target) => {
|
||||
rendererRef.current?.moveAgent(agentId, target);
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback((themeName) => {
|
||||
rendererRef.current?.setTheme(themeName);
|
||||
}, []);
|
||||
|
||||
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
|
||||
};
|
||||
return { updateAgentState, moveAgent };
|
||||
}
|
||||
|
||||
@@ -125,30 +125,14 @@
|
||||
.bm-empty { text-align: center; padding: 48px 20px; color: rgba(255,255,255,.25); font-size: 0.85rem; }
|
||||
|
||||
/* ── 모바일 ───────────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.bm-tabs {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.bm-tabs > * {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@media (max-width: 640px) {
|
||||
.bm { padding: 16px 10px 60px; }
|
||||
.bm-header h1 { font-size: 1.2rem; }
|
||||
.bm-status { display: none; }
|
||||
.bm-tab { padding: 6px 10px; font-size: 0.8rem; }
|
||||
.bm-dash-cards { grid-template-columns: 1fr; }
|
||||
.bm-dash-cards { grid-template-columns: repeat(2, 1fr); }
|
||||
.bm-research-form { flex-direction: column; }
|
||||
.bm-analysis-card__scores { gap: 10px; }
|
||||
.bm-write-actions { flex-direction: column; }
|
||||
.bm-post-card__actions { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bm-spinner { animation: none; }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
import {
|
||||
getBlogMarketingStatus,
|
||||
startResearch,
|
||||
@@ -86,13 +84,9 @@ export default function BlogMarketing() {
|
||||
const [tab, setTab] = useState('dashboard');
|
||||
const [status, setStatus] = useState(null);
|
||||
|
||||
const loadStatus = useCallback(() => {
|
||||
return getBlogMarketingStatus().then(setStatus).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, [loadStatus]);
|
||||
getBlogMarketingStatus().then(setStatus).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
@@ -102,7 +96,6 @@ export default function BlogMarketing() {
|
||||
];
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadStatus}>
|
||||
<div className="bm">
|
||||
<header className="bm-header">
|
||||
<h1>Blog Lab</h1>
|
||||
@@ -131,13 +124,10 @@ export default function BlogMarketing() {
|
||||
</nav>
|
||||
|
||||
{tab === 'dashboard' && <DashboardTab />}
|
||||
{tab === 'research' && <ResearchTab />}
|
||||
{tab === 'research' && <ResearchTab onGenerate={(id) => { setTab('write'); }} />}
|
||||
{tab === 'write' && <WriteTab />}
|
||||
{tab === 'posts' && <PostsTab />}
|
||||
|
||||
<FAB onClick={() => setTab('research')} label="키워드 분석" />
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
display: none;
|
||||
position: fixed;
|
||||
/* 사이드바 토글 버튼(top-left) 과 겹치지 않도록 오른쪽 하단 배치 */
|
||||
bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 16px);
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
top: auto;
|
||||
left: auto;
|
||||
@@ -451,8 +451,9 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.blog-header {
|
||||
@media (max-width: 900px) {
|
||||
.blog-header,
|
||||
.blog-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -468,10 +469,10 @@
|
||||
|
||||
.blog-list {
|
||||
display: none;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.blog-list.is-visible {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -489,13 +490,6 @@
|
||||
|
||||
.blog-list.is-visible .blog-category-filter {
|
||||
margin-bottom: 8px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.blog-list.is-visible .blog-category-filter > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.blog-list.is-visible .blog-pagination {
|
||||
@@ -504,18 +498,22 @@
|
||||
|
||||
.blog-article {
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.blog-header h1 {
|
||||
font-size: clamp(24px, 6vw, 32px);
|
||||
}
|
||||
|
||||
.blog-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.blog-list {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.blog-list__item-btn {
|
||||
padding: 14px;
|
||||
}
|
||||
@@ -528,6 +526,10 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.blog-article {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.blog-article__body h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
@@ -764,19 +766,4 @@
|
||||
align-self: stretch;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 태그/카테고리 필터 가로 스크롤 */
|
||||
.blog-categories,
|
||||
.blog-category-list {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.blog-categories > *,
|
||||
.blog-category-list > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
updateBlogPost,
|
||||
deleteBlogPost,
|
||||
} from '../../api';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
import './Blog.css';
|
||||
|
||||
// ── 마크다운 렌더러 ──────────────────────────────────────────────────────────
|
||||
@@ -361,8 +359,9 @@ const Blog = () => {
|
||||
const [editorPost, setEditorPost] = useState(null); // null=닫힘, {}=새글, post=수정
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
|
||||
const fetchPosts = useCallback(() => {
|
||||
return getBlogPostsApi()
|
||||
// API 글 불러오기
|
||||
useEffect(() => {
|
||||
getBlogPostsApi()
|
||||
.then((data) => {
|
||||
const posts = Array.isArray(data) ? data : (data?.posts ?? []);
|
||||
setApiPosts(posts.map((p) => ({ ...p, slug: `api-${p.id}` })));
|
||||
@@ -370,11 +369,6 @@ const Blog = () => {
|
||||
.catch(() => setApiError(true));
|
||||
}, []);
|
||||
|
||||
// API 글 불러오기
|
||||
useEffect(() => {
|
||||
fetchPosts();
|
||||
}, [fetchPosts]);
|
||||
|
||||
// 정적 + API 글 병합 (API 글이 앞에 표시)
|
||||
const allPosts = useMemo(() => {
|
||||
const combined = [...apiPosts, ...staticPosts];
|
||||
@@ -456,7 +450,6 @@ const Blog = () => {
|
||||
const closeEditor = useCallback(() => { setIsEditorOpen(false); setEditorPost(null); }, []);
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={fetchPosts}>
|
||||
<div className="blog">
|
||||
<header className="blog-header">
|
||||
<div>
|
||||
@@ -658,10 +651,7 @@ const Blog = () => {
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FAB onClick={openNewEditor} label="글 쓰기" />
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -80,14 +80,3 @@
|
||||
max-width: 400px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sword-stream {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.sword-stream__overlay {
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,7 +727,7 @@
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@media (max-width: 960px) {
|
||||
.home-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -803,27 +803,15 @@
|
||||
.home-profile__name {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.home-hero__stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.home-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.home-card {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.home-posts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.home-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.home-hero__stats {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { navLinks } from '../../routes.jsx';
|
||||
import { getBlogPosts } from '../../data/blog';
|
||||
import { getTodos } from '../../api';
|
||||
import { getCurrentTheme } from '../../data/heroConfig';
|
||||
import myPhoto from '../../assets/myPhoto.jpg';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import SwipeableView from '../../components/SwipeableView';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import './Home.css';
|
||||
|
||||
const TODO_COLUMNS = [
|
||||
@@ -20,32 +17,22 @@ const Home = () => {
|
||||
const posts = getBlogPosts().slice(0, 3);
|
||||
const highlights = navLinks.filter((link) => link.id !== 'home');
|
||||
const theme = getCurrentTheme();
|
||||
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));
|
||||
getTodos()
|
||||
.then((data) => {
|
||||
if (!Array.isArray(data)) return;
|
||||
setTodosByStatus({
|
||||
todo: data.filter((t) => t.status === 'todo'),
|
||||
in_progress: data.filter((t) => t.status === 'in_progress'),
|
||||
done: data.filter((t) => t.status === 'done'),
|
||||
});
|
||||
})
|
||||
.catch(() => { /* 조용히 실패 */ });
|
||||
}, []);
|
||||
|
||||
const loadTodos = useCallback(async () => {
|
||||
const data = await getTodos();
|
||||
if (!Array.isArray(data)) return;
|
||||
setTodosByStatus({
|
||||
todo: data.filter((t) => t.status === 'todo'),
|
||||
in_progress: data.filter((t) => t.status === 'in_progress'),
|
||||
done: data.filter((t) => t.status === 'done'),
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTodos().catch(() => { /* 조용히 실패 */ });
|
||||
}, [loadTodos]);
|
||||
|
||||
const totalTasks = todosByStatus.todo.length + todosByStatus.in_progress.length + todosByStatus.done.length;
|
||||
const doneTasks = todosByStatus.done.length;
|
||||
const inProgress = todosByStatus.in_progress.length;
|
||||
@@ -145,79 +132,7 @@ const Home = () => {
|
||||
<h2>TODO</h2>
|
||||
<p>계획 · 진행 중 · 완료 태스크를 한눈에 확인합니다.</p>
|
||||
</div>
|
||||
<PullToRefresh onRefresh={loadTodos}>
|
||||
{isMobile ? (
|
||||
<SwipeableView
|
||||
tabs={[
|
||||
{
|
||||
key: 'todo',
|
||||
label: 'TODO',
|
||||
content: (
|
||||
<div className="home-todo-col__body">
|
||||
{(todosByStatus.todo || []).length === 0 ? (
|
||||
<p className="home-todo-col__empty">태스크가 없습니다.</p>
|
||||
) : (todosByStatus.todo || []).map((todo) => (
|
||||
<div key={todo.id} className="home-todo-card">
|
||||
<p className="home-todo-card__title">{todo.title}</p>
|
||||
{todo.description && <p className="home-todo-card__desc">{todo.description}</p>}
|
||||
<p className="home-todo-card__date">
|
||||
{todo.updated_at
|
||||
? new Date(todo.updated_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'in_progress',
|
||||
label: '진행중',
|
||||
content: (
|
||||
<div className="home-todo-col__body">
|
||||
{(todosByStatus.in_progress || []).length === 0 ? (
|
||||
<p className="home-todo-col__empty">태스크가 없습니다.</p>
|
||||
) : (todosByStatus.in_progress || []).map((todo) => (
|
||||
<div key={todo.id} className="home-todo-card">
|
||||
<p className="home-todo-card__title">{todo.title}</p>
|
||||
{todo.description && <p className="home-todo-card__desc">{todo.description}</p>}
|
||||
<p className="home-todo-card__date">
|
||||
{todo.updated_at
|
||||
? new Date(todo.updated_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'done',
|
||||
label: '완료',
|
||||
content: (
|
||||
<div className="home-todo-col__body">
|
||||
{(todosByStatus.done || []).length === 0 ? (
|
||||
<p className="home-todo-col__empty">태스크가 없습니다.</p>
|
||||
) : (todosByStatus.done || []).map((todo) => (
|
||||
<div key={todo.id} className="home-todo-card">
|
||||
<p className="home-todo-card__title">{todo.title}</p>
|
||||
{todo.description && <p className="home-todo-card__desc">{todo.description}</p>}
|
||||
<p className="home-todo-card__date">
|
||||
{todo.updated_at
|
||||
? new Date(todo.updated_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<TodoBoard todosByStatus={todosByStatus} />
|
||||
)}
|
||||
</PullToRefresh>
|
||||
<TodoBoard todosByStatus={todosByStatus} />
|
||||
</section>
|
||||
|
||||
<section className="home-section">
|
||||
@@ -230,30 +145,47 @@ const Home = () => {
|
||||
<div className="home-profile__identity">
|
||||
<img
|
||||
className="home-profile__avatar"
|
||||
src={portfolio?.profile?.photo_url || myPhoto}
|
||||
src={myPhoto}
|
||||
alt="Profile"
|
||||
/>
|
||||
<div>
|
||||
<p className="home-profile__role">{portfolio?.profile?.role || 'Server Developer'}</p>
|
||||
<p className="home-profile__name">{portfolio?.profile?.name || '박 재 오'}</p>
|
||||
<p className="home-profile__role">Server Developer</p>
|
||||
<p className="home-profile__name">박 재 오</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="home-profile__bio">
|
||||
{portfolio?.profile?.bio || '주변 동료와 함께 소통하며 성장하는걸 좋아합니다.'}
|
||||
주변 동료와 함께 소통하며 성장하는걸 좋아합니다. <br />
|
||||
성능 최적화, 인프라 자동화를 중요하게 생각합니다. <br />
|
||||
여행과 사진, 새로운 기술 탐구를 좋아합니다.
|
||||
</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">
|
||||
{(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) => (
|
||||
{['C++', 'Git', 'AWS', 'Jira', 'MySQL', 'Docker', 'Kubernetes', 'Linux'].map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="home-profile__actions">
|
||||
<Link className="button ghost" to="/portfolio">
|
||||
포트폴리오 보기
|
||||
</Link>
|
||||
<a className="button primary" href={`mailto:${portfolio?.profile?.email || 'bgg8988@gmail.com'}`}>
|
||||
<button className="button ghost">프로필 수정</button>
|
||||
<a className="button primary" href="mailto:bgg8988@gmail.com">
|
||||
연락하기
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,56 +1,460 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import BriefingTab from './tabs/BriefingTab';
|
||||
import AnalysisTab from './tabs/AnalysisTab';
|
||||
import PurchaseTab from './tabs/PurchaseTab';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import SwipeableView from '../../components/SwipeableView';
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
fmtKST, Ball, NumberRow, copyNumbers,
|
||||
buildMetricsFromFrequency, BEST_PICKS_DEFAULT_SHOW,
|
||||
} from './lottoUtils';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'briefing', label: '🗓 이번 주 브리핑' },
|
||||
{ id: 'analysis', label: '📚 자료실 / Deep Dive' },
|
||||
{ id: 'purchase', label: '💰 구매·성과' },
|
||||
];
|
||||
/* ── hooks ──────────────────────────────────────────────────────── */
|
||||
import useLottoData from './hooks/useLottoData';
|
||||
import usePurchases from './hooks/usePurchases';
|
||||
import useManualRecommend from './hooks/useManualRecommend';
|
||||
|
||||
/* ── components ─────────────────────────────────────────────────── */
|
||||
import MetricBlock from './components/MetricBlock';
|
||||
import FrequencyChart from './components/FrequencyChart';
|
||||
import PerformanceBanner from './components/PerformanceBanner';
|
||||
import CombinedRecommendPanel from './components/CombinedRecommendPanel';
|
||||
import ReportPanel from './components/ReportPanel';
|
||||
import PersonalAnalysisPanel from './components/PersonalAnalysisPanel';
|
||||
import PurchasePanel from './components/PurchasePanel';
|
||||
|
||||
/* ── component ──────────────────────────────────────────────────── */
|
||||
export default function Functions() {
|
||||
const [tab, setTab] = useState('briefing');
|
||||
const isMobile = useIsMobile();
|
||||
const ld = useLottoData();
|
||||
const pur = usePurchases();
|
||||
const mr = useManualRecommend();
|
||||
|
||||
const tabIndex = TABS.findIndex(t => t.id === tab);
|
||||
/* ── derived ────────────────────────────────────────────────── */
|
||||
const overallMetrics = useMemo(() => buildMetricsFromFrequency(ld.stats?.frequency), [ld.stats]);
|
||||
const visibleBestPicks = ld.bestPicksExpanded ? ld.bestPicks : ld.bestPicks.slice(0, BEST_PICKS_DEFAULT_SHOW);
|
||||
|
||||
const handleTabChange = useCallback((index) => {
|
||||
setTab(TABS[index].id);
|
||||
}, []);
|
||||
/* ── merged error ───────────────────────────────────────────── */
|
||||
const error = ld.error || mr.error;
|
||||
const clearError = () => { ld.setError(''); mr.setError(''); };
|
||||
|
||||
/* ── render ──────────────────────────────────────────────────── */
|
||||
return (
|
||||
<div className="lotto-functions">
|
||||
{isMobile ? (
|
||||
<SwipeableView
|
||||
tabs={TABS.map(t => ({
|
||||
key: t.id,
|
||||
label: t.label,
|
||||
content: t.id === 'briefing' ? <BriefingTab /> : t.id === 'analysis' ? <AnalysisTab /> : <PurchaseTab />,
|
||||
}))}
|
||||
activeIndex={tabIndex}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<nav className="lotto-tabs">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={tab === t.id ? 'active' : ''}
|
||||
onClick={() => setTab(t.id)}
|
||||
>{t.label}</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="lotto-tab-body">
|
||||
{tab === 'briefing' && <BriefingTab />}
|
||||
{tab === 'analysis' && <AnalysisTab />}
|
||||
{tab === 'purchase' && <PurchaseTab />}
|
||||
{error ? (
|
||||
<div className="lotto-alert">
|
||||
<div>
|
||||
<p className="lotto-alert__title">오류</p>
|
||||
<p className="lotto-alert__message">{error}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<button className="button ghost small" onClick={clearError}>닫기</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ── 신뢰도 배너 ── */}
|
||||
<PerformanceBanner perf={ld.perfStats} />
|
||||
|
||||
{/* ── 종합 추론 번호 추천 ── */}
|
||||
<CombinedRecommendPanel
|
||||
combined={ld.combined}
|
||||
history={ld.combinedHistory}
|
||||
loading={ld.combinedLoading}
|
||||
histLoading={ld.combinedHistLoading}
|
||||
onRun={ld.runCombinedRecommend}
|
||||
onCopy={copyNumbers}
|
||||
/>
|
||||
|
||||
{/* ── 최신 회차 + 시뮬레이션 추천 ── */}
|
||||
<div className="lotto-grid">
|
||||
{/* Latest Draw */}
|
||||
<section className="lotto-panel">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">Latest Draw</p>
|
||||
<h3>최신 회차</h3>
|
||||
<p className="lotto-panel__sub">최신 회차와 번호를 빠르게 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
<div className="lotto-panel__actions">
|
||||
{ld.loading.latest ? <span className="lotto-chip">로딩 중</span> : null}
|
||||
<button className="button ghost small" onClick={ld.refreshLatest} disabled={ld.loading.latest}>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{ld.latest ? (
|
||||
<>
|
||||
<div className="lotto-meta">
|
||||
<div>
|
||||
<p className="lotto-meta__title">{ld.latest.drawNo}회</p>
|
||||
<p className="lotto-meta__date">{ld.latest.date}</p>
|
||||
</div>
|
||||
<button className="button small" onClick={() => copyNumbers(ld.latest.numbers)}>
|
||||
번호 복사
|
||||
</button>
|
||||
</div>
|
||||
<NumberRow nums={ld.latest.numbers} />
|
||||
<p className="lotto-bonus">보너스 <strong>{ld.latest.bonus}</strong></p>
|
||||
{overallMetrics && (
|
||||
<MetricBlock title="당첨 통계 (전체 회차)" metrics={overallMetrics} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Simulation Picks */}
|
||||
<section className="lotto-panel">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">Simulation Picks</p>
|
||||
<h3>시뮬레이션 추천</h3>
|
||||
<p className="lotto-panel__sub">
|
||||
하루 6회 몬테카를로 시뮬레이션으로 선별된 최적 번호입니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="lotto-panel__actions">
|
||||
{ld.loading.bestPicks ? <span className="lotto-chip">로딩 중</span> : null}
|
||||
{ld.simulating ? <span className="lotto-chip lotto-chip--active">분석 중</span> : null}
|
||||
<button className="button ghost small" onClick={ld.refreshBestPicks}
|
||||
disabled={ld.loading.bestPicks || ld.simulating}>
|
||||
새로고침
|
||||
</button>
|
||||
<button className="button small" onClick={ld.onSimulate}
|
||||
disabled={ld.simulating || ld.loading.bestPicks}>
|
||||
{ld.simulating ? '실행 중...' : '지금 실행'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ld.simResult && (
|
||||
<div className="lotto-sim-result">
|
||||
<p>완료: {ld.simResult.total_generated?.toLocaleString()}개 후보 → 상위 {ld.simResult.best_n_saved}개 저장</p>
|
||||
<p>최고 점수 {((ld.simResult.best_score ?? 0) * 100).toFixed(1)}% / 평균 {((ld.simResult.avg_score ?? 0) * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ld.bestPicks.length === 0 ? (
|
||||
<p className="lotto-empty">
|
||||
{ld.loading.bestPicks ? '불러오는 중...' : "시뮬레이션 결과가 없습니다. '지금 실행'으로 시작하세요."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="lotto-picks">
|
||||
{visibleBestPicks.map((pick) => (
|
||||
<div key={pick.id} className="lotto-pick">
|
||||
<span className="lotto-pick__rank">#{pick.rank}</span>
|
||||
<div className="lotto-pick__content">
|
||||
<NumberRow nums={pick.numbers} />
|
||||
<div className="lotto-pick__score">
|
||||
<span className="lotto-pick__score-label">
|
||||
{((pick.score_total ?? 0) * 100).toFixed(1)}%
|
||||
</span>
|
||||
<div className="lotto-pick__bar">
|
||||
<span style={{ width: `${(pick.score_total ?? 0) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="button ghost small" onClick={() => copyNumbers(pick.numbers)}>
|
||||
복사
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{ld.bestPicks.length > BEST_PICKS_DEFAULT_SHOW && (
|
||||
<button
|
||||
className="button ghost small lotto-history-toggle"
|
||||
onClick={() => ld.setBestPicksExpanded((p) => !p)}
|
||||
aria-expanded={ld.bestPicksExpanded}
|
||||
>
|
||||
{ld.bestPicksExpanded ? '접기' : `모두 보기 (${ld.bestPicks.length}개)`}
|
||||
<span className={`lotto-history-toggle__icon ${ld.bestPicksExpanded ? 'is-open' : ''}`} aria-hidden>▼</span>
|
||||
</button>
|
||||
)}
|
||||
<p className="lotto-panel__sub">
|
||||
갱신: {fmtKST(ld.bestPicks[0]?.created_at) || '-'}
|
||||
{ld.bestPicks[0]?.based_on_draw ? ` · ${ld.bestPicks[0].based_on_draw}회 기준` : ''}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* ── 이번 주 공략 리포트 ── */}
|
||||
<ReportPanel
|
||||
report={ld.report}
|
||||
history={ld.reportHistory}
|
||||
loading={ld.reportLoading}
|
||||
onRefresh={ld.refreshReport}
|
||||
onSelectDrw={ld.loadSpecificReport}
|
||||
/>
|
||||
|
||||
{/* ── 통계 분석 ── */}
|
||||
<section className="lotto-panel lotto-panel--wide">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">Analysis</p>
|
||||
<h3>통계 분석</h3>
|
||||
<p className="lotto-panel__sub">빈도, Z-score, 갭 분석으로 번호를 분류합니다.</p>
|
||||
</div>
|
||||
<div className="lotto-panel__actions">
|
||||
{ld.loading.analysis ? <span className="lotto-chip">로딩 중</span> : null}
|
||||
<button className="button ghost small" onClick={ld.refreshAnalysis} disabled={ld.loading.analysis}>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{ld.analysis ? (
|
||||
<div className="lotto-analysis">
|
||||
<div className="lotto-analysis__row">
|
||||
<div className="lotto-analysis__group">
|
||||
<p className="lotto-analysis__label">🔥 핫 번호 <span>출현 빈도 상위 10</span></p>
|
||||
<div className="lotto-row">
|
||||
{(ld.analysis.hot_numbers ?? []).map((n) => <Ball key={n} n={n} />)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lotto-analysis__group">
|
||||
<p className="lotto-analysis__label">🧊 콜드 번호 <span>출현 빈도 하위 10</span></p>
|
||||
<div className="lotto-row">
|
||||
{(ld.analysis.cold_numbers ?? []).map((n) => <Ball key={n} n={n} />)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lotto-analysis__group">
|
||||
<p className="lotto-analysis__label">⏰ 오버듀 번호 <span>오래 안 나온 번호 (회차 수)</span></p>
|
||||
<div className="lotto-row">
|
||||
{(ld.analysis.overdue_numbers ?? []).map((n) => {
|
||||
const stat = (ld.analysis.number_stats ?? []).find((s) => s.number === n);
|
||||
return (
|
||||
<div key={n} className="lotto-overdue">
|
||||
<Ball n={n} />
|
||||
<span className="lotto-overdue__gap">{stat?.gap ?? '-'}회</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lotto-analysis__stats">
|
||||
<span>역대 합계 평균 <strong>{ld.analysis.mean_sum}</strong></span>
|
||||
<span>표준편차 <strong>±{ld.analysis.std_sum}</strong></span>
|
||||
<span>분석 회차 <strong>{ld.analysis.total_draws?.toLocaleString()}</strong></span>
|
||||
<span>
|
||||
홀수 3:짝수 3 확률{' '}
|
||||
<strong>
|
||||
{ld.analysis.odd_distribution?.['3'] ? `${ld.analysis.odd_distribution['3']}%` : '-'}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="lotto-empty">
|
||||
{ld.loading.analysis ? '불러오는 중...' : '통계 분석 데이터가 없습니다.'}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── 전체 번호 분포 ── */}
|
||||
<section className="lotto-panel lotto-panel--wide">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">Distribution</p>
|
||||
<h3>전체 회차 번호 분포</h3>
|
||||
<p className="lotto-panel__sub">1~45번 번호가 등장한 횟수를 기준으로 분포를 표시합니다.</p>
|
||||
</div>
|
||||
<div className="lotto-panel__actions">
|
||||
{ld.statsLoading ? <span className="lotto-chip">로딩 중</span> : null}
|
||||
{ld.stats?.total_draws ? (
|
||||
<span className="lotto-chip">{ld.stats.total_draws}회차</span>
|
||||
) : null}
|
||||
<button className="button ghost small" onClick={ld.refreshStats} disabled={ld.statsLoading}>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{ld.statsError ? <p className="lotto-empty">{ld.statsError}</p> : null}
|
||||
{ld.stats ? (
|
||||
<FrequencyChart stats={ld.stats} />
|
||||
) : (
|
||||
<p className="lotto-empty">통계 데이터를 불러오지 못했습니다.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── 내 번호 패턴 ── */}
|
||||
<PersonalAnalysisPanel data={ld.personalAnalysis} loading={ld.personalLoading} />
|
||||
|
||||
{/* ── 구매 기록 ── */}
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* ── 수동 추천 ── */}
|
||||
<section className="lotto-panel">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">Manual Recommendation</p>
|
||||
<h3>수동 추천</h3>
|
||||
<p className="lotto-panel__sub">파라미터를 직접 조정해 번호를 추천받을 수 있습니다.</p>
|
||||
</div>
|
||||
<div className="lotto-panel__actions">
|
||||
{mr.loading.recommend ? <span className="lotto-chip">계산 중</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lotto-presets">
|
||||
{mr.presets.map((preset) => (
|
||||
<button key={preset.name} className="button ghost small"
|
||||
onClick={() => mr.setParams({
|
||||
recent_window: preset.recent_window,
|
||||
recent_weight: preset.recent_weight,
|
||||
avoid_recent_k: preset.avoid_recent_k,
|
||||
})}>
|
||||
{preset.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="lotto-form">
|
||||
<label className="lotto-field">
|
||||
recent_window <span>최근 N회차 가중치 범위</span>
|
||||
<input type="number" min={20} max={1000} value={mr.params.recent_window}
|
||||
onChange={(e) => mr.setParams((s) => ({ ...s, recent_window: Number(e.target.value) }))} />
|
||||
</label>
|
||||
<label className="lotto-field">
|
||||
recent_weight <span>최근 회차 가중치</span>
|
||||
<input type="number" step="0.1" min={0.5} max={10} value={mr.params.recent_weight}
|
||||
onChange={(e) => mr.setParams((s) => ({ ...s, recent_weight: Number(e.target.value) }))} />
|
||||
</label>
|
||||
<label className="lotto-field">
|
||||
avoid_recent_k <span>최근 K회차 중복 회피</span>
|
||||
<input type="number" min={0} max={50} value={mr.params.avoid_recent_k}
|
||||
onChange={(e) => mr.setParams((s) => ({ ...s, avoid_recent_k: Number(e.target.value) }))} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button className="button primary" onClick={mr.onRecommend} disabled={mr.loading.recommend}>
|
||||
추천 받기
|
||||
</button>
|
||||
|
||||
{mr.result ? (
|
||||
<div className="lotto-result">
|
||||
<div className="lotto-result__meta">
|
||||
<div>
|
||||
<p className="lotto-result__id">추천 ID #{mr.result.id}</p>
|
||||
<p className="lotto-result__based">기준 회차 {mr.result.based_on_latest_draw ?? '-'}</p>
|
||||
</div>
|
||||
<button className="button small" onClick={() => copyNumbers(mr.result.numbers)}>
|
||||
번호 복사
|
||||
</button>
|
||||
</div>
|
||||
{mr.result.numbers && <NumberRow nums={mr.result.numbers} />}
|
||||
{mr.historyMetrics && (
|
||||
<div className="lotto-compare">
|
||||
<MetricBlock title="추천 통계 (히스토리)" metrics={mr.historyMetrics} />
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(mr.result.items) && mr.result.items.length ? (
|
||||
<details className="lotto-details">
|
||||
<summary>추천 후보 보기</summary>
|
||||
<div className="lotto-batch">
|
||||
{mr.result.items.map((item, idx) => (
|
||||
<div key={item.id ?? `candidate-${idx}`} className="lotto-batch__item">
|
||||
<div className="lotto-batch__meta">
|
||||
<div>
|
||||
<p className="lotto-batch__title">후보 #{item.id ?? idx + 1}</p>
|
||||
<p className="lotto-batch__sub">기준 회차 {item.based_on_draw ?? '-'}</p>
|
||||
</div>
|
||||
<button className="button ghost small" onClick={() => copyNumbers(item.numbers)}>
|
||||
복사
|
||||
</button>
|
||||
</div>
|
||||
<NumberRow nums={item.numbers} />
|
||||
{item.metrics && <MetricBlock title="후보 통계" metrics={item.metrics} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
{mr.result.explain && (
|
||||
<details className="lotto-details">
|
||||
<summary>설명 보기</summary>
|
||||
<pre>{JSON.stringify(mr.result.explain, null, 2)}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── 추천 히스토리 ── */}
|
||||
<section className="lotto-panel">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">History</p>
|
||||
<h3>추천 히스토리</h3>
|
||||
<p className="lotto-panel__sub">수동 추천 결과를 모아서 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
<div className="lotto-panel__actions">
|
||||
<span className="lotto-chip">{mr.history.length}건</span>
|
||||
{mr.history.length > 5 && (
|
||||
<button className="button ghost small lotto-history-toggle"
|
||||
onClick={() => mr.setHistoryExpanded((p) => !p)}
|
||||
aria-expanded={mr.historyExpanded}>
|
||||
{mr.historyExpanded ? '접기' : '더보기'}
|
||||
<span className={`lotto-history-toggle__icon ${mr.historyExpanded ? 'is-open' : ''}`} aria-hidden>▼</span>
|
||||
</button>
|
||||
)}
|
||||
<button className="button ghost small" onClick={mr.refreshHistory} disabled={mr.loading.history}>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mr.loading.history ? <p className="lotto-empty">불러오는 중...</p> : null}
|
||||
{mr.history.length === 0 ? (
|
||||
<p className="lotto-empty">저장된 히스토리가 없습니다.</p>
|
||||
) : (
|
||||
<div className="lotto-history">
|
||||
{mr.visibleHistory.map((item) => (
|
||||
<div key={item.id} className="lotto-history__item">
|
||||
<div className="lotto-history__meta">
|
||||
<p>#{item.id}</p>
|
||||
<p>{fmtKST(item.created_at)}</p>
|
||||
<p>기준 회차 {item.based_on_draw ?? '-'}</p>
|
||||
</div>
|
||||
<div className="lotto-history__body">
|
||||
<NumberRow nums={item.numbers} />
|
||||
<p className="lotto-history__params">
|
||||
window={item.params?.recent_window}, weight={item.params?.recent_weight},
|
||||
avoid_k={item.params?.avoid_recent_k}
|
||||
</p>
|
||||
</div>
|
||||
<div className="lotto-history__actions">
|
||||
<button className="button ghost small" onClick={() => copyNumbers(item.numbers)}>
|
||||
복사
|
||||
</button>
|
||||
<button className="button danger small" onClick={() => mr.onDelete(item.id)}>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<span ref={mr.historyEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<footer className="lotto-foot">
|
||||
backend: FastAPI / nginx proxy / DB: sqlite ·{' '}
|
||||
<a className="lotto-foot__link" href="/lotto-api.md" download>API 스펙 다운로드</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1020,7 +1020,7 @@
|
||||
|
||||
.lotto-purchase-list__head {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 100px 100px 100px minmax(0, 160px) minmax(0, 1fr) 120px;
|
||||
grid-template-columns: 60px 100px 100px 100px 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, 160px) minmax(0, 1fr) 120px;
|
||||
grid-template-columns: 60px 100px 100px 100px minmax(0, 1fr) 120px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
@@ -1068,28 +1068,47 @@
|
||||
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; }
|
||||
|
||||
/* ── 반응형 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@media (max-width: 900px) {
|
||||
.lotto-header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lotto-history__item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lotto-analysis__row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.lotto-pick {
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lotto-report-top {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lotto-purchase-list__head,
|
||||
.lotto-purchase-row {
|
||||
grid-template-columns: 56px 90px 90px minmax(0, 1fr) 100px;
|
||||
}
|
||||
|
||||
.lotto-purchase-list__head span:nth-child(4),
|
||||
.lotto-purchase-row span:nth-child(4) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.lotto-purchase-stats {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -1113,8 +1132,8 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.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) {
|
||||
.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) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1138,34 +1157,6 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.lotto-header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lotto-analysis__row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.lotto-pick {
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lotto-report-top {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lotto-purchase-list__head,
|
||||
.lotto-purchase-row {
|
||||
grid-template-columns: 56px 90px 90px minmax(0, 120px) minmax(0, 1fr) 100px;
|
||||
}
|
||||
|
||||
.lotto-purchase-list__head span:nth-child(4),
|
||||
.lotto-purchase-row span:nth-child(4) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lotto-header h1 {
|
||||
font-size: clamp(24px, 6vw, 32px);
|
||||
}
|
||||
@@ -1190,9 +1181,9 @@
|
||||
}
|
||||
|
||||
.lotto-ball {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.lotto-meta__title {
|
||||
@@ -1200,7 +1191,6 @@
|
||||
}
|
||||
|
||||
.lotto-history__item {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 14px;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -1469,7 +1459,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@media (max-width: 640px) {
|
||||
.lotto-combined__method {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
@@ -1485,70 +1475,3 @@
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Briefing UI ──────────────────────────────────────────────────────────── */
|
||||
.briefing-header { padding: 16px; border-radius: 12px; background: rgba(129,140,248,0.08); margin-bottom: 16px; }
|
||||
.briefing-header-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
.briefing-meta { display: flex; gap: 12px; color: #94a3b8; font-size: 0.85rem; margin-top: 4px; flex-wrap: wrap; }
|
||||
.briefing-confidence strong { color: #e2e8f0; }
|
||||
.briefing-tokens { font-family: monospace; }
|
||||
.briefing-confidence-bar { height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; margin-top: 8px; overflow: hidden; }
|
||||
.briefing-confidence-bar > div { height: 100%; background: linear-gradient(90deg, #818cf8, #34d399); transition: width .3s; }
|
||||
.briefing-summary { padding: 12px 16px; background: rgba(0,0,0,0.2); border-radius: 10px; margin-bottom: 16px; }
|
||||
.briefing-summary h3 { margin: 0 0 8px; }
|
||||
.briefing-3lines { margin: 0; padding-left: 20px; }
|
||||
.briefing-hotcold { color: #fbbf24; margin-top: 8px; }
|
||||
.briefing-warning { color: #f87171; margin-top: 8px; }
|
||||
.pick-card { padding: 12px; border-radius: 10px; background: rgba(255,255,255,0.04); border-left: 3px solid #64748b; margin-bottom: 8px; }
|
||||
.pick-card--안정 { border-left-color: #34d399; }
|
||||
.pick-card--균형 { border-left-color: #fbbf24; }
|
||||
.pick-card--공격 { border-left-color: #f87171; }
|
||||
.pick-card-header { display: flex; justify-content: space-between; font-size: 0.85rem; color: #94a3b8; margin-bottom: 6px; }
|
||||
.pick-card-balls { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
|
||||
.ball { width: 32px; height: 32px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold; color: #fff; }
|
||||
.ball--1 { background: #fbbf24; } .ball--2 { background: #60a5fa; } .ball--3 { background: #f87171; }
|
||||
.ball--4 { background: #94a3b8; } .ball--5 { background: #34d399; }
|
||||
.pick-card-reason { margin: 0; font-size: 0.85rem; color: #cbd5e1; }
|
||||
.briefing-empty { text-align: center; padding: 40px 20px; color: #94a3b8; }
|
||||
.briefing-empty button { margin-top: 12px; padding: 8px 20px; }
|
||||
.briefing-empty-hint { font-size: 0.85rem; }
|
||||
.briefing-error { color: #f87171; margin-top: 8px; }
|
||||
.curator-usage-footer { display: flex; gap: 12px; padding: 10px 14px; background: rgba(0,0,0,0.25); border-radius: 8px; font-size: 0.8rem; color: #94a3b8; margin-top: 24px; flex-wrap: wrap; font-family: monospace; }
|
||||
@media (max-width: 768px) {
|
||||
.briefing-meta { font-size: 0.75rem; }
|
||||
.briefing-tokens { width: 100%; }
|
||||
.pick-card-balls { justify-content: center; }
|
||||
}
|
||||
|
||||
/* ── Tab navigation ───────────────────────────────────────────────────────── */
|
||||
.lotto-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid rgba(255,255,255,0.1); }
|
||||
.lotto-tabs button { padding: 8px 16px; background: transparent; border: none; color: #94a3b8; cursor: pointer; border-bottom: 2px solid transparent; }
|
||||
.lotto-tabs button.active { color: #e2e8f0; border-bottom-color: #818cf8; }
|
||||
.lotto-tab-body { padding-top: 8px; display: grid; gap: 24px; }
|
||||
@media (max-width: 768px) {
|
||||
.lotto-tabs { overflow-x: auto; }
|
||||
.lotto-tabs button { white-space: nowrap; }
|
||||
|
||||
/* 구매 이력 테이블 가로 스크롤 */
|
||||
.purchase-list {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.lotto-ball {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
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,7 +137,6 @@ const PurchasePanel = ({
|
||||
<span>투자금</span>
|
||||
<span>당첨금</span>
|
||||
<span>손익</span>
|
||||
<span>채점</span>
|
||||
<span>메모</span>
|
||||
<span />
|
||||
</div>
|
||||
@@ -153,14 +152,6 @@ 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)}>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export default function BriefingEmpty({ regenerating, onRegenerate, error }) {
|
||||
return (
|
||||
<div className="briefing-empty">
|
||||
<p>아직 이번 주 브리핑이 없습니다.</p>
|
||||
<p className="briefing-empty-hint">매주 월요일 07:00에 자동 생성됩니다.</p>
|
||||
<button onClick={onRegenerate} disabled={regenerating}>
|
||||
{regenerating ? '⏳ 생성 중...' : '지금 생성'}
|
||||
</button>
|
||||
{error && <p className="briefing-error">⚠️ {error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { estimateCost, fmtUsd, fmtTokens } from './pricing';
|
||||
|
||||
export default function BriefingHeader({ briefing, regenerating, onRegenerate }) {
|
||||
const cost = estimateCost(briefing);
|
||||
const genDate = new Date(briefing.generated_at).toLocaleString('ko-KR');
|
||||
return (
|
||||
<div className="briefing-header">
|
||||
<div className="briefing-header-row">
|
||||
<h2>🗓 #{briefing.draw_no}회 브리핑</h2>
|
||||
<button onClick={onRegenerate} disabled={regenerating}>
|
||||
{regenerating ? '⏳ 생성 중...' : '🔄 다시 생성'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="briefing-meta">
|
||||
<span>{genDate}</span>
|
||||
<span className="briefing-confidence">
|
||||
신뢰도 <strong>{briefing.confidence}</strong>/100
|
||||
</span>
|
||||
<span className="briefing-tokens">
|
||||
{fmtTokens(briefing.tokens_input)} in · {fmtTokens(briefing.tokens_output)} out · {fmtUsd(cost)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="briefing-confidence-bar">
|
||||
<div style={{ width: `${briefing.confidence}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export default function BriefingSummary({ narrative }) {
|
||||
return (
|
||||
<div className="briefing-summary">
|
||||
<h3>{narrative.headline}</h3>
|
||||
<ul className="briefing-3lines">
|
||||
{narrative.summary_3lines.map((line, i) => <li key={i}>{line}</li>)}
|
||||
</ul>
|
||||
{narrative.hot_cold_comment && (
|
||||
<p className="briefing-hotcold">🔥❄️ {narrative.hot_cold_comment}</p>
|
||||
)}
|
||||
{narrative.warnings && (
|
||||
<p className="briefing-warning">⚠️ {narrative.warnings}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import useCuratorUsage from '../../hooks/useCuratorUsage';
|
||||
import { estimateCost, fmtUsd, fmtTokens } from './pricing';
|
||||
|
||||
export default function CuratorUsageFooter() {
|
||||
const { usage } = useCuratorUsage(30);
|
||||
if (!usage) return null;
|
||||
const cost = estimateCost(usage);
|
||||
return (
|
||||
<div className="curator-usage-footer">
|
||||
<span>최근 30일 큐레이터:</span>
|
||||
<span>{usage.calls}회 호출</span>
|
||||
<span>{fmtTokens(usage.tokens_input + usage.tokens_output)} tokens</span>
|
||||
<span>{fmtUsd(cost)}</span>
|
||||
<span>캐시 {(usage.cache_hit_rate * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
const RISK_BADGE = { '안정': '🟢', '균형': '🟡', '공격': '🔴' };
|
||||
|
||||
export default function PickSetCard({ pick, index }) {
|
||||
return (
|
||||
<div className={`pick-card pick-card--${pick.risk_tag}`}>
|
||||
<div className="pick-card-header">
|
||||
<span className="pick-card-index">Set {index + 1}</span>
|
||||
<span className="pick-card-risk">{RISK_BADGE[pick.risk_tag] || '⚪'} {pick.risk_tag}</span>
|
||||
</div>
|
||||
<div className="pick-card-balls">
|
||||
{pick.numbers.map(n => (
|
||||
<span key={n} className={`ball ball--${Math.ceil(n / 10)}`}>{n}</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="pick-card-reason">{pick.reason}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
const IN_PER_M = 3.00;
|
||||
const OUT_PER_M = 15.00;
|
||||
const CACHE_READ_PER_M = 0.30;
|
||||
const CACHE_WRITE_PER_M = 3.75;
|
||||
|
||||
export function estimateCost({ tokens_input = 0, tokens_output = 0, cache_read = 0, cache_write = 0 }) {
|
||||
const usd =
|
||||
(tokens_input / 1_000_000) * IN_PER_M +
|
||||
(tokens_output / 1_000_000) * OUT_PER_M +
|
||||
(cache_read / 1_000_000) * CACHE_READ_PER_M +
|
||||
(cache_write / 1_000_000) * CACHE_WRITE_PER_M;
|
||||
return usd;
|
||||
}
|
||||
|
||||
export function fmtUsd(usd) {
|
||||
if (usd < 0.01) return `$${usd.toFixed(4)}`;
|
||||
return `$${usd.toFixed(3)}`;
|
||||
}
|
||||
|
||||
export function fmtTokens(n) {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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 };
|
||||
@@ -1,25 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
.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,68 +0,0 @@
|
||||
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);
|
||||
const [error, setError] = useState('');
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
const pollingRef = useRef(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const data = await getLatestBriefing();
|
||||
setBriefing(data ? { ...data, picks: normalizePicks(data.picks) } : data);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const regenerate = useCallback(async () => {
|
||||
setRegenerating(true); setError('');
|
||||
try {
|
||||
const prevGen = briefing?.generated_at;
|
||||
await triggerLottoCurate();
|
||||
let attempts = 0;
|
||||
pollingRef.current = setInterval(async () => {
|
||||
attempts += 1;
|
||||
try {
|
||||
const data = await getLatestBriefing();
|
||||
if (data && data.generated_at !== prevGen) {
|
||||
setBriefing({ ...data, picks: normalizePicks(data.picks) });
|
||||
setRegenerating(false);
|
||||
clearInterval(pollingRef.current);
|
||||
}
|
||||
} catch {}
|
||||
if (attempts >= 40) {
|
||||
clearInterval(pollingRef.current);
|
||||
setRegenerating(false);
|
||||
setError('재생성 타임아웃 (2분)');
|
||||
}
|
||||
}, 3000);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
setRegenerating(false);
|
||||
}
|
||||
}, [briefing?.generated_at]);
|
||||
|
||||
useEffect(() => () => { if (pollingRef.current) clearInterval(pollingRef.current); }, []);
|
||||
|
||||
return { briefing, loading, error, regenerating, reload: load, regenerate };
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getCuratorUsage } from '../../../api';
|
||||
|
||||
export default function useCuratorUsage(days = 30) {
|
||||
const [usage, setUsage] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
getCuratorUsage(days)
|
||||
.then(d => { if (alive) setUsage(d); })
|
||||
.catch(e => { if (alive) setError(e.message); });
|
||||
return () => { alive = false; };
|
||||
}, [days]);
|
||||
|
||||
return { usage, error };
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
getPurchases, getPurchaseStats, addPurchase, updatePurchase, deletePurchase,
|
||||
bulkPurchase as apiBulkPurchase,
|
||||
} from '../../../api';
|
||||
import { emptyPurchaseForm } from '../lottoUtils';
|
||||
|
||||
@@ -95,12 +94,6 @@ 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 {
|
||||
@@ -108,6 +101,5 @@ export default function usePurchases() {
|
||||
purchaseFormOpen, purchaseForm, purchaseFormSaving, purchaseFormError, purchaseEditId,
|
||||
handlePurchaseFormOpen, handlePurchaseFormClose, handlePurchaseFormChange,
|
||||
handlePurchaseFormSubmit, handlePurchaseEditStart, handlePurchaseDelete,
|
||||
handleBulkPurchase,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
fmtKST, Ball, NumberRow, copyNumbers,
|
||||
buildMetricsFromFrequency, BEST_PICKS_DEFAULT_SHOW,
|
||||
} from '../lottoUtils';
|
||||
|
||||
import useLottoData from '../hooks/useLottoData';
|
||||
import useManualRecommend from '../hooks/useManualRecommend';
|
||||
|
||||
import MetricBlock from '../components/MetricBlock';
|
||||
import FrequencyChart from '../components/FrequencyChart';
|
||||
import PerformanceBanner from '../components/PerformanceBanner';
|
||||
import CombinedRecommendPanel from '../components/CombinedRecommendPanel';
|
||||
import ReportPanel from '../components/ReportPanel';
|
||||
import PersonalAnalysisPanel from '../components/PersonalAnalysisPanel';
|
||||
|
||||
export default function AnalysisTab() {
|
||||
const ld = useLottoData();
|
||||
const mr = useManualRecommend();
|
||||
|
||||
const overallMetrics = useMemo(() => buildMetricsFromFrequency(ld.stats?.frequency), [ld.stats]);
|
||||
const visibleBestPicks = ld.bestPicksExpanded ? ld.bestPicks : ld.bestPicks.slice(0, BEST_PICKS_DEFAULT_SHOW);
|
||||
|
||||
const error = ld.error || mr.error;
|
||||
const clearError = () => { ld.setError(''); mr.setError(''); };
|
||||
|
||||
return (
|
||||
<>
|
||||
{error ? (
|
||||
<div className="lotto-alert">
|
||||
<div>
|
||||
<p className="lotto-alert__title">오류</p>
|
||||
<p className="lotto-alert__message">{error}</p>
|
||||
</div>
|
||||
<button className="button ghost small" onClick={clearError}>닫기</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 신뢰도 배너 */}
|
||||
<PerformanceBanner perf={ld.perfStats} />
|
||||
|
||||
{/* 종합 추론 번호 추천 */}
|
||||
<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>
|
||||
|
||||
{/* 최신 회차 */}
|
||||
<details className="lotto-section-fold">
|
||||
<summary>최신 회차</summary>
|
||||
<section className="lotto-panel">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">Latest Draw</p>
|
||||
<h3>최신 회차</h3>
|
||||
<p className="lotto-panel__sub">최신 회차와 번호를 빠르게 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
<div className="lotto-panel__actions">
|
||||
{ld.loading.latest ? <span className="lotto-chip">로딩 중</span> : null}
|
||||
<button className="button ghost small" onClick={ld.refreshLatest} disabled={ld.loading.latest}>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{ld.latest ? (
|
||||
<>
|
||||
<div className="lotto-meta">
|
||||
<div>
|
||||
<p className="lotto-meta__title">{ld.latest.drawNo}회</p>
|
||||
<p className="lotto-meta__date">{ld.latest.date}</p>
|
||||
</div>
|
||||
<button className="button small" onClick={() => copyNumbers(ld.latest.numbers)}>
|
||||
번호 복사
|
||||
</button>
|
||||
</div>
|
||||
<NumberRow nums={ld.latest.numbers} />
|
||||
<p className="lotto-bonus">보너스 <strong>{ld.latest.bonus}</strong></p>
|
||||
{overallMetrics && (
|
||||
<MetricBlock title="당첨 통계 (전체 회차)" metrics={overallMetrics} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
|
||||
)}
|
||||
</section>
|
||||
</details>
|
||||
|
||||
{/* Simulation Picks */}
|
||||
<details className="lotto-section-fold">
|
||||
<summary>시뮬레이션 추천</summary>
|
||||
<section className="lotto-panel">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">Simulation Picks</p>
|
||||
<h3>시뮬레이션 추천</h3>
|
||||
<p className="lotto-panel__sub">
|
||||
하루 6회 몬테카를로 시뮬레이션으로 선별된 최적 번호입니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="lotto-panel__actions">
|
||||
{ld.loading.bestPicks ? <span className="lotto-chip">로딩 중</span> : null}
|
||||
{ld.simulating ? <span className="lotto-chip lotto-chip--active">분석 중</span> : null}
|
||||
<button className="button ghost small" onClick={ld.refreshBestPicks}
|
||||
disabled={ld.loading.bestPicks || ld.simulating}>
|
||||
새로고침
|
||||
</button>
|
||||
<button className="button small" onClick={ld.onSimulate}
|
||||
disabled={ld.simulating || ld.loading.bestPicks}>
|
||||
{ld.simulating ? '실행 중...' : '지금 실행'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ld.simResult && (
|
||||
<div className="lotto-sim-result">
|
||||
<p>완료: {ld.simResult.total_generated?.toLocaleString()}개 후보 → 상위 {ld.simResult.best_n_saved}개 저장</p>
|
||||
<p>최고 점수 {((ld.simResult.best_score ?? 0) * 100).toFixed(1)}% / 평균 {((ld.simResult.avg_score ?? 0) * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ld.bestPicks.length === 0 ? (
|
||||
<p className="lotto-empty">
|
||||
{ld.loading.bestPicks ? '불러오는 중...' : "시뮬레이션 결과가 없습니다. '지금 실행'으로 시작하세요."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="lotto-picks">
|
||||
{visibleBestPicks.map((pick) => (
|
||||
<div key={pick.id} className="lotto-pick">
|
||||
<span className="lotto-pick__rank">#{pick.rank}</span>
|
||||
<div className="lotto-pick__content">
|
||||
<NumberRow nums={pick.numbers} />
|
||||
<div className="lotto-pick__score">
|
||||
<span className="lotto-pick__score-label">
|
||||
{((pick.score_total ?? 0) * 100).toFixed(1)}%
|
||||
</span>
|
||||
<div className="lotto-pick__bar">
|
||||
<span style={{ width: `${(pick.score_total ?? 0) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="button ghost small" onClick={() => copyNumbers(pick.numbers)}>
|
||||
복사
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{ld.bestPicks.length > BEST_PICKS_DEFAULT_SHOW && (
|
||||
<button
|
||||
className="button ghost small lotto-history-toggle"
|
||||
onClick={() => ld.setBestPicksExpanded((p) => !p)}
|
||||
aria-expanded={ld.bestPicksExpanded}
|
||||
>
|
||||
{ld.bestPicksExpanded ? '접기' : `모두 보기 (${ld.bestPicks.length}개)`}
|
||||
<span className={`lotto-history-toggle__icon ${ld.bestPicksExpanded ? 'is-open' : ''}`} aria-hidden>▼</span>
|
||||
</button>
|
||||
)}
|
||||
<p className="lotto-panel__sub">
|
||||
갱신: {fmtKST(ld.bestPicks[0]?.created_at) || '-'}
|
||||
{ld.bestPicks[0]?.based_on_draw ? ` · ${ld.bestPicks[0].based_on_draw}회 기준` : ''}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</details>
|
||||
|
||||
{/* 이번 주 공략 리포트 */}
|
||||
<details className="lotto-section-fold">
|
||||
<summary>이번 주 공략 리포트</summary>
|
||||
<ReportPanel
|
||||
report={ld.report}
|
||||
history={ld.reportHistory}
|
||||
loading={ld.reportLoading}
|
||||
onRefresh={ld.refreshReport}
|
||||
onSelectDrw={ld.loadSpecificReport}
|
||||
/>
|
||||
</details>
|
||||
|
||||
{/* 통계 분석 */}
|
||||
<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>
|
||||
<h3>통계 분석</h3>
|
||||
<p className="lotto-panel__sub">빈도, Z-score, 갭 분석으로 번호를 분류합니다.</p>
|
||||
</div>
|
||||
<div className="lotto-panel__actions">
|
||||
{ld.loading.analysis ? <span className="lotto-chip">로딩 중</span> : null}
|
||||
<button className="button ghost small" onClick={ld.refreshAnalysis} disabled={ld.loading.analysis}>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{ld.analysis ? (
|
||||
<div className="lotto-analysis">
|
||||
<div className="lotto-analysis__row">
|
||||
<div className="lotto-analysis__group">
|
||||
<p className="lotto-analysis__label">🔥 핫 번호 <span>출현 빈도 상위 10</span></p>
|
||||
<div className="lotto-row">
|
||||
{(ld.analysis.hot_numbers ?? []).map((n) => <Ball key={n} n={n} />)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lotto-analysis__group">
|
||||
<p className="lotto-analysis__label">🧊 콜드 번호 <span>출현 빈도 하위 10</span></p>
|
||||
<div className="lotto-row">
|
||||
{(ld.analysis.cold_numbers ?? []).map((n) => <Ball key={n} n={n} />)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lotto-analysis__group">
|
||||
<p className="lotto-analysis__label">⏰ 오버듀 번호 <span>오래 안 나온 번호 (회차 수)</span></p>
|
||||
<div className="lotto-row">
|
||||
{(ld.analysis.overdue_numbers ?? []).map((n) => {
|
||||
const stat = (ld.analysis.number_stats ?? []).find((s) => s.number === n);
|
||||
return (
|
||||
<div key={n} className="lotto-overdue">
|
||||
<Ball n={n} />
|
||||
<span className="lotto-overdue__gap">{stat?.gap ?? '-'}회</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lotto-analysis__stats">
|
||||
<span>역대 합계 평균 <strong>{ld.analysis.mean_sum}</strong></span>
|
||||
<span>표준편차 <strong>±{ld.analysis.std_sum}</strong></span>
|
||||
<span>분석 회차 <strong>{ld.analysis.total_draws?.toLocaleString()}</strong></span>
|
||||
<span>
|
||||
홀수 3:짝수 3 확률{' '}
|
||||
<strong>
|
||||
{ld.analysis.odd_distribution?.['3'] ? `${ld.analysis.odd_distribution['3']}%` : '-'}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="lotto-empty">
|
||||
{ld.loading.analysis ? '불러오는 중...' : '통계 분석 데이터가 없습니다.'}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</details>
|
||||
|
||||
{/* 전체 번호 분포 */}
|
||||
<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>
|
||||
<h3>전체 회차 번호 분포</h3>
|
||||
<p className="lotto-panel__sub">1~45번 번호가 등장한 횟수를 기준으로 분포를 표시합니다.</p>
|
||||
</div>
|
||||
<div className="lotto-panel__actions">
|
||||
{ld.statsLoading ? <span className="lotto-chip">로딩 중</span> : null}
|
||||
{ld.stats?.total_draws ? (
|
||||
<span className="lotto-chip">{ld.stats.total_draws}회차</span>
|
||||
) : null}
|
||||
<button className="button ghost small" onClick={ld.refreshStats} disabled={ld.statsLoading}>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{ld.statsError ? <p className="lotto-empty">{ld.statsError}</p> : null}
|
||||
{ld.stats ? (
|
||||
<FrequencyChart stats={ld.stats} />
|
||||
) : (
|
||||
<p className="lotto-empty">통계 데이터를 불러오지 못했습니다.</p>
|
||||
)}
|
||||
</section>
|
||||
</details>
|
||||
|
||||
{/* 내 번호 패턴 */}
|
||||
<details className="lotto-section-fold">
|
||||
<summary>내 번호 패턴</summary>
|
||||
<PersonalAnalysisPanel data={ld.personalAnalysis} loading={ld.personalLoading} />
|
||||
</details>
|
||||
|
||||
{/* 수동 추천 */}
|
||||
<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>
|
||||
<h3>수동 추천</h3>
|
||||
<p className="lotto-panel__sub">파라미터를 직접 조정해 번호를 추천받을 수 있습니다.</p>
|
||||
</div>
|
||||
<div className="lotto-panel__actions">
|
||||
{mr.loading.recommend ? <span className="lotto-chip">계산 중</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lotto-presets">
|
||||
{mr.presets.map((preset) => (
|
||||
<button key={preset.name} className="button ghost small"
|
||||
onClick={() => mr.setParams({
|
||||
recent_window: preset.recent_window,
|
||||
recent_weight: preset.recent_weight,
|
||||
avoid_recent_k: preset.avoid_recent_k,
|
||||
})}>
|
||||
{preset.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="lotto-form">
|
||||
<label className="lotto-field">
|
||||
recent_window <span>최근 N회차 가중치 범위</span>
|
||||
<input type="number" min={20} max={1000} value={mr.params.recent_window}
|
||||
onChange={(e) => mr.setParams((s) => ({ ...s, recent_window: Number(e.target.value) }))} />
|
||||
</label>
|
||||
<label className="lotto-field">
|
||||
recent_weight <span>최근 회차 가중치</span>
|
||||
<input type="number" step="0.1" min={0.5} max={10} value={mr.params.recent_weight}
|
||||
onChange={(e) => mr.setParams((s) => ({ ...s, recent_weight: Number(e.target.value) }))} />
|
||||
</label>
|
||||
<label className="lotto-field">
|
||||
avoid_recent_k <span>최근 K회차 중복 회피</span>
|
||||
<input type="number" min={0} max={50} value={mr.params.avoid_recent_k}
|
||||
onChange={(e) => mr.setParams((s) => ({ ...s, avoid_recent_k: Number(e.target.value) }))} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button className="button primary" onClick={mr.onRecommend} disabled={mr.loading.recommend}>
|
||||
추천 받기
|
||||
</button>
|
||||
|
||||
{mr.result ? (
|
||||
<div className="lotto-result">
|
||||
<div className="lotto-result__meta">
|
||||
<div>
|
||||
<p className="lotto-result__id">추천 ID #{mr.result.id}</p>
|
||||
<p className="lotto-result__based">기준 회차 {mr.result.based_on_latest_draw ?? '-'}</p>
|
||||
</div>
|
||||
<button className="button small" onClick={() => copyNumbers(mr.result.numbers)}>
|
||||
번호 복사
|
||||
</button>
|
||||
</div>
|
||||
{mr.result.numbers && <NumberRow nums={mr.result.numbers} />}
|
||||
{mr.historyMetrics && (
|
||||
<div className="lotto-compare">
|
||||
<MetricBlock title="추천 통계 (히스토리)" metrics={mr.historyMetrics} />
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(mr.result.items) && mr.result.items.length ? (
|
||||
<details className="lotto-details">
|
||||
<summary>추천 후보 보기</summary>
|
||||
<div className="lotto-batch">
|
||||
{mr.result.items.map((item, idx) => (
|
||||
<div key={item.id ?? `candidate-${idx}`} className="lotto-batch__item">
|
||||
<div className="lotto-batch__meta">
|
||||
<div>
|
||||
<p className="lotto-batch__title">후보 #{item.id ?? idx + 1}</p>
|
||||
<p className="lotto-batch__sub">기준 회차 {item.based_on_draw ?? '-'}</p>
|
||||
</div>
|
||||
<button className="button ghost small" onClick={() => copyNumbers(item.numbers)}>
|
||||
복사
|
||||
</button>
|
||||
</div>
|
||||
<NumberRow nums={item.numbers} />
|
||||
{item.metrics && <MetricBlock title="후보 통계" metrics={item.metrics} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
{mr.result.explain && (
|
||||
<details className="lotto-details">
|
||||
<summary>설명 보기</summary>
|
||||
<pre>{JSON.stringify(mr.result.explain, null, 2)}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
|
||||
)}
|
||||
</section>
|
||||
</details>
|
||||
|
||||
{/* 추천 히스토리 */}
|
||||
<details className="lotto-section-fold">
|
||||
<summary>추천 히스토리</summary>
|
||||
<section className="lotto-panel">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">History</p>
|
||||
<h3>추천 히스토리</h3>
|
||||
<p className="lotto-panel__sub">수동 추천 결과를 모아서 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
<div className="lotto-panel__actions">
|
||||
<span className="lotto-chip">{mr.history.length}건</span>
|
||||
{mr.history.length > 5 && (
|
||||
<button className="button ghost small lotto-history-toggle"
|
||||
onClick={() => mr.setHistoryExpanded((p) => !p)}
|
||||
aria-expanded={mr.historyExpanded}>
|
||||
{mr.historyExpanded ? '접기' : '더보기'}
|
||||
<span className={`lotto-history-toggle__icon ${mr.historyExpanded ? 'is-open' : ''}`} aria-hidden>▼</span>
|
||||
</button>
|
||||
)}
|
||||
<button className="button ghost small" onClick={mr.refreshHistory} disabled={mr.loading.history}>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mr.loading.history ? <p className="lotto-empty">불러오는 중...</p> : null}
|
||||
{mr.history.length === 0 ? (
|
||||
<p className="lotto-empty">저장된 히스토리가 없습니다.</p>
|
||||
) : (
|
||||
<div className="lotto-history">
|
||||
{mr.visibleHistory.map((item) => (
|
||||
<div key={item.id} className="lotto-history__item">
|
||||
<div className="lotto-history__meta">
|
||||
<p>#{item.id}</p>
|
||||
<p>{fmtKST(item.created_at)}</p>
|
||||
<p>기준 회차 {item.based_on_draw ?? '-'}</p>
|
||||
</div>
|
||||
<div className="lotto-history__body">
|
||||
<NumberRow nums={item.numbers} />
|
||||
<p className="lotto-history__params">
|
||||
window={item.params?.recent_window}, weight={item.params?.recent_weight},
|
||||
avoid_k={item.params?.avoid_recent_k}
|
||||
</p>
|
||||
</div>
|
||||
<div className="lotto-history__actions">
|
||||
<button className="button ghost small" onClick={() => copyNumbers(item.numbers)}>
|
||||
복사
|
||||
</button>
|
||||
<button className="button danger small" onClick={() => mr.onDelete(item.id)}>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<span ref={mr.historyEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import useBriefing from '../hooks/useBriefing';
|
||||
import useReview from '../hooks/useReview';
|
||||
import DecisionCard from '../components/decision/DecisionCard';
|
||||
import BriefingEmpty from '../components/briefing/BriefingEmpty';
|
||||
|
||||
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">
|
||||
<DecisionCard briefing={briefing} review={review} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import usePurchases from '../hooks/usePurchases';
|
||||
import PurchasePanel from '../components/PurchasePanel';
|
||||
import PurchaseTrendChart from '../components/PurchaseTrendChart';
|
||||
|
||||
export default function PurchaseTab() {
|
||||
const pur = usePurchases();
|
||||
|
||||
return (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -317,7 +317,7 @@
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@media (max-width: 960px) {
|
||||
.ms-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -487,7 +487,7 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@media (max-width: 640px) {
|
||||
.ms-genre-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
@@ -932,27 +932,6 @@
|
||||
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
|
||||
═══════════════════════════════════════════════════ */
|
||||
@@ -1717,19 +1696,7 @@
|
||||
/* ═══════════════════════════════════════════════════
|
||||
MOBILE
|
||||
═══════════════════════════════════════════════════ */
|
||||
@media (max-width: 768px) {
|
||||
.ms-tabs {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.ms-tabs > * {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@media (max-width: 640px) {
|
||||
.ms-header__title {
|
||||
font-size: clamp(44px, 14vw, 70px);
|
||||
}
|
||||
@@ -2629,904 +2596,3 @@
|
||||
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,12 +15,7 @@ import {
|
||||
getTimestampedLyrics,
|
||||
generateStyleBoost,
|
||||
generateVideo,
|
||||
startBatchGen,
|
||||
getBatchJob,
|
||||
listGenres,
|
||||
} from '../../api';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
import './MusicStudio.css';
|
||||
import AudioPlayer from './components/AudioPlayer';
|
||||
import { fmtTime } from './components/AudioPlayer';
|
||||
@@ -30,8 +25,6 @@ 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';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
데이터 상수
|
||||
@@ -342,7 +335,7 @@ const TrackResult = ({ track, onDownload, onNew }) => {
|
||||
/* ─────────────────────────────────────────────
|
||||
Library Card
|
||||
───────────────────────────────────────────── */
|
||||
const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, onVideoProject, onVideoPipeline, isGenerating }) => {
|
||||
const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, isGenerating }) => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const genre = GENRES.find((g) => g.id === track.genre);
|
||||
const totalSec = track.duration_sec ?? null;
|
||||
@@ -430,12 +423,6 @@ 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>
|
||||
@@ -446,10 +433,6 @@ 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">
|
||||
@@ -462,7 +445,7 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
|
||||
/* ─────────────────────────────────────────────
|
||||
Library Section
|
||||
───────────────────────────────────────────── */
|
||||
const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, onVideoProject, onVideoPipeline, isGenerating, loading }) => {
|
||||
const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, isGenerating, loading }) => {
|
||||
const [playingId, setPlayingId] = useState(null);
|
||||
|
||||
const handlePlay = (track) => {
|
||||
@@ -516,8 +499,6 @@ const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCove
|
||||
onStemSplit={onStemSplit}
|
||||
onSyncedLyrics={onSyncedLyrics}
|
||||
onVideoGenerate={onVideoGenerate}
|
||||
onVideoProject={onVideoProject}
|
||||
onVideoPipeline={onVideoPipeline}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
))}
|
||||
@@ -532,8 +513,6 @@ 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([]);
|
||||
@@ -549,7 +528,6 @@ 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('');
|
||||
@@ -596,17 +574,6 @@ 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';
|
||||
|
||||
@@ -666,56 +633,6 @@ 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) =>
|
||||
@@ -811,7 +728,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 = customTitle.trim() || `${activeGenre?.label} — ${moodLabel} Mix`;
|
||||
const title = `${activeGenre?.label} — ${moodLabel} Mix`;
|
||||
const instList = instruments.length > 0 ? instruments : ['piano', 'synth', 'bass'];
|
||||
|
||||
const payload = {
|
||||
@@ -1139,21 +1056,10 @@ 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);
|
||||
};
|
||||
|
||||
@@ -1213,18 +1119,10 @@ 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 ═══ */}
|
||||
{tab === 'library' && (
|
||||
<PullToRefresh onRefresh={loadLibrary}>
|
||||
<Library
|
||||
tracks={library}
|
||||
loading={libLoading}
|
||||
@@ -1237,11 +1135,8 @@ export default function MusicStudio() {
|
||||
onStemSplit={handleStemSplit}
|
||||
onSyncedLyrics={handleSyncedLyrics}
|
||||
onVideoGenerate={handleVideoGenerate}
|
||||
onVideoProject={handleVideoProject}
|
||||
onVideoPipeline={handleVideoPipeline}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
</PullToRefresh>
|
||||
)}
|
||||
|
||||
{/* ═══ LYRICS TAB ═══ */}
|
||||
@@ -1267,16 +1162,6 @@ 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">
|
||||
@@ -1341,44 +1226,6 @@ 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">
|
||||
@@ -1810,20 +1657,6 @@ 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
|
||||
@@ -1927,10 +1760,6 @@ export default function MusicStudio() {
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === 'library' && (
|
||||
<FAB onClick={() => setTab('create')} label="음악 생성" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user