Compare commits
56 Commits
1d78b2c430
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
| 95d74c1913 | |||
| d8bc6af062 | |||
| 226e368347 | |||
| 310679de61 | |||
| 916d16c235 | |||
| 96a5d97ff7 | |||
| 2ef43b070a | |||
| 7fc2d3aaf7 | |||
| b215a93c89 | |||
| 1f00866694 | |||
| 0849c70644 | |||
| 7a591bb0f1 | |||
| 312677e624 | |||
| 6786f8c883 | |||
| 45b74e672a | |||
| bf5c7ba54e | |||
| 8af2824c12 | |||
| ff0ee3757c | |||
| 0eb55fe731 | |||
| 5dadd4bf2c | |||
| 5cf60e7ee6 | |||
| 74f043bf29 | |||
| e8e45391ae | |||
| c9e29bdad9 | |||
| c4f67e7d34 | |||
| a727bbf153 | |||
| 299ce636ff | |||
| 2b463682d5 | |||
| 1b16b40251 | |||
| 314702cb66 | |||
| 8fcfb6b000 | |||
| 22573909ec | |||
| 9bce2bfb6e | |||
| f7175ad80c | |||
| d1ecf13400 | |||
| 2c4b1e2e3a | |||
| 76447fa262 | |||
| 46e122a229 | |||
| 248835fa54 | |||
| b8d6dac70a | |||
| df54437f47 | |||
| dac06fc4eb | |||
| 1af16dde47 | |||
| c6ac849a25 | |||
| bbc9bf36f9 | |||
| b9aeb2ff3e | |||
| fa696b0c90 | |||
| c28bd9368c | |||
| ccc9f7c634 | |||
| 618d5f8e6f | |||
| 840b0a5300 | |||
| 3e9112c4c7 | |||
| c4abdbed3e | |||
| 9380bf331f | |||
| ba30de718f | |||
| 628a47b2ec |
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm run:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.env.production
Normal file
1
.env.production
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE=https://gahusb.synology.me
|
||||||
283
CLAUDE.md
Normal file
283
CLAUDE.md
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
# web-ui — CLAUDE.md
|
||||||
|
|
||||||
|
개인 웹페이지 프론트엔드 프로젝트에 대한 컨텍스트 문서입니다.
|
||||||
|
|
||||||
|
## 프로젝트 개요
|
||||||
|
|
||||||
|
- **스택**: React 18 + Vite + react-router-dom v6
|
||||||
|
- **목적**: 개인 블로그, 로또 실험, 주식 뉴스/트레이딩, 여행 기록을 한 곳에 모은 개인 웹 UI
|
||||||
|
- **배포 대상**: Synology NAS (`gahusb.synology.me`) Docker 컨테이너 내 nginx
|
||||||
|
|
||||||
|
## 페이지 구조
|
||||||
|
|
||||||
|
| 경로 | 컴포넌트 | 설명 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `/` | `Home` | 메인 허브 |
|
||||||
|
| `/blog` | `Blog` | 마크다운 기반 블로그 |
|
||||||
|
| `/lotto` | `Lotto` | 로또 추천/통계 |
|
||||||
|
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
||||||
|
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
|
||||||
|
| `/realestate` | `Subscription` | 청약 자격·일정 관리 |
|
||||||
|
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
||||||
|
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
||||||
|
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
|
||||||
|
| `/lab/sword-stream` | `SwordStream` | Three.js 파티클 인터랙션 |
|
||||||
|
| `/lab/day-calc` | `DayCalc` | 날짜 계산기 |
|
||||||
|
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
|
||||||
|
| `/todo` | `Todo` | 태스크 보드 |
|
||||||
|
|
||||||
|
라우트 정의: `src/routes.jsx` / 라우터 설정: `src/Router.jsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 설정
|
||||||
|
|
||||||
|
### 핵심 원칙
|
||||||
|
|
||||||
|
- **항상 상대 경로 사용**: 프로덕션에서 프론트와 백엔드는 nginx 리버스 프록시로 동일 도메인에서 서비스됨
|
||||||
|
- **절대 URL 사용 금지**: `https://` 절대 URL을 fetch에 직접 사용하면 Mixed Content 오류 발생
|
||||||
|
- `VITE_API_BASE` 환경변수는 사용하지 않음
|
||||||
|
|
||||||
|
### API 헬퍼 (`src/api.js`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 모든 API 호출은 이 헬퍼를 통해 사용
|
||||||
|
import { apiGet, apiPost, apiPut, apiDelete } from './api';
|
||||||
|
|
||||||
|
// 예시
|
||||||
|
apiGet('/api/lotto/latest')
|
||||||
|
apiPost('/api/portfolio', { ... })
|
||||||
|
```
|
||||||
|
|
||||||
|
제공 함수: `apiGet`, `apiPost`, `apiPut`, `apiDelete`
|
||||||
|
|
||||||
|
### 개발 서버 프록시 (`vite.config.js`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'https://gahusb.synology.me', changeOrigin: true, secure: true },
|
||||||
|
'/media': { target: 'https://gahusb.synology.me', changeOrigin: true, secure: true },
|
||||||
|
// /ext/* — Yahoo Finance, CNN Fear&Greed 등 외부 API 프록시
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `/api/*` → NAS 백엔드
|
||||||
|
- `/media/*` → NAS 미디어 파일 (여행 사진 `/media/travel/`, 음악 `/media/music/`)
|
||||||
|
- 개발 서버 포트: **3007**
|
||||||
|
|
||||||
|
### API 엔드포인트 목록
|
||||||
|
|
||||||
|
| 분류 | 메서드 | 경로 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 로또 기본 | GET | `/api/lotto/latest`, `/api/lotto/stats`, `/api/lotto/recommend` |
|
||||||
|
| 로또 기본 | GET | `/api/lotto/best`, `/api/lotto/analysis` |
|
||||||
|
| 로또 기본 | POST | `/api/admin/simulate` |
|
||||||
|
| 로또 고도화 | GET | `/api/lotto/stats/performance` |
|
||||||
|
| 로또 고도화 | GET | `/api/lotto/report/latest`, `/api/lotto/report/:drw_no`, `/api/lotto/report/history?limit=N` |
|
||||||
|
| 로또 고도화 | GET | `/api/lotto/analysis/personal` |
|
||||||
|
| 로또 구매 | GET | `/api/lotto/purchase?draw_no=N&days=N`, `/api/lotto/purchase/stats` |
|
||||||
|
| 로또 구매 | POST/PUT/DELETE | `/api/lotto/purchase`, `/api/lotto/purchase/:id` |
|
||||||
|
| 히스토리 | GET | `/api/history` |
|
||||||
|
| 히스토리 | DELETE | `/api/history/:id` |
|
||||||
|
| 주식 | GET | `/api/stock/news`, `/api/stock/indices` |
|
||||||
|
| 트레이딩 | GET | `/api/trade/balance` |
|
||||||
|
| 트레이딩 | POST | `/api/trade/order` |
|
||||||
|
| 포트폴리오 | GET/POST | `/api/portfolio` |
|
||||||
|
| 포트폴리오 | PUT/DELETE | `/api/portfolio/:id` |
|
||||||
|
| 예수금 | PUT | `/api/portfolio/cash` — body: `{ broker, cash }` |
|
||||||
|
| 예수금 | DELETE | `/api/portfolio/cash/:broker` |
|
||||||
|
| 자산 스냅샷 | POST | `/api/portfolio/snapshot` — body: `{ total_assets }` 또는 body 없이 서버 계산 |
|
||||||
|
| 자산 스냅샷 | GET | `/api/portfolio/snapshot/history?days=N` — response: `{ history: [{date, total_assets}] }` |
|
||||||
|
| 실현손익 | GET | `/api/portfolio/sell-history?broker=X&days=N` — response: `{ records: [...] }` |
|
||||||
|
| 실현손익 | POST/PUT | `/api/portfolio/sell-history`, `/api/portfolio/sell-history/:id` |
|
||||||
|
| 실현손익 | DELETE | `/api/portfolio/sell-history/:id` |
|
||||||
|
| TODO | GET/POST | `/api/todos` |
|
||||||
|
| TODO | PUT/DELETE | `/api/todos/:id`, `/api/todos/done` |
|
||||||
|
| 블로그 | GET/POST | `/api/blog/posts` |
|
||||||
|
| 블로그 | PUT/DELETE | `/api/blog/posts/:id` |
|
||||||
|
| 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` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NAS 배포 설정
|
||||||
|
|
||||||
|
### 배포 경로
|
||||||
|
|
||||||
|
| 환경 | 경로 |
|
||||||
|
|------|------|
|
||||||
|
| Windows | `Z:\docker\webpage\frontend\` (NAS 네트워크 드라이브 마운트) |
|
||||||
|
| macOS (SMB) | `/Volumes/gahusb.synology.me/docker/webpage/frontend/` |
|
||||||
|
| macOS (SSH) | `/volume1/docker/webpage/frontend/` |
|
||||||
|
|
||||||
|
### 배포 명령어
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 빌드 + 배포 (권장)
|
||||||
|
npm run release:nas
|
||||||
|
|
||||||
|
# 빌드만
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 배포만 (dist 폴더가 이미 있을 때)
|
||||||
|
npm run deploy:nas
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows 배포
|
||||||
|
|
||||||
|
NAS가 `Z:` 드라이브로 마운트되어 있어야 함. `robocopy`로 `/MIR` 동기화하며 로그는 `robocopy.log`에 저장됨.
|
||||||
|
|
||||||
|
### macOS 배포 — SSH 방식 (권장)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 환경변수 설정 후 배포
|
||||||
|
NAS_SSH_TARGET=user@gahusb.synology.me NAS_SSH_PORT=22 npm run release:nas
|
||||||
|
```
|
||||||
|
|
||||||
|
`NAS_SSH_TARGET`이 설정되면 `rsync`로 SSH 배포. SMB 마운트 방식보다 안정적.
|
||||||
|
|
||||||
|
### macOS 배포 — SMB 마운트 방식
|
||||||
|
|
||||||
|
SMB 마운트 후 `ditto`로 복사. `NAS_CLEAN=1` 설정 시 배포 전 기존 파일 전체 삭제.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NAS_CLEAN=1 npm run release:nas
|
||||||
|
```
|
||||||
|
|
||||||
|
### 배포 스크립트 파일
|
||||||
|
|
||||||
|
`scripts/deploy-nas.cjs` — Node.js CJS 모듈, 플랫폼 자동 감지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개발 환경
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # localhost:3007 에서 개발 서버 실행
|
||||||
|
npm run build # dist/ 로 프로덕션 빌드
|
||||||
|
npm run lint # ESLint 검사
|
||||||
|
npm run preview # 빌드 결과물 미리보기
|
||||||
|
```
|
||||||
|
|
||||||
|
## 주요 파일 위치
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `src/api.js` | API 헬퍼 함수 모음 |
|
||||||
|
| `src/routes.jsx` | 라우트 및 네비게이션 링크 정의 |
|
||||||
|
| `src/Router.jsx` | BrowserRouter 설정 |
|
||||||
|
| `vite.config.js` | 개발 서버 및 프록시 설정 |
|
||||||
|
| `scripts/deploy-nas.cjs` | NAS 배포 스크립트 |
|
||||||
|
| `src/content/blog/` | 블로그 마크다운 파일 |
|
||||||
|
| `public/` | 정적 파일 (로고, API 스펙 등) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sonic Forge — AI 음악 생성 스튜디오 (`/lab/music`)
|
||||||
|
|
||||||
|
### 파일 구조
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `src/pages/music/MusicStudio.jsx` | 메인 컴포넌트 |
|
||||||
|
| `src/pages/music/MusicStudio.css` | 스타일 (Bebas Neue · Syne · Courier Prime) |
|
||||||
|
|
||||||
|
### 주요 컴포넌트
|
||||||
|
|
||||||
|
- **SonicRadar** — 헤더 우측 비주얼. SVG 링·크로스헤어·스윕 라인 + 48개 CSS 방사형 바. `isGenerating` / `accentColor` prop으로 상태 전환
|
||||||
|
- **WaveformCanvas** — 스테이지 우측 캔버스 오실로스코프 (헤더와 별도)
|
||||||
|
- **AudioPlayer** — 실제 `<audio>` 엘리먼트 기반. `audio_url` 없으면 타이머 폴백
|
||||||
|
- **Library** — 저장된 트랙 카드 그리드 + 삭제/재생
|
||||||
|
- **GenerationProgress** — 진행률 바 + 단계 메시지
|
||||||
|
|
||||||
|
### 생성 플로우
|
||||||
|
|
||||||
|
```
|
||||||
|
handleGenerate()
|
||||||
|
→ POST /api/music/generate (payload에 title 포함)
|
||||||
|
→ task_id 반환 시: setInterval 3초 폴링 (getMusicStatus)
|
||||||
|
succeeded → setTrack(status.track 우선, 없으면 로컬 조립) + loadLibrary()
|
||||||
|
failed → genError 표시
|
||||||
|
→ API 실패 시: 6단계 시뮬레이션 폴백 (오프라인 모드)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 백엔드 연동 규칙
|
||||||
|
|
||||||
|
- `audio_url`은 반드시 **상대경로** `/media/music/{task_id}.mp3` 형식 (절대 URL 금지)
|
||||||
|
- `status` 응답 shape: `{ status, progress, message, audio_url?, error?, track? }`
|
||||||
|
- `track` 객체: `{ id, title, genre, moods[], instruments[], duration_sec, bpm, key, scale, audio_url, created_at }`
|
||||||
|
- 백엔드가 `succeeded` 시 library 자동 등록 → 프론트는 "Save" 버튼 없음, `loadLibrary()` 자동 호출
|
||||||
|
- generate payload에 `title` 포함 → 백엔드에서 payload title 우선 사용 권장
|
||||||
|
|
||||||
|
### CSS 설계 특이사항
|
||||||
|
|
||||||
|
- 설명 토글: `.ms-desc-wrap` + `grid-template-rows: 0fr → 1fr` 트랜지션으로 높이 애니메이션
|
||||||
|
- 완전히 닫힐 때 노출 방지: `.ms-desc-wrap { overflow: hidden }` + `.ms-desc-wrap > * { min-height: 0 }`
|
||||||
|
- 장르 선택 시 `--ms-accent` / `--radar-accent` / `--g-color` CSS 변수로 전체 컬러 테마 동기화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lotto 고도화 (`/lotto`)
|
||||||
|
|
||||||
|
`src/pages/lotto/Functions.jsx`에 4개 신규 섹션 추가:
|
||||||
|
|
||||||
|
| 섹션 | API | 설명 |
|
||||||
|
|------|-----|------|
|
||||||
|
| PerformanceBanner | `/api/lotto/stats/performance` | 수익률·당첨 통계 상단 띠 |
|
||||||
|
| ReportPanel | `/api/lotto/report/*` | 주간 리포트 + 전략 카드 + ConfidenceRing |
|
||||||
|
| PersonalAnalysisPanel | `/api/lotto/analysis/personal` | 개인 번호 성향 분석 |
|
||||||
|
| PurchasePanel | `/api/lotto/purchase/*` | 구매 내역 CRUD |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Travel 갤러리 (`/travel`)
|
||||||
|
|
||||||
|
- 테마: "Dark Room" (배경 `#0f0c09`, 서체 Cormorant Garamond + Space Mono)
|
||||||
|
- 사진 URL: `/media/travel/...` 형식 → `vite.config.js` `/media` 프록시로 처리
|
||||||
|
- 프로덕션 nginx에도 `location /media/` 프록시 블록 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Windows AI 서버 (`C:\Users\jaeoh\Desktop\workspace\music_ai`)
|
||||||
|
|
||||||
|
NAS의 music-lab 컨테이너 대신 Windows PC(RTX 5070 Ti)에서 MusicGen을 로컬 추론하는 별도 서버.
|
||||||
|
|
||||||
|
### 구성 파일
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `server.py` | FastAPI 서버 (generate/status/audio 엔드포인트) |
|
||||||
|
| `model.py` | MusicGen 래퍼 + 프롬프트 빌더 (genre/mood/instruments→텍스트) |
|
||||||
|
| `.env` | MODEL_NAME, OUTPUT_DIR, SERVER_PORT 등 |
|
||||||
|
| `setup.bat` | venv 생성 + PyTorch CUDA 12.4 + audiocraft 설치 |
|
||||||
|
| `start.bat` | 서버 시작 |
|
||||||
|
|
||||||
|
### 엔드포인트
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/health` | 서버·GPU 상태 확인 |
|
||||||
|
| POST | `/generate` | 음악 생성 → `task_id` 즉시 반환 |
|
||||||
|
| GET | `/status/{task_id}` | 생성 진행 폴링 |
|
||||||
|
| GET | `/audio/{task_id}.mp3` | 완성 오디오 파일 |
|
||||||
|
|
||||||
|
### 모델
|
||||||
|
|
||||||
|
- 기본: `facebook/musicgen-stereo-large` (16GB VRAM, 스테레오 고품질)
|
||||||
|
- RTX 5070 Ti(16GB)로 실행 가능
|
||||||
|
|
||||||
|
### NAS 연동 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
web-ui → POST /api/music/generate (NAS music-lab)
|
||||||
|
→ music-lab이 Windows PC :8765/generate 호출
|
||||||
|
→ Windows PC가 MusicGen 추론 → WAV → MP3 변환
|
||||||
|
→ music-lab이 /status 폴링 → audio_url 다운로드
|
||||||
|
→ /media/music/{task_id}.mp3 저장 → DB 등록
|
||||||
|
→ 프론트 폴링 성공 → Library 자동 갱신
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 방화벽에서 포트 8765 인바운드 허용 필요.
|
||||||
172
README.md
172
README.md
@@ -1,27 +1,165 @@
|
|||||||
# Web UI
|
# Web UI
|
||||||
|
|
||||||
블로그, 로또 추천 실험, 여행 기록을 한 곳에서 모아 보는 개인 웹 UI입니다.
|
개인 대시보드 — 블로그, 로또, 주식, 부동산, 여행, 할 일 등을 한 곳에서 관리하는 개인 웹 UI입니다.
|
||||||
|
|
||||||
## 블로그
|
## 기술 스택
|
||||||
|
|
||||||
- 마크다운 기반 글 작성 및 자동 목록화 (`src/content/blog`)
|
| 분류 | 사용 기술 |
|
||||||
- 태그 기반 카테고리 분류와 카테고리별 목록 뷰
|
|------|-----------|
|
||||||
- 목록/본문 분리 UI, 페이지네이션 지원
|
| 프레임워크 | React 18 + Vite |
|
||||||
- 인라인 스타일(링크/강조/코드/이미지) 렌더링 지원
|
| 라우팅 | react-router-dom v6 |
|
||||||
|
| 지도 | react-leaflet + Leaflet |
|
||||||
|
| 차트 | Recharts |
|
||||||
|
| 3D | Three.js |
|
||||||
|
| 스타일 | 커스텀 CSS (CSS 변수 기반 사이버펑크 다크 테마) |
|
||||||
|
| 배포 | Synology NAS (Docker + nginx 리버스 프록시) |
|
||||||
|
|
||||||
## Lotto Lab
|
---
|
||||||
|
|
||||||
- 최신 로또 결과 조회
|
## 페이지 구성
|
||||||
- 추천 번호 생성 (가중치/최근 회차/회피 수 등 파라미터 반영)
|
|
||||||
- 프리셋 파라미터로 빠른 추천 생성
|
### Home (`/`)
|
||||||
|
|
||||||
|
개인 아카이브 허브.
|
||||||
|
|
||||||
|
- 전체 페이지 네비게이션 카드 그리드
|
||||||
|
- 최근 업데이트 및 할 일 목록 미리보기
|
||||||
|
- 사이트 소개 및 최신 활동 요약
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Blog (`/blog`)
|
||||||
|
|
||||||
|
마크다운 기반 개인 블로그.
|
||||||
|
|
||||||
|
- `src/content/blog/` 폴더의 마크다운 파일 자동 목록화
|
||||||
|
- 태그 기반 카테고리 분류 및 필터링
|
||||||
|
- 목록 / 본문 분리 UI, 페이지네이션 지원
|
||||||
|
- 인라인 스타일(링크, 강조, 코드, 이미지) 렌더링
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Lotto (`/lotto`)
|
||||||
|
|
||||||
|
로또 번호 추천 및 통계 실험실.
|
||||||
|
|
||||||
|
- 최신 로또 당첨 결과 조회
|
||||||
|
- 가중치·최근 회차·회피 수 파라미터 기반 번호 추천
|
||||||
|
- 프리셋으로 빠른 추천 생성
|
||||||
- 추천 히스토리 목록 확인 및 삭제
|
- 추천 히스토리 목록 확인 및 삭제
|
||||||
- 번호 복사 기능
|
- 번호 원클릭 복사
|
||||||
- API 스펙 다운로드 링크 제공 (`public/lotto-api.md`)
|
|
||||||
|
|
||||||
## 여행 기록 (Travel Archive)
|
---
|
||||||
|
|
||||||
|
### Stock (`/stock`)
|
||||||
|
|
||||||
|
주식 시장 모니터링 대시보드.
|
||||||
|
|
||||||
|
- KOSPI, KOSDAQ, 나스닥, S&P500 등 주요 지수 현황
|
||||||
|
- Fear & Greed 지수 및 VIX 변동성 지표 시각화
|
||||||
|
- 미국 10년물 금리, WTI/Brent 유가 등 매크로 지표
|
||||||
|
- 국내 / 해외 주식 뉴스 탭 분류 및 필터링
|
||||||
|
|
||||||
|
### Stock Trade (`/stock/trade`)
|
||||||
|
|
||||||
|
포트폴리오 관리 및 트레이딩 데스크.
|
||||||
|
|
||||||
|
- **포트폴리오 탭**: 보유 종목 수익률, 자산 구성 차트 (파이/바 차트)
|
||||||
|
- **AI 탭**: AI 시장 분석 요약 및 투자 인사이트
|
||||||
|
- **리포트 탭**: 자산 스냅샷 히스토리 및 수익 추이
|
||||||
|
- 종목 추가/편집/삭제 CRUD
|
||||||
|
- 현금 잔고(예수금) 관리, 브로커별 분리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Realestate (`/realestate`)
|
||||||
|
|
||||||
|
부동산 청약 통합 관리 — 청약 대시보드와 관심 단지 정보 두 화면으로 구성.
|
||||||
|
|
||||||
|
#### 청약 대시보드 (`/realestate`)
|
||||||
|
|
||||||
|
- **청약 목록 탭**: 관심 청약 카드 목록 + 상세 패널 (요건/일정/자금 섹션)
|
||||||
|
- **일정 탭**: 전체 청약 일정 타임라인 (청약 → 계약 → 중도금 → 잔금)
|
||||||
|
- **자금 탭**: 단지별 자금 계획 및 총합 분석
|
||||||
|
- 가점 계산 엔진 (무주택기간 최대 32점, 부양가족 최대 35점, 통장기간 최대 17점 = 84점 만점)
|
||||||
|
- 내 청약 조건 프로필 입력 및 단지별 요건 충족 여부 자동 비교
|
||||||
|
- 청약 유형 분류: 줍줍 / 특공 / 일반
|
||||||
|
- API 미구현 시 localStorage fallback으로 데이터 유지
|
||||||
|
|
||||||
|
#### 부동산 정보 (`/realestate/property`)
|
||||||
|
|
||||||
|
- 관심 아파트 단지 카드 그리드 + 지도 통합 뷰 (react-leaflet)
|
||||||
|
- 단지별 상태 마커: 청약예정 / 청약중 / 결과발표 / 완료
|
||||||
|
- D-day 카운트다운 및 우선순위 배지
|
||||||
|
- 평당가 비교 바 차트 (Recharts)
|
||||||
|
- 일정 탭: 전체 단지 청약 일정 타임라인
|
||||||
|
- 분석 탭: 단지별 평당가 비교표
|
||||||
|
- 모달 기반 단지 추가/편집 (단지명, 주소, 좌표, 평형, 분양가, 네이버 부동산 URL)
|
||||||
|
- 네이버 부동산 바로가기 링크 연동
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Travel (`/travel`)
|
||||||
|
|
||||||
|
여행 사진 갤러리.
|
||||||
|
|
||||||
- 지도 기반 지역 선택 (GeoJSON)
|
- 지도 기반 지역 선택 (GeoJSON)
|
||||||
- 선택한 지역의 사진 목록 로딩 및 캐시
|
- 선택 지역의 사진 목록 로딩 및 캐시
|
||||||
- 스크롤 기반 사진 추가 로딩 (chunked lazy load)
|
- 스크롤 기반 이미지 추가 로딩 (chunked lazy load)
|
||||||
- 썸네일/모달 뷰, 키보드/스와이프 네비게이션
|
- 썸네일 / 모달 뷰, 키보드 및 스와이프 네비게이션
|
||||||
- 앨범/파일 메타 정보 표시
|
- 앨범 및 파일 메타 정보 표시
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Lab (`/lab`)
|
||||||
|
|
||||||
|
실험적 UI/UX 효과 테스트 공간.
|
||||||
|
|
||||||
|
- Three.js 기반 실시간 3D 파티클 애니메이션 (1,500개 오브젝트)
|
||||||
|
- 호버 모드: 마우스 추적 및 자연스러운 흐름
|
||||||
|
- 오빗 모드: 클릭 시 나선형 궤도 회전
|
||||||
|
- 동적 스케일, 조명 효과
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Todo (`/todo`)
|
||||||
|
|
||||||
|
태스크 관리 보드.
|
||||||
|
|
||||||
|
- 칸반 레이아웃: 할 일 → 진행 중 → 완료
|
||||||
|
- 드래그 앤 드롭으로 상태 변경
|
||||||
|
- 태스크 추가/삭제, 완료 항목 일괄 정리
|
||||||
|
- 상태별 카운트 및 타임스탬프 표시
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개발 환경
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # localhost:3007
|
||||||
|
npm run build # dist/ 빌드
|
||||||
|
npm run lint # ESLint
|
||||||
|
npm run preview # 빌드 결과 미리보기
|
||||||
|
```
|
||||||
|
|
||||||
|
## NAS 배포
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 빌드 + NAS 배포 (Windows, Z: 드라이브 마운트 필요)
|
||||||
|
npm run release:nas
|
||||||
|
|
||||||
|
# SSH 배포 (macOS)
|
||||||
|
NAS_SSH_TARGET=user@gahusb.synology.me NAS_SSH_PORT=22 npm run release:nas
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 설정
|
||||||
|
|
||||||
|
모든 API 호출은 상대 경로(`/api/...`)를 사용합니다. 개발 서버에서는 `vite.config.js`의 프록시 설정으로 NAS 백엔드(`gahusb.synology.me`)로 자동 전달됩니다.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { apiGet, apiPost, apiPut, apiDelete } from './api';
|
||||||
|
|
||||||
|
apiGet('/api/stock/indices');
|
||||||
|
apiPost('/api/subscription/items', { ... });
|
||||||
|
```
|
||||||
|
|||||||
413
package-lock.json
generated
413
package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.30.3",
|
"react-router-dom": "^6.30.3",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"three": "^0.182.0"
|
"three": "^0.182.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1045,6 +1046,42 @@
|
|||||||
"react-dom": "^18.0.0"
|
"react-dom": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||||
|
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@remix-run/router": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.2",
|
"version": "1.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||||
@@ -1411,6 +1448,18 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -1456,6 +1505,69 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -1474,14 +1586,14 @@
|
|||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.2.79",
|
"version": "18.2.79",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.79.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.79.tgz",
|
||||||
"integrity": "sha512-RwGAGXPl9kSXwdNTafkOEuFrTBD5SA2B3iEB96xi8+xu5ddUa/cpvyVCSNn+asgLCTHkb5ZxN8gbuibYJi4s1w==",
|
"integrity": "sha512-RwGAGXPl9kSXwdNTafkOEuFrTBD5SA2B3iEB96xi8+xu5ddUa/cpvyVCSNn+asgLCTHkb5ZxN8gbuibYJi4s1w==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
@@ -1498,6 +1610,12 @@
|
|||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
|
||||||
@@ -1692,6 +1810,15 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -1745,9 +1872,130 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -1766,6 +2014,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -1780,6 +2034,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.45.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.0.tgz",
|
||||||
|
"integrity": "sha512-RArCX+Zea16+R1jg4mH223Z8p/ivbJjIkU3oC6ld2bdUfmDxiCkFYSi9zLOR2anucWJUeH4Djnzgd0im0nD3dw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||||
@@ -2029,6 +2293,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -2241,6 +2511,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -2268,6 +2548,15 @@
|
|||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-extglob": {
|
"node_modules/is-extglob": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
@@ -2713,6 +3002,13 @@
|
|||||||
"react": "^18.2.0"
|
"react": "^18.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-is": {
|
||||||
|
"version": "19.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
||||||
|
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/react-leaflet": {
|
"node_modules/react-leaflet": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
||||||
@@ -2727,6 +3023,29 @@
|
|||||||
"react-dom": "^18.0.0"
|
"react-dom": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
@@ -2769,6 +3088,57 @@
|
|||||||
"react-dom": ">=16.8"
|
"react-dom": ">=16.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||||
|
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve-from": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
@@ -2928,6 +3298,12 @@
|
|||||||
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -2999,6 +3375,37 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.30.3",
|
"react-router-dom": "^6.30.3",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"three": "^0.182.0"
|
"three": "^0.182.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
492
src/App.css
492
src/App.css
@@ -1,77 +1,493 @@
|
|||||||
:root {
|
/* ═══════════════════════════════════════════════════════════════════════
|
||||||
--bg: #0f0d12;
|
App.css — Dashboard Layout & Design System
|
||||||
--surface: rgba(26, 23, 32, 0.88);
|
Cyberpunk / Futuristic Dashboard UI
|
||||||
--text: #f4efe9;
|
═══════════════════════════════════════════════════════════════════════ */
|
||||||
--muted: #b6b1a9;
|
|
||||||
--line: rgba(255, 255, 255, 0.12);
|
/* ── Layout: App Shell ───────────────────────────────────────────────── */
|
||||||
--accent: #f7a8a5;
|
|
||||||
--accent-strong: #fdd4b1;
|
|
||||||
--font-display: "DM Serif Display", "Noto Serif KR", serif;
|
|
||||||
--font-body: "Manrope", "Noto Sans KR", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
min-height: 100vh;
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Layout: Content Area ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.app-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
margin-left: var(--sidebar-w);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layout: Top Bar (mobile only) ──────────────────────────────────── */
|
||||||
|
|
||||||
|
.app-topbar {
|
||||||
|
display: none;
|
||||||
|
height: var(--topbar-h);
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: rgba(7, 11, 25, 0.85);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app-topbar {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layout: Main Content ────────────────────────────────────────────── */
|
||||||
|
|
||||||
.site-main {
|
.site-main {
|
||||||
max-width: 1200px;
|
flex: 1;
|
||||||
margin: 0 auto;
|
overflow-y: auto;
|
||||||
padding: 40px 20px 80px;
|
overflow-x: hidden;
|
||||||
|
padding: 28px 32px;
|
||||||
|
background: transparent;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.site-main {
|
.site-main {
|
||||||
padding: 20px 16px 60px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Loading State ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.suspend-loading {
|
.suspend-loading {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
min-height: 50vh;
|
min-height: 50vh;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeUp {
|
/* ═══════════════════════════════════════════════════════════════════════
|
||||||
from {
|
Animations
|
||||||
opacity: 0;
|
═══════════════════════════════════════════════════════════════════════ */
|
||||||
transform: translateY(16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-main>* {
|
@keyframes glowPulse {
|
||||||
animation: fadeUp 0.6s ease both;
|
0%, 100% {
|
||||||
|
box-shadow: var(--glow-cyan);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: var(--glow-purple);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes scanLine {
|
||||||
|
0% { transform: translateY(-100%); }
|
||||||
|
100% { transform: translateY(100vh); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes neonFlicker {
|
||||||
|
0%, 95%, 100% { opacity: 1; }
|
||||||
|
96% { opacity: 0.85; }
|
||||||
|
97% { opacity: 1; }
|
||||||
|
98% { opacity: 0.9; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes borderGlow {
|
||||||
|
0% { border-color: var(--neon-cyan-dim); }
|
||||||
|
50% { border-color: var(--neon-purple-dim); }
|
||||||
|
100% { border-color: var(--neon-cyan-dim); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-enter {
|
||||||
|
animation: fadeIn 0.4s var(--ease-out) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════
|
||||||
|
Button System
|
||||||
|
═══════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
padding: 10px 18px;
|
background: var(--surface-card);
|
||||||
border-radius: 999px;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 14px;
|
font-family: var(--font-body);
|
||||||
letter-spacing: 0.08em;
|
font-size: 13px;
|
||||||
text-transform: uppercase;
|
font-weight: 500;
|
||||||
transition: all 0.2s ease;
|
letter-spacing: 0.05em;
|
||||||
background: rgba(255, 255, 255, 0.06);
|
padding: 9px 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s var(--ease-out),
|
||||||
|
color 0.2s var(--ease-out),
|
||||||
|
background 0.2s var(--ease-out),
|
||||||
|
box-shadow 0.2s var(--ease-out),
|
||||||
|
filter 0.2s var(--ease-out),
|
||||||
|
transform 0.15s var(--ease-spring);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:hover {
|
.button:hover {
|
||||||
border-color: rgba(255, 255, 255, 0.3);
|
border-color: var(--line-bright);
|
||||||
transform: translateY(-2px);
|
color: var(--neon-cyan);
|
||||||
|
box-shadow: 0 0 12px rgba(0, 212, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary */
|
||||||
.button.primary {
|
.button.primary {
|
||||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
background: var(--grad-accent);
|
||||||
color: #1a1414;
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 212, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.primary:hover {
|
||||||
|
box-shadow: var(--glow-cyan);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ghost */
|
||||||
|
.button.ghost {
|
||||||
|
background: transparent;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.ghost {
|
.button.ghost:hover {
|
||||||
background: transparent;
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
border-color: var(--line);
|
||||||
|
color: var(--text-bright);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small */
|
||||||
|
.button.small {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Danger */
|
||||||
|
.button.danger {
|
||||||
|
border-color: rgba(239, 68, 68, 0.4);
|
||||||
|
color: rgba(248, 113, 113, 1);
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.danger:hover {
|
||||||
|
border-color: rgba(239, 68, 68, 0.7);
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
box-shadow: 0 0 12px rgba(239, 68, 68, 0.15);
|
||||||
|
color: rgba(252, 165, 165, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled */
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════
|
||||||
|
Dashboard Card / Panel System
|
||||||
|
═══════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.dash-card {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 20px;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
border-color 0.25s var(--ease-out),
|
||||||
|
box-shadow 0.25s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top accent line */
|
||||||
|
.dash-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--grad-accent);
|
||||||
|
opacity: 0.3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-card:hover {
|
||||||
|
border-color: rgba(0, 212, 255, 0.15);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 40px rgba(0, 0, 0, 0.5),
|
||||||
|
0 0 0 1px rgba(0, 212, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Elevated variant */
|
||||||
|
.dash-card.raised {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow variant */
|
||||||
|
.dash-card.glow {
|
||||||
|
animation: glowPulse 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Legacy card alias ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
border-color: rgba(0, 212, 255, 0.15);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px rgba(0, 0, 0, 0.5),
|
||||||
|
0 0 0 1px rgba(0, 212, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════
|
||||||
|
Typography Utilities
|
||||||
|
═══════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* Eyebrow / Section label */
|
||||||
|
.eyebrow {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.26em;
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel title */
|
||||||
|
.panel-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-bright);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section heading */
|
||||||
|
.section-heading {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-bright);
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════
|
||||||
|
Badge / Chip System
|
||||||
|
═══════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.cyan {
|
||||||
|
background: var(--neon-cyan-muted);
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.purple {
|
||||||
|
background: var(--neon-purple-muted);
|
||||||
|
color: var(--neon-purple);
|
||||||
|
border: 1px solid rgba(139, 92, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.green {
|
||||||
|
background: rgba(52, 211, 153, 0.12);
|
||||||
|
color: #34d399;
|
||||||
|
border: 1px solid rgba(52, 211, 153, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.red {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
color: #f87171;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--text-dim);
|
||||||
|
background: var(--surface-card);
|
||||||
|
transition: border-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:hover {
|
||||||
|
border-color: var(--line-bright);
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════
|
||||||
|
Data Display Utilities
|
||||||
|
═══════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* Metric / stat number */
|
||||||
|
.metric-value {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-bright);
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Positive / negative indicators */
|
||||||
|
.pos {
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neg {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Separator / Divider ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--line);
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gradient text utility ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.gradient-text {
|
||||||
|
background: var(--grad-accent);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════
|
||||||
|
Grid Utilities
|
||||||
|
═══════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.dash-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-grid-2 {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-grid-3 {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-grid-4 {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.dash-grid-4 { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.dash-grid-3 { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dash-grid-2,
|
||||||
|
.dash-grid-3,
|
||||||
|
.dash-grid-4 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════
|
||||||
|
Responsive Mobile
|
||||||
|
═══════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-content {
|
||||||
|
margin-left: 0;
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-main {
|
||||||
|
overflow: visible;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
14
src/App.jsx
14
src/App.jsx
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import Navbar from './components/Navbar';
|
import Navbar from './components/Navbar';
|
||||||
|
import PageHeader from './components/PageHeader';
|
||||||
import Loading from './components/Loading';
|
import Loading from './components/Loading';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
@@ -8,11 +9,14 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main className="site-main">
|
<div className="app-content">
|
||||||
<React.Suspense fallback={<div className="suspend-loading"><Loading /></div>}>
|
<main className="site-main">
|
||||||
<Outlet />
|
<PageHeader />
|
||||||
</React.Suspense>
|
<React.Suspense fallback={<div className="suspend-loading"><Loading /></div>}>
|
||||||
</main>
|
<Outlet />
|
||||||
|
</React.Suspense>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
540
src/api.js
540
src/api.js
@@ -1,25 +1,7 @@
|
|||||||
// src/api.js
|
// src/api.js
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || "";
|
// 프론트와 API가 동일 도메인(nginx 프록시)이므로 항상 상대 경로 사용.
|
||||||
|
// 절대 URL(VITE_API_BASE)은 Mixed Content를 유발하므로 사용하지 않음.
|
||||||
const toApiUrl = (path) => {
|
const toApiUrl = (path) => path;
|
||||||
if (!API_BASE) return path;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const base = new URL(API_BASE, window.location.origin);
|
|
||||||
// Ensure base pathname ends with '/' if it's not the root or if likely intended as a directory
|
|
||||||
if (!base.pathname.endsWith('/')) {
|
|
||||||
base.pathname += '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove leading slash from path to avoid double slashes when joining
|
|
||||||
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
|
||||||
|
|
||||||
return new URL(cleanPath, base).toString();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Invalid VITE_API_BASE configuration:", error);
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function apiGet(path) {
|
export async function apiGet(path) {
|
||||||
const res = await fetch(toApiUrl(path), {
|
const res = await fetch(toApiUrl(path), {
|
||||||
@@ -57,6 +39,22 @@ export async function apiPost(path, body) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiPut(path, body) {
|
||||||
|
const res = await fetch(toApiUrl(path), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json",
|
||||||
|
...(body ? { "Content-Type": "application/json" } : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
export function getLatest() {
|
export function getLatest() {
|
||||||
return apiGet("/api/lotto/latest");
|
return apiGet("/api/lotto/latest");
|
||||||
}
|
}
|
||||||
@@ -82,6 +80,25 @@ export function deleteHistory(id) {
|
|||||||
return apiDelete(`/api/history/${id}`);
|
return apiDelete(`/api/history/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 시뮬레이션 관련 API ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getBestPicks(limit = 20) {
|
||||||
|
return apiGet(`/api/lotto/best?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAnalysis() {
|
||||||
|
return apiGet('/api/lotto/analysis');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerSimulate(nCandidates = 20000, topK = 100, bestN = 20) {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
n_candidates: String(nCandidates),
|
||||||
|
top_k: String(topK),
|
||||||
|
best_n: String(bestN),
|
||||||
|
});
|
||||||
|
return apiPost(`/api/admin/simulate?${qs.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function getStockNews(limit = 20, category) {
|
export function getStockNews(limit = 20, category) {
|
||||||
const qs = new URLSearchParams({ limit: String(limit) });
|
const qs = new URLSearchParams({ limit: String(limit) });
|
||||||
if (category) {
|
if (category) {
|
||||||
@@ -101,3 +118,484 @@ export function getTradeBalance() {
|
|||||||
export function createTradeOrder(payload) {
|
export function createTradeOrder(payload) {
|
||||||
return apiPost("/api/trade/order", payload);
|
return apiPost("/api/trade/order", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 포트폴리오 (수동 입력) API ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getPortfolio() {
|
||||||
|
return apiGet("/api/portfolio");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addPortfolio(item) {
|
||||||
|
return apiPost("/api/portfolio", item);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePortfolio(id, fields) {
|
||||||
|
return apiPut(`/api/portfolio/${id}`, fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePortfolio(id) {
|
||||||
|
return apiDelete(`/api/portfolio/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 자산 스냅샷 API ──────────────────────────────────────────────────────────
|
||||||
|
// 장 마감 시점 총 자산을 기록하고, 기간별 추이를 조회합니다.
|
||||||
|
|
||||||
|
// GET /api/portfolio/snapshot/history?days=N
|
||||||
|
// response: { history: [{ date: "2026-03-07", total_assets: 12345678 }, ...] }
|
||||||
|
export function getAssetHistory(days = 30) {
|
||||||
|
const qs = days ? `?days=${days}` : '';
|
||||||
|
return apiGet(`/api/portfolio/snapshot/history${qs}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/portfolio/snapshot (body 없이 호출 — 서버가 현재 total_assets 계산해서 저장)
|
||||||
|
// 또는 body: { total_assets: number } 로 직접 지정 가능
|
||||||
|
export function saveAssetSnapshot(total_assets) {
|
||||||
|
return apiPost('/api/portfolio/snapshot', total_assets != null ? { total_assets } : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 예수금 API ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function upsertCash(broker, cash) {
|
||||||
|
return apiPut('/api/portfolio/cash', { broker, cash });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCash(broker) {
|
||||||
|
return apiDelete(`/api/portfolio/cash/${encodeURIComponent(broker)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 시장 심리 지표 API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// CNN Fear & Greed Index (개발: vite proxy /ext/feargreed, 프로덕션: nginx proxy 필요)
|
||||||
|
export async function getFearAndGreed() {
|
||||||
|
const res = await fetch('/ext/feargreed', { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yahoo Finance chart API 공통 파서
|
||||||
|
async function fetchYahooPrice(extPath) {
|
||||||
|
const res = await fetch(extPath, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const meta = data?.chart?.result?.[0]?.meta;
|
||||||
|
const price = meta?.regularMarketPrice;
|
||||||
|
const prevClose = meta?.previousClose ?? meta?.chartPreviousClose;
|
||||||
|
if (price == null) throw new Error('데이터 없음');
|
||||||
|
const rounded = Math.round(price * 100) / 100;
|
||||||
|
const change = prevClose != null ? Math.round((price - prevClose) * 100) / 100 : null;
|
||||||
|
const changePercent = prevClose ? Math.round(((price - prevClose) / prevClose) * 10000) / 100 : null;
|
||||||
|
return { value: rounded, change, changePercent };
|
||||||
|
}
|
||||||
|
|
||||||
|
// VIX 지수 (Yahoo Finance 공개 API)
|
||||||
|
export function getVix() { return fetchYahooPrice('/ext/vix'); }
|
||||||
|
|
||||||
|
// 미국 10년물 국채 금리 (^TNX)
|
||||||
|
export function getTreasury10Y() { return fetchYahooPrice('/ext/treasury'); }
|
||||||
|
|
||||||
|
// WTI 원유 선물 (CL=F)
|
||||||
|
export function getWTI() { return fetchYahooPrice('/ext/wti'); }
|
||||||
|
|
||||||
|
// Brent 원유 선물 (BZ=F)
|
||||||
|
export function getBrent() { return fetchYahooPrice('/ext/brent'); }
|
||||||
|
|
||||||
|
// ── TODO API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getTodos() {
|
||||||
|
return apiGet('/api/todos');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addTodo(data) {
|
||||||
|
return apiPost('/api/todos', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTodo(id, data) {
|
||||||
|
return apiPut(`/api/todos/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTodo(id) {
|
||||||
|
return apiDelete(`/api/todos/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTodos() {
|
||||||
|
return apiDelete('/api/todos/done');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 실현손익 내역 API ─────────────────────────────────────────────────────────
|
||||||
|
// GET /api/portfolio/sell-history?broker=X&days=N → { records: [...] }
|
||||||
|
// POST /api/portfolio/sell-history → 저장된 레코드 반환
|
||||||
|
// DELETE /api/portfolio/sell-history/:id → { ok: true }
|
||||||
|
|
||||||
|
export function getSellHistory({ broker, days } = {}) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (broker && broker !== 'ALL') qs.set('broker', broker);
|
||||||
|
if (days) qs.set('days', String(days));
|
||||||
|
const q = qs.toString();
|
||||||
|
return apiGet(`/api/portfolio/sell-history${q ? '?' + q : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addSellHistory(record) {
|
||||||
|
return apiPost('/api/portfolio/sell-history', record);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSellHistory(id, record) {
|
||||||
|
return apiPut(`/api/portfolio/sell-history/${id}`, record);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSellHistory(id) {
|
||||||
|
return apiDelete(`/api/portfolio/sell-history/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AI 음악 생성 API ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/music/providers → { providers: [{ id, name, description, features }] }
|
||||||
|
export function getMusicProviders() {
|
||||||
|
return apiGet('/api/music/providers');
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/generate
|
||||||
|
// body: { provider, genre, moods, instruments, duration_sec, bpm, key, scale, prompt, lyrics, instrumental }
|
||||||
|
// → { task_id: string, provider: string }
|
||||||
|
export function generateMusic(payload) {
|
||||||
|
return apiPost('/api/music/generate', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/music/status/:task_id
|
||||||
|
// → { status, progress, message, audio_url?, error?, provider?, track? }
|
||||||
|
export function getMusicStatus(taskId) {
|
||||||
|
return apiGet(`/api/music/status/${encodeURIComponent(taskId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/lyrics body: { prompt }
|
||||||
|
// → { id, status, text } (Suno 가사 생성)
|
||||||
|
export function generateMusicLyrics(prompt) {
|
||||||
|
return apiPost('/api/music/lyrics', { prompt });
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/music/library
|
||||||
|
// → { tracks: [{ id, title, genre, ..., provider, lyrics, image_url, suno_id }] }
|
||||||
|
export function getMusicLibrary() {
|
||||||
|
return apiGet('/api/music/library');
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/library body: track object
|
||||||
|
// → saved track with id
|
||||||
|
export function saveMusicTrack(data) {
|
||||||
|
return apiPost('/api/music/library', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/music/library/:id
|
||||||
|
// → { ok: true }
|
||||||
|
export function deleteMusicTrack(id) {
|
||||||
|
return apiDelete(`/api/music/library/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/music/models → { models: [{ id, name, max_duration, description }] }
|
||||||
|
export function getMusicModels() {
|
||||||
|
return apiGet('/api/music/models');
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/music/credits → { remaining, total, ... }
|
||||||
|
export function getMusicCredits() {
|
||||||
|
return apiGet('/api/music/credits');
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/extend body: { suno_id, continue_at, prompt, style, title, model }
|
||||||
|
// → { task_id, provider }
|
||||||
|
export function extendMusicTrack(payload) {
|
||||||
|
return apiPost('/api/music/extend', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/vocal-removal body: { suno_id, title }
|
||||||
|
// → { task_id, provider }
|
||||||
|
export function removeVocals(payload) {
|
||||||
|
return apiPost('/api/music/vocal-removal', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 저장된 가사 CRUD ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/music/lyrics/library → { lyrics: [{ id, title, text, prompt, created_at, updated_at }] }
|
||||||
|
export function getSavedLyrics() {
|
||||||
|
return apiGet('/api/music/lyrics/library');
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/lyrics/library body: { title, text, prompt }
|
||||||
|
export function saveLyrics(data) {
|
||||||
|
return apiPost('/api/music/lyrics/library', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/music/lyrics/library/:id body: { title?, text?, prompt? }
|
||||||
|
export function updateLyrics(id, data) {
|
||||||
|
return apiPut(`/api/music/lyrics/library/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/music/lyrics/library/:id
|
||||||
|
export function deleteLyrics(id) {
|
||||||
|
return apiDelete(`/api/music/lyrics/library/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 1: 커버 이미지 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// POST /api/music/cover-image body: { suno_task_id, track_id }
|
||||||
|
export function generateCoverImage(payload) {
|
||||||
|
return apiPost('/api/music/cover-image', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 2 API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// POST /api/music/wav body: { suno_task_id, suno_id, track_id }
|
||||||
|
export function convertToWav(payload) {
|
||||||
|
return apiPost('/api/music/wav', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/stem-split body: { suno_task_id, suno_id, track_id }
|
||||||
|
export function splitStems(payload) {
|
||||||
|
return apiPost('/api/music/stem-split', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/music/timestamped-lyrics?task_id=...&suno_id=...
|
||||||
|
export function getTimestampedLyrics(taskId, sunoId) {
|
||||||
|
return apiGet(`/api/music/timestamped-lyrics?task_id=${encodeURIComponent(taskId)}&suno_id=${encodeURIComponent(sunoId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/style-boost body: { content }
|
||||||
|
export function generateStyleBoost(content) {
|
||||||
|
return apiPost('/api/music/style-boost', { content });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 3 API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// POST /api/music/upload-cover
|
||||||
|
export function uploadAndCover(payload) {
|
||||||
|
return apiPost('/api/music/upload-cover', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/upload-extend
|
||||||
|
export function uploadAndExtend(payload) {
|
||||||
|
return apiPost('/api/music/upload-extend', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/add-vocals
|
||||||
|
export function addVocals(payload) {
|
||||||
|
return apiPost('/api/music/add-vocals', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/add-instrumental
|
||||||
|
export function addInstrumental(payload) {
|
||||||
|
return apiPost('/api/music/add-instrumental', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/video
|
||||||
|
export function generateVideo(payload) {
|
||||||
|
return apiPost('/api/music/video', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 로또 고도화 API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/lotto/stats/performance
|
||||||
|
export function getPerformanceStats() {
|
||||||
|
return apiGet('/api/lotto/stats/performance');
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/lotto/report/latest
|
||||||
|
export function getLatestReport() {
|
||||||
|
return apiGet('/api/lotto/report/latest');
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/lotto/report/:drw_no
|
||||||
|
export function getReport(drwNo) {
|
||||||
|
return apiGet(`/api/lotto/report/${drwNo}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/lotto/report/history?limit=N
|
||||||
|
export function getReportHistory(limit = 10) {
|
||||||
|
return apiGet(`/api/lotto/report/history?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/lotto/analysis/personal
|
||||||
|
export function getPersonalAnalysis() {
|
||||||
|
return apiGet('/api/lotto/analysis/personal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 종합 추론 추천 ──────────────────────────────────────────────────────────
|
||||||
|
// GET /api/lotto/recommend/combined
|
||||||
|
export function getCombinedRecommend() {
|
||||||
|
return apiGet('/api/lotto/recommend/combined');
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/lotto/recommend/combined/history
|
||||||
|
export function getCombinedHistory(limit = 30) {
|
||||||
|
return apiGet(`/api/lotto/recommend/combined/history?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/lotto/purchase?draw_no=N&days=N
|
||||||
|
export function getPurchases({ draw_no, days } = {}) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (draw_no) qs.set('draw_no', String(draw_no));
|
||||||
|
if (days) qs.set('days', String(days));
|
||||||
|
const q = qs.toString();
|
||||||
|
return apiGet(`/api/lotto/purchase${q ? '?' + q : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/lotto/purchase/stats
|
||||||
|
export function getPurchaseStats() {
|
||||||
|
return apiGet('/api/lotto/purchase/stats');
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/lotto/purchase
|
||||||
|
export function addPurchase(data) {
|
||||||
|
return apiPost('/api/lotto/purchase', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/lotto/purchase/:id
|
||||||
|
export function updatePurchase(id, data) {
|
||||||
|
return apiPut(`/api/lotto/purchase/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/lotto/purchase/:id
|
||||||
|
export function deletePurchase(id) {
|
||||||
|
return apiDelete(`/api/lotto/purchase/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 블로그 API ────────────────────────────────────────────────────────────────
|
||||||
|
// GET /api/blog/posts → { posts: [{id, title, tags, body, date, excerpt}] }
|
||||||
|
// POST /api/blog/posts → 새 글 생성
|
||||||
|
// PUT /api/blog/posts/:id → 글 수정
|
||||||
|
// DELETE /api/blog/posts/:id → 글 삭제
|
||||||
|
|
||||||
|
export function getBlogPostsApi() {
|
||||||
|
return apiGet('/api/blog/posts');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBlogPost(data) {
|
||||||
|
return apiPost('/api/blog/posts', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBlogPost(id, data) {
|
||||||
|
return apiPut(`/api/blog/posts/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBlogPost(id) {
|
||||||
|
return apiDelete(`/api/blog/posts/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 블로그 마케팅 API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getBlogMarketingStatus() {
|
||||||
|
return apiGet('/api/blog-marketing/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startResearch(keyword) {
|
||||||
|
return apiPost('/api/blog-marketing/research', { keyword });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getResearchHistory(limit = 30) {
|
||||||
|
return apiGet(`/api/blog-marketing/research/history?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getResearchDetail(id) {
|
||||||
|
return apiGet(`/api/blog-marketing/research/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteResearch(id) {
|
||||||
|
return apiDelete(`/api/blog-marketing/research/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlogMarketingTask(taskId) {
|
||||||
|
return apiGet(`/api/blog-marketing/task/${encodeURIComponent(taskId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startGenerate(keywordId) {
|
||||||
|
return apiPost('/api/blog-marketing/generate', { keyword_id: keywordId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startReview(postId) {
|
||||||
|
return apiPost(`/api/blog-marketing/review/${postId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startRegenerate(postId) {
|
||||||
|
return apiPost(`/api/blog-marketing/regenerate/${postId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlogMarketingPosts(status, limit = 50) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (status) qs.set('status', status);
|
||||||
|
if (limit) qs.set('limit', String(limit));
|
||||||
|
const q = qs.toString();
|
||||||
|
return apiGet(`/api/blog-marketing/posts${q ? '?' + q : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlogMarketingPost(id) {
|
||||||
|
return apiGet(`/api/blog-marketing/posts/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBlogMarketingPost(id, data) {
|
||||||
|
return apiPut(`/api/blog-marketing/posts/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBlogMarketingPost(id) {
|
||||||
|
return apiDelete(`/api/blog-marketing/posts/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishBlogMarketingPost(id, naverUrl) {
|
||||||
|
return apiPost(`/api/blog-marketing/posts/${id}/publish`, { naver_url: naverUrl || '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlogMarketingCommissions(postId) {
|
||||||
|
const qs = postId ? `?post_id=${postId}` : '';
|
||||||
|
return apiGet(`/api/blog-marketing/commissions${qs}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addBlogMarketingCommission(data) {
|
||||||
|
return apiPost('/api/blog-marketing/commissions', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBlogMarketingCommission(id, data) {
|
||||||
|
return apiPut(`/api/blog-marketing/commissions/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBlogMarketingCommission(id) {
|
||||||
|
return apiDelete(`/api/blog-marketing/commissions/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlogMarketingDashboard() {
|
||||||
|
return apiGet('/api/blog-marketing/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마케터 단계
|
||||||
|
export function startMarket(postId) {
|
||||||
|
return apiPost(`/api/blog-marketing/market/${postId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 브랜드커넥트 링크 CRUD
|
||||||
|
export function getBrandLinks(params = {}) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.post_id) qs.set('post_id', String(params.post_id));
|
||||||
|
if (params.keyword_id) qs.set('keyword_id', String(params.keyword_id));
|
||||||
|
const q = qs.toString();
|
||||||
|
return apiGet(`/api/blog-marketing/links${q ? '?' + q : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBrandLink(data) {
|
||||||
|
return apiPost('/api/blog-marketing/links', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBrandLink(id, data) {
|
||||||
|
return apiPut(`/api/blog-marketing/links/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBrandLink(id) {
|
||||||
|
return apiDelete(`/api/blog-marketing/links/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agent Office ──────────────────────────────────
|
||||||
|
export const getAgents = () => apiGet('/api/agent-office/agents');
|
||||||
|
export const getAgentDetail = (id) => apiGet(`/api/agent-office/agents/${id}`);
|
||||||
|
export const updateAgentConfig = (id, body) => apiPut(`/api/agent-office/agents/${id}`, body);
|
||||||
|
export const getAgentTasks = (id, limit=20) => apiGet(`/api/agent-office/agents/${id}/tasks?limit=${limit}`);
|
||||||
|
export const getAgentLogs = (id, limit=50) => apiGet(`/api/agent-office/agents/${id}/logs?limit=${limit}`);
|
||||||
|
export const getPendingTasks = () => apiGet('/api/agent-office/tasks/pending');
|
||||||
|
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');
|
||||||
|
|
||||||
|
|||||||
106
src/components/FearGreedGauge.jsx
Normal file
106
src/components/FearGreedGauge.jsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const getFgColor = (score) => {
|
||||||
|
if (score <= 25) return '#ef4444';
|
||||||
|
if (score <= 45) return '#f97316';
|
||||||
|
if (score <= 55) return '#eab308';
|
||||||
|
if (score <= 75) return '#84cc16';
|
||||||
|
return '#22c55e';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFgLabel = (score) => {
|
||||||
|
if (score <= 25) return '극단적 공포';
|
||||||
|
if (score <= 45) return '공포';
|
||||||
|
if (score <= 55) return '중립';
|
||||||
|
if (score <= 75) return '탐욕';
|
||||||
|
return '극단적 탐욕';
|
||||||
|
};
|
||||||
|
|
||||||
|
const FG_LEVELS = [
|
||||||
|
{
|
||||||
|
range: '0 – 25',
|
||||||
|
label: '극단적 공포',
|
||||||
|
color: '#ef4444',
|
||||||
|
desc: '투자자들이 극도로 불안해하는 상태. 역사적으로 매수 기회가 되기도 하나, 하락세가 이어질 수 있습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
range: '26 – 45',
|
||||||
|
label: '공포',
|
||||||
|
color: '#f97316',
|
||||||
|
desc: '시장 심리가 위축된 상태. 불확실성이 높고, 매도 압력이 강합니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
range: '46 – 55',
|
||||||
|
label: '중립',
|
||||||
|
color: '#eab308',
|
||||||
|
desc: '공포와 탐욕이 균형을 이루는 상태. 뚜렷한 방향성 없이 관망세가 지속됩니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
range: '56 – 75',
|
||||||
|
label: '탐욕',
|
||||||
|
color: '#84cc16',
|
||||||
|
desc: '투자자들이 낙관적이고 시장에 적극 참여하는 상태. 과열 신호를 주의해야 합니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
range: '76 – 100',
|
||||||
|
label: '극단적 탐욕',
|
||||||
|
color: '#22c55e',
|
||||||
|
desc: '시장이 과열된 상태. 조정 가능성이 높아지므로 리스크 관리가 필요합니다.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fear & Greed 게이지 컴포넌트
|
||||||
|
* @param {{ score: number, date?: string, showLevels?: boolean }} props
|
||||||
|
*/
|
||||||
|
const FearGreedGauge = ({ score, date, showLevels = false }) => {
|
||||||
|
const color = getFgColor(score);
|
||||||
|
const label = getFgLabel(score);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fg-wrap">
|
||||||
|
<div className="fg-panel">
|
||||||
|
<div className="fg-score-display">
|
||||||
|
<span className="fg-score-number" style={{ color }}>{score}</span>
|
||||||
|
<span className="fg-score-label" style={{ color }}>{label}</span>
|
||||||
|
{date && <span className="fg-score-date">{date}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="fg-gauge">
|
||||||
|
<div className="fg-gauge__track">
|
||||||
|
<div
|
||||||
|
className="fg-gauge__needle"
|
||||||
|
style={{ left: `${Math.min(100, Math.max(0, score))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="fg-gauge__labels">
|
||||||
|
<span>극단적 공포</span>
|
||||||
|
<span>공포</span>
|
||||||
|
<span>중립</span>
|
||||||
|
<span>탐욕</span>
|
||||||
|
<span>극단적 탐욕</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showLevels && (
|
||||||
|
<div className="fg-levels">
|
||||||
|
{FG_LEVELS.map((lv) => (
|
||||||
|
<div
|
||||||
|
key={lv.label}
|
||||||
|
className={`fg-level${getFgLabel(score) === lv.label ? ' is-current' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="fg-level__head">
|
||||||
|
<span className="fg-level__dot" style={{ background: lv.color }} />
|
||||||
|
<span className="fg-level__label" style={{ color: lv.color }}>{lv.label}</span>
|
||||||
|
<span className="fg-level__range">{lv.range}</span>
|
||||||
|
</div>
|
||||||
|
<p className="fg-level__desc">{lv.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FearGreedGauge;
|
||||||
117
src/components/Icons.jsx
Normal file
117
src/components/Icons.jsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
const S = {
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeWidth: '1.6',
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
};
|
||||||
|
|
||||||
|
const svg = (children) => (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" {...S}>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconHome = () =>
|
||||||
|
svg(
|
||||||
|
<>
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||||
|
<polyline points="9,22 9,12 15,12 15,22" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconBlog = () =>
|
||||||
|
svg(
|
||||||
|
<>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconLotto = () =>
|
||||||
|
svg(
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<circle cx="8.5" cy="9.5" r="1.4" fill="currentColor" strokeWidth="0" />
|
||||||
|
<circle cx="15.5" cy="9.5" r="1.4" fill="currentColor" strokeWidth="0" />
|
||||||
|
<circle cx="8.5" cy="14.5" r="1.4" fill="currentColor" strokeWidth="0" />
|
||||||
|
<circle cx="15.5" cy="14.5" r="1.4" fill="currentColor" strokeWidth="0" />
|
||||||
|
<circle cx="12" cy="12" r="1.4" fill="currentColor" strokeWidth="0" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconStock = () =>
|
||||||
|
svg(
|
||||||
|
<>
|
||||||
|
<polyline points="22,7 13.5,15.5 8.5,10.5 2,17" />
|
||||||
|
<polyline points="16,7 22,7 22,13" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconTravel = () =>
|
||||||
|
svg(<polygon points="3,11 22,2 13,21 11,13 3,11" />);
|
||||||
|
|
||||||
|
export const IconMusic = () =>
|
||||||
|
svg(
|
||||||
|
<>
|
||||||
|
<path d="M9 18V5l12-2v13" />
|
||||||
|
<circle cx="6" cy="18" r="3" />
|
||||||
|
<circle cx="18" cy="16" r="3" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconLab = () =>
|
||||||
|
svg(
|
||||||
|
<>
|
||||||
|
<line x1="9" y1="3" x2="15" y2="3" />
|
||||||
|
<path d="M10 3v6.5L5.5 17.5A2 2 0 0 0 7.3 20h9.4a2 2 0 0 0 1.8-2.5L14 9.5V3" />
|
||||||
|
<line x1="6.5" y1="15" x2="17.5" y2="15" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconTodo = () =>
|
||||||
|
svg(
|
||||||
|
<>
|
||||||
|
<rect x="3" y="5" width="6" height="6" rx="1" />
|
||||||
|
<polyline points="9,8 11,10 15,6" />
|
||||||
|
<rect x="3" y="13" width="6" height="6" rx="1" />
|
||||||
|
<line x1="13" y1="16" x2="21" y2="16" />
|
||||||
|
<line x1="13" y1="8" x2="21" y2="8" />
|
||||||
|
<line x1="17" y1="12" x2="21" y2="12" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconSubscription = () =>
|
||||||
|
svg(
|
||||||
|
<>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14,2 14,8 20,8" />
|
||||||
|
<polyline points="9,15 11,17 15,13" />
|
||||||
|
<line x1="9" y1="10" x2="15" y2="10" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconBlogMarketing = () =>
|
||||||
|
svg(
|
||||||
|
<>
|
||||||
|
<path d="M4 4h16v16H4z" />
|
||||||
|
<path d="M8 8h8" />
|
||||||
|
<path d="M8 12h5" />
|
||||||
|
<circle cx="17" cy="15" r="2.5" fill="currentColor" strokeWidth="0" />
|
||||||
|
<path d="M15.5 13l3 4" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconBuilding = () =>
|
||||||
|
svg(
|
||||||
|
<>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<path d="M9 21V9" />
|
||||||
|
<rect x="6" y="6" width="3" height="3" />
|
||||||
|
<rect x="11" y="6" width="3" height="3" />
|
||||||
|
<rect x="16" y="6" width="2" height="3" />
|
||||||
|
<rect x="11" y="11" width="3" height="3" />
|
||||||
|
<rect x="16" y="11" width="2" height="3" />
|
||||||
|
<rect x="11" y="16" width="3" height="3" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
@@ -8,49 +8,58 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.loading-spinner__circle {
|
.loading-spinner__circle {
|
||||||
width: 32px;
|
width: 28px;
|
||||||
height: 32px;
|
height: 28px;
|
||||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
border: 2px solid rgba(255, 255, 255, 0.08);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border-top-color: var(--accent, #f7a8a5);
|
border-top-color: var(--accent, #f7a8a5);
|
||||||
animation: spin 0.8s linear infinite;
|
animation: loading-spin 0.75s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-spinner__text {
|
.loading-spinner__text {
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
color: var(--muted, #b6b1a9);
|
color: var(--muted, #9b9490);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes loading-spin {
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Skeleton ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.loading-skeleton {
|
.loading-skeleton {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
padding: 16px;
|
padding: 4px 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-skeleton__line {
|
.loading-skeleton__line {
|
||||||
height: 16px;
|
height: 14px;
|
||||||
border-radius: 4px;
|
border-radius: 7px;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
rgba(255, 255, 255, 0.05) 25%,
|
rgba(255, 255, 255, 0.04) 0%,
|
||||||
rgba(255, 255, 255, 0.1) 50%,
|
rgba(255, 255, 255, 0.09) 40%,
|
||||||
rgba(255, 255, 255, 0.05) 75%
|
rgba(255, 255, 255, 0.04) 80%
|
||||||
);
|
);
|
||||||
background-size: 200% 100%;
|
background-size: 300% 100%;
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
animation: loading-shimmer 1.8s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
.loading-skeleton__line:nth-child(1) { width: 65%; }
|
||||||
|
.loading-skeleton__line:nth-child(2) { width: 85%; animation-delay: 0.1s; }
|
||||||
|
.loading-skeleton__line:nth-child(3) { width: 50%; animation-delay: 0.2s; }
|
||||||
|
.loading-skeleton__line:nth-child(4) { width: 75%; animation-delay: 0.15s; }
|
||||||
|
.loading-skeleton__line:nth-child(5) { width: 60%; animation-delay: 0.25s; }
|
||||||
|
|
||||||
|
@keyframes loading-shimmer {
|
||||||
0% {
|
0% {
|
||||||
background-position: 200% 0;
|
background-position: 100% 0;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
background-position: -200% 0;
|
background-position: -200% 0;
|
||||||
|
|||||||
@@ -1,126 +1,359 @@
|
|||||||
.site-nav {
|
|
||||||
position: sticky;
|
/* ── 사이드바 본체 ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
bottom: 0;
|
||||||
background: rgba(16, 16, 24, 0.82);
|
width: var(--sidebar-w);
|
||||||
backdrop-filter: blur(10px);
|
z-index: 200;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-nav__inner {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 18px 20px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
background: rgba(7, 12, 28, 0.92);
|
||||||
gap: 16px;
|
backdrop-filter: blur(20px) saturate(1.5);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(1.5);
|
||||||
|
border-right: 1px solid rgba(0, 212, 255, 0.08);
|
||||||
|
box-shadow: 4px 0 40px rgba(0, 0, 0, 0.5), 1px 0 0 rgba(0, 212, 255, 0.05);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-nav__brand {
|
/* ── 브랜드 섹션 ─────────────────────────────────────────────────────── */
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-nav__logo-image {
|
.sidebar__brand {
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
|
||||||
border-radius: 14px;
|
|
||||||
object-fit: cover;
|
|
||||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-nav__logo {
|
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
|
||||||
border-radius: 14px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 20px;
|
|
||||||
color: #1b1a24;
|
|
||||||
background: linear-gradient(135deg, #fdd4b1, #f7a8a5);
|
|
||||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-nav__title {
|
|
||||||
margin: 0;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-nav__subtitle {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-nav__links {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex-wrap: wrap;
|
padding: 20px 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-nav__link {
|
.sidebar__logo {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 12px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(0, 212, 255, 0.2),
|
||||||
|
0 0 12px rgba(0, 212, 255, 0.15),
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__brand-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__brand-name {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Space Grotesk', 'Manrope', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-bright);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__brand-sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 구분선 ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sidebar__divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--line, rgba(255, 255, 255, 0.1));
|
||||||
|
margin: 8px 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 네비게이션 ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sidebar__nav {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 4px 0;
|
||||||
|
/* 스크롤바 숨김 */
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__nav::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__section-label {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 24px 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 네비게이션 아이템 ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sidebar__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-sm, 12px);
|
||||||
|
margin: 2px 10px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
color: var(--text-dim);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
letter-spacing: 0.02em;
|
font-weight: 500;
|
||||||
color: var(--text);
|
font-family: var(--font-body, 'Manrope', sans-serif);
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
transition: all 0.2s ease;
|
position: relative;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-nav__link:hover {
|
.sidebar__item:hover {
|
||||||
border-color: rgba(255, 255, 255, 0.18);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
color: var(--text, #f0ebe4);
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-nav__link.is-active {
|
/* 활성 아이템 */
|
||||||
border-color: rgba(247, 168, 165, 0.6);
|
.sidebar__item.is-active {
|
||||||
background: rgba(247, 168, 165, 0.16);
|
background: linear-gradient(90deg, rgba(0, 212, 255, 0.12) 0%, rgba(0, 212, 255, 0.04) 100%);
|
||||||
color: #ffe9e2;
|
border-color: rgba(0, 212, 255, 0.2);
|
||||||
|
color: var(--text-bright);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
/* 활성 아이템 좌측 네온 바 */
|
||||||
.site-nav__inner {
|
.sidebar__item.is-active::before {
|
||||||
flex-direction: column;
|
content: '';
|
||||||
align-items: flex-start;
|
position: absolute;
|
||||||
}
|
left: 0;
|
||||||
|
top: 20%;
|
||||||
|
bottom: 20%;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--neon-cyan);
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
box-shadow: 0 0 8px var(--neon-cyan), 0 0 16px rgba(0, 212, 255, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 아이콘 ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sidebar__item-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: inherit;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__item.is-active .sidebar__item-icon {
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 라벨 ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sidebar__item-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 도트 인디케이터 ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sidebar__item-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--neon-cyan);
|
||||||
|
box-shadow: 0 0 6px var(--neon-cyan);
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__item.is-active .sidebar__item-dot {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 사이드바 푸터 ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sidebar__footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__footer-content {
|
||||||
|
padding: 12px 16px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__status-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #34d399;
|
||||||
|
box-shadow: 0 0 6px rgba(52, 211, 153, 0.8), 0 0 12px rgba(52, 211, 153, 0.4);
|
||||||
|
flex-shrink: 0;
|
||||||
|
animation: pulse-dot 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%, 100% { opacity: 1; box-shadow: 0 0 6px rgba(52, 211, 153, 0.8), 0 0 12px rgba(52, 211, 153, 0.4); }
|
||||||
|
50% { opacity: 0.7; box-shadow: 0 0 3px rgba(52, 211, 153, 0.5), 0 0 6px rgba(52, 211, 153, 0.2); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__status-text {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__version {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 모바일 토글 버튼 ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sidebar-toggle {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
z-index: 201;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(7, 12, 28, 0.88);
|
||||||
|
backdrop-filter: blur(12px) saturate(1.4);
|
||||||
|
-webkit-backdrop-filter: blur(12px) saturate(1.4);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle:hover {
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
|
border-color: rgba(0, 212, 255, 0.25);
|
||||||
|
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.4), 0 0 8px rgba(0, 212, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle__icon {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle__icon span {
|
||||||
|
display: block;
|
||||||
|
width: 16px;
|
||||||
|
height: 1.5px;
|
||||||
|
background: var(--text-bright, #ffffff);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
opacity 0.28s ease,
|
||||||
|
width 0.28s ease;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle__icon.is-open span:nth-child(1) {
|
||||||
|
transform: translateY(6.5px) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle__icon.is-open span:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle__icon.is-open span:nth-child(3) {
|
||||||
|
transform: translateY(-6.5px) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 오버레이 ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sidebar__overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 199;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__overlay.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 모바일 반응형 ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.site-nav__inner {
|
.sidebar {
|
||||||
padding: 14px 16px;
|
transform: translateX(-100%);
|
||||||
gap: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-nav__brand {
|
.sidebar.is-open {
|
||||||
gap: 10px;
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-nav__logo-image {
|
.sidebar-toggle {
|
||||||
width: 36px;
|
display: flex;
|
||||||
height: 36px;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-nav__title {
|
/* ── 데스크톱: 토글 버튼 숨김 ────────────────────────────────────────── */
|
||||||
font-size: 14px;
|
|
||||||
}
|
@media (min-width: 769px) {
|
||||||
|
.sidebar-toggle {
|
||||||
.site-nav__subtitle {
|
display: none;
|
||||||
font-size: 11px;
|
}
|
||||||
}
|
|
||||||
|
.sidebar__overlay {
|
||||||
.site-nav__links {
|
display: none;
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-nav__link {
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,92 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { navLinks } from '../routes.jsx';
|
import { navLinks } from '../routes.jsx';
|
||||||
import mainLogo from '../assets/main_logo.png';
|
import mainLogo from '../assets/main_logo.png';
|
||||||
import './Navbar.css';
|
import './Navbar.css';
|
||||||
|
|
||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const closeMenu = () => setMenuOpen(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.overflow = menuOpen ? 'hidden' : '';
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [menuOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="site-nav">
|
<>
|
||||||
<div className="site-nav__inner">
|
{/* 모바일 오버레이 */}
|
||||||
<div className="site-nav__brand">
|
<div
|
||||||
<img src={mainLogo} alt="Logo" className="site-nav__logo-image" />
|
className={`sidebar__overlay${menuOpen ? ' is-visible' : ''}`}
|
||||||
<div>
|
onClick={closeMenu}
|
||||||
<p className="site-nav__title">Jaeoh Archive</p>
|
aria-hidden="true"
|
||||||
<p className="site-nav__subtitle">Stories, notes, and snapshots</p>
|
/>
|
||||||
|
|
||||||
|
{/* 모바일 토글 버튼 */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 사이드바 본체 */}
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="site-nav__links">
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="sidebar__divider" />
|
||||||
|
|
||||||
|
{/* 네비게이션 */}
|
||||||
|
<nav className="sidebar__nav">
|
||||||
|
<p className="sidebar__section-label">NAVIGATION</p>
|
||||||
{navLinks.map((link) => (
|
{navLinks.map((link) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={link.id}
|
key={link.id}
|
||||||
to={link.path}
|
to={link.path}
|
||||||
|
onClick={closeMenu}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`site-nav__link${isActive ? ' is-active' : ''}`
|
`sidebar__item${isActive ? ' is-active' : ''}`
|
||||||
}
|
}
|
||||||
|
style={{ '--item-accent': link.accent }}
|
||||||
|
end={link.path === '/'}
|
||||||
>
|
>
|
||||||
{link.label}
|
<span className="sidebar__item-icon">{link.icon}</span>
|
||||||
|
<span className="sidebar__item-label">{link.label}</span>
|
||||||
|
<span className="sidebar__item-dot" />
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
|
||||||
</header>
|
{/* 사이드바 푸터 */}
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
67
src/components/PageHeader.css
Normal file
67
src/components/PageHeader.css
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/* ── PageHeader ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
padding: 0 0 20px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.28em;
|
||||||
|
color: var(--page-accent, var(--neon-cyan));
|
||||||
|
font-family: var(--font-display, 'Space Grotesk', sans-serif);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__subtitle::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 1.5px;
|
||||||
|
background: var(--page-accent, var(--neon-cyan));
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 0 6px var(--page-accent, var(--neon-cyan));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(22px, 3vw, 32px);
|
||||||
|
font-weight: 800;
|
||||||
|
font-family: var(--font-display, 'Space Grotesk', sans-serif);
|
||||||
|
color: var(--text-bright, #fff);
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__line {
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--page-accent, var(--neon-cyan)) 0%,
|
||||||
|
transparent 60%
|
||||||
|
);
|
||||||
|
margin-top: 14px;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-header {
|
||||||
|
padding: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__title {
|
||||||
|
font-size: clamp(18px, 5vw, 24px);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/components/PageHeader.jsx
Normal file
31
src/components/PageHeader.jsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { navLinks } from '../routes.jsx';
|
||||||
|
import './PageHeader.css';
|
||||||
|
|
||||||
|
const PageHeader = () => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
// Home 페이지에서는 Hero 섹션이 있으므로 숨김
|
||||||
|
if (pathname === '/') return null;
|
||||||
|
|
||||||
|
// stock/trade 같은 하위 경로도 stock로 매칭
|
||||||
|
const current = navLinks.find((link) => {
|
||||||
|
if (link.path === '/') return false;
|
||||||
|
return pathname === link.path || pathname.startsWith(link.path + '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!current) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="page-header" style={{ '--page-accent': current.accent }}>
|
||||||
|
<div className="page-header__inner">
|
||||||
|
<p className="page-header__subtitle">{current.subtitle}</p>
|
||||||
|
<h1 className="page-header__title">{current.label}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="page-header__line" />
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PageHeader;
|
||||||
83
src/data/heroConfig.js
Normal file
83
src/data/heroConfig.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* 홈 히어로 카드 월별 테마 설정
|
||||||
|
* 매달 month, theme, desc, nextUpdate 를 수정해 적용하세요.
|
||||||
|
*/
|
||||||
|
export const MONTHLY_THEMES = [
|
||||||
|
{
|
||||||
|
month: 1,
|
||||||
|
theme: '새해 목표 설정',
|
||||||
|
desc: '연초를 맞아 올해 개발·기록 목표를 구체적으로 정리하고 실행 계획을 세웁니다.',
|
||||||
|
nextUpdate: '매주 일요일',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
month: 2,
|
||||||
|
theme: '코드 품질 개선',
|
||||||
|
desc: '리팩토링과 테스트 커버리지 향상에 집중합니다. 작은 개선도 꾸준히 쌓아갑니다.',
|
||||||
|
nextUpdate: '매주 토요일',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
month: 3,
|
||||||
|
theme: '웹 UI 고도화',
|
||||||
|
desc: '대시보드 형태의 UI를 사이버펑크 스타일로 전면 개편하고, 새 기능을 추가합니다.',
|
||||||
|
nextUpdate: '이번 주말',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
month: 4,
|
||||||
|
theme: '백엔드 성능 최적화',
|
||||||
|
desc: 'API 응답 속도와 데이터베이스 쿼리를 분석하고 병목을 개선하는 달입니다.',
|
||||||
|
nextUpdate: '이번 주말',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
month: 5,
|
||||||
|
theme: '인프라 자동화',
|
||||||
|
desc: 'Docker/Kubernetes 파이프라인을 정비하고 배포 자동화를 강화합니다.',
|
||||||
|
nextUpdate: '격주 일요일',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
month: 6,
|
||||||
|
theme: '여름 사이드 프로젝트',
|
||||||
|
desc: '새로운 기술 스택을 탐구하며 소규모 실험 프로젝트를 진행합니다.',
|
||||||
|
nextUpdate: '매주 금요일',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
month: 7,
|
||||||
|
theme: '기록과 문서화',
|
||||||
|
desc: '그동안 미뤄뒀던 개발 노트와 블로그 글 작성에 집중합니다.',
|
||||||
|
nextUpdate: '매주 화요일',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
month: 8,
|
||||||
|
theme: '보안 점검',
|
||||||
|
desc: '서비스 취약점을 점검하고 인증·인가 로직을 강화합니다.',
|
||||||
|
nextUpdate: '격주 토요일',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
month: 9,
|
||||||
|
theme: '모니터링 강화',
|
||||||
|
desc: '로그 수집과 알림 파이프라인을 개선해 운영 가시성을 높입니다.',
|
||||||
|
nextUpdate: '이번 주말',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
month: 10,
|
||||||
|
theme: '오픈소스 기여',
|
||||||
|
desc: '사용 중인 라이브러리에 이슈를 제보하거나 PR을 올려봅니다.',
|
||||||
|
nextUpdate: '매주 목요일',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
month: 11,
|
||||||
|
theme: '연말 회고 준비',
|
||||||
|
desc: '올 한 해의 개발 성과를 정리하고 내년 로드맵 초안을 그립니다.',
|
||||||
|
nextUpdate: '매주 일요일',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
month: 12,
|
||||||
|
theme: '느린 기록, 깊은 회고',
|
||||||
|
desc: '빠르게 달려온 한 해를 천천히 돌아보며 가장 의미 있었던 작업을 기록합니다.',
|
||||||
|
nextUpdate: '크리스마스 주간',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getCurrentTheme() {
|
||||||
|
const month = new Date().getMonth() + 1;
|
||||||
|
return MONTHLY_THEMES.find((t) => t.month === month) ?? MONTHLY_THEMES[0];
|
||||||
|
}
|
||||||
239
src/index.css
239
src/index.css
@@ -1,35 +1,244 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Manrope:wght@300;400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
* {
|
/* ── Reset ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Design Tokens ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ── Background Surfaces ─────────────────────────────────────────── */
|
||||||
|
--bg: #070b19;
|
||||||
|
--bg-secondary: #0a0f23;
|
||||||
|
--bg-tertiary: #0d1530;
|
||||||
|
|
||||||
|
/* ── Glass Surfaces ──────────────────────────────────────────────── */
|
||||||
|
--surface: rgba(10, 18, 45, 0.8);
|
||||||
|
--surface-raised: rgba(14, 24, 58, 0.9);
|
||||||
|
--surface-card: rgba(255, 255, 255, 0.03);
|
||||||
|
|
||||||
|
/* ── Neon Cyan ───────────────────────────────────────────────────── */
|
||||||
|
--neon-cyan: #00d4ff;
|
||||||
|
--neon-cyan-dim: rgba(0, 212, 255, 0.6);
|
||||||
|
--neon-cyan-muted: rgba(0, 212, 255, 0.12);
|
||||||
|
|
||||||
|
/* ── Neon Purple ─────────────────────────────────────────────────── */
|
||||||
|
--neon-purple: #8b5cf6;
|
||||||
|
--neon-purple-dim: rgba(139, 92, 246, 0.6);
|
||||||
|
--neon-purple-muted: rgba(139, 92, 246, 0.12);
|
||||||
|
|
||||||
|
/* ── Gradients ───────────────────────────────────────────────────── */
|
||||||
|
--grad-accent: linear-gradient(135deg, #00d4ff 0%, #8b5cf6 100%);
|
||||||
|
--grad-accent-subtle: linear-gradient(135deg, rgba(0, 212, 255, 0.12) 0%, rgba(139, 92, 246, 0.12) 100%);
|
||||||
|
--grad-bg-radial: radial-gradient(ellipse 120% 80% at 20% 0%, rgba(0, 212, 255, 0.06) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 100% 70% at 80% 10%, rgba(139, 92, 246, 0.05) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 80% 60% at 50% 80%, rgba(0, 100, 180, 0.04) 0%, transparent 70%);
|
||||||
|
|
||||||
|
/* ── Text ────────────────────────────────────────────────────────── */
|
||||||
|
--text: #ccd6f6;
|
||||||
|
--text-bright: #e8f0fe;
|
||||||
|
--text-dim: #8892b0;
|
||||||
|
--text-muted: #4a5572;
|
||||||
|
|
||||||
|
/* ── Borders ─────────────────────────────────────────────────────── */
|
||||||
|
--line: rgba(255, 255, 255, 0.07);
|
||||||
|
--line-bright: rgba(0, 212, 255, 0.25);
|
||||||
|
--line-subtle: rgba(255, 255, 255, 0.04);
|
||||||
|
|
||||||
|
/* ── Glow Effects ────────────────────────────────────────────────── */
|
||||||
|
--glow-cyan: 0 0 20px rgba(0, 212, 255, 0.25), 0 0 60px rgba(0, 212, 255, 0.08);
|
||||||
|
--glow-purple: 0 0 20px rgba(139, 92, 246, 0.25), 0 0 60px rgba(139, 92, 246, 0.08);
|
||||||
|
--glow-active: 0 0 30px rgba(0, 212, 255, 0.2), 0 2px 0 rgba(0, 212, 255, 0.4);
|
||||||
|
|
||||||
|
/* ── Shadows ─────────────────────────────────────────────────────── */
|
||||||
|
--shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-md: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-lg: 0 24px 64px rgba(0, 0, 0, 0.65);
|
||||||
|
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.45), 0 1px 0 rgba(255, 255, 255, 0.04) inset;
|
||||||
|
|
||||||
|
/* ── Border Radii ────────────────────────────────────────────────── */
|
||||||
|
--radius-xs: 6px;
|
||||||
|
--radius-sm: 10px;
|
||||||
|
--radius-md: 14px;
|
||||||
|
--radius-lg: 20px;
|
||||||
|
--radius-xl: 28px;
|
||||||
|
|
||||||
|
/* ── Layout ──────────────────────────────────────────────────────── */
|
||||||
|
--sidebar-w: 240px;
|
||||||
|
--topbar-h: 56px;
|
||||||
|
|
||||||
|
/* ── Typography ──────────────────────────────────────────────────── */
|
||||||
|
--font-display: 'Space Grotesk', 'Noto Sans KR', system-ui, serif;
|
||||||
|
--font-body: 'Inter', 'Noto Sans KR', system-ui, sans-serif;
|
||||||
|
|
||||||
|
/* ── Easing ──────────────────────────────────────────────────────── */
|
||||||
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
|
||||||
|
/* ── Page Accent Colors ──────────────────────────────────────────── */
|
||||||
|
--accent-home: #00d4ff;
|
||||||
|
--accent-blog: #c084fc;
|
||||||
|
--accent-lotto: #34d399;
|
||||||
|
--accent-stock: #38bdf8;
|
||||||
|
--accent-realestate: #f43f5e;
|
||||||
|
--accent-subscription: #f43f5e;
|
||||||
|
--accent-todo: #f472b6;
|
||||||
|
--accent-travel: #fb923c;
|
||||||
|
--accent-lab: #fbbf24;
|
||||||
|
|
||||||
|
/* ── Convenience alias ───────────────────────────────────────────── */
|
||||||
|
--accent: var(--neon-cyan);
|
||||||
|
|
||||||
|
/* ── Legacy / Backward-compat aliases ───────────────────────────── */
|
||||||
|
--muted: var(--text-dim);
|
||||||
|
--fg: var(--text-bright);
|
||||||
|
--surface-hover: var(--surface-raised);
|
||||||
|
--line-strong: var(--line-bright);
|
||||||
|
--accent-strong: var(--neon-purple);
|
||||||
|
--shadow-inset: 0 1px 0 rgba(255, 255, 255, 0.04) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Base Document ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
text-size-adjust: 100%;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
overflow: hidden;
|
||||||
|
background-color: var(--bg);
|
||||||
body {
|
background-image: var(--grad-bg-radial);
|
||||||
margin: 0;
|
|
||||||
background: radial-gradient(2000px 1200px at 15% 5%, rgba(247, 168, 165, 0.25), transparent 70%),
|
|
||||||
radial-gradient(1600px 1200px at 85% 0%, rgba(253, 212, 177, 0.18), transparent 70%),
|
|
||||||
radial-gradient(1500px 800px at 50% 50%, rgba(151, 201, 170, 0.1), transparent 80%),
|
|
||||||
#0f0d12;
|
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.65;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
/* ── Scrollbar ───────────────────────────────────────────────────────── */
|
||||||
body {
|
|
||||||
background-attachment: scroll;
|
::-webkit-scrollbar {
|
||||||
}
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 212, 255, 0.22);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(0, 212, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(0, 212, 255, 0.22) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Selection ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgba(0, 212, 255, 0.2);
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Focus ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 1.5px solid rgba(0, 212, 255, 0.8);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Typography ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-bright);
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Images ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
img {
|
img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form Elements ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
border-color: var(--neon-cyan-dim);
|
||||||
|
box-shadow: 0 0 0 3px var(--neon-cyan-muted);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
select option {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive Mobile Override ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body {
|
||||||
|
overflow: auto;
|
||||||
|
background-attachment: scroll;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
331
src/pages/agent-office/AgentOffice.css
Normal file
331
src/pages/agent-office/AgentOffice.css
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
.ao-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: #0d0d1a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-bottom: 1px solid #2a2a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-title {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: #8b5cf6;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.ao-dot--on { background: #34d399; }
|
||||||
|
.ao-dot--off { background: #f87171; }
|
||||||
|
|
||||||
|
.ao-workspace {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-canvas-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
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-history-item:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; }
|
||||||
85
src/pages/agent-office/AgentOffice.jsx
Normal file
85
src/pages/agent-office/AgentOffice.jsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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 function Component() {
|
||||||
|
const canvasContainerRef = useRef(null);
|
||||||
|
const [selectedAgent, setSelectedAgent] = useState(null);
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
for (const [id, info] of Object.entries(agents)) {
|
||||||
|
updateAgentState(id, info.state, info.detail);
|
||||||
|
}
|
||||||
|
}, [agents, updateAgentState]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ao-page">
|
||||||
|
<div className="ao-header">
|
||||||
|
<h1 className="ao-title">Agent Office</h1>
|
||||||
|
<div className="ao-status">
|
||||||
|
<span className={`ao-dot ${connected ? 'ao-dot--on' : 'ao-dot--off'}`} />
|
||||||
|
{connected ? 'Connected' : 'Disconnected'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ao-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 && (
|
||||||
|
<ChatPanel
|
||||||
|
agentId={selectedAgent}
|
||||||
|
agentState={agents[selectedAgent]}
|
||||||
|
onCommand={sendCommand}
|
||||||
|
onApproval={sendApproval}
|
||||||
|
onClose={() => setSelectedAgent(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/pages/agent-office/assets/office-map.json
Normal file
45
src/pages/agent-office/assets/office-map.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"tileSize": 32,
|
||||||
|
"cols": 20,
|
||||||
|
"rows": 14,
|
||||||
|
"layers": {
|
||||||
|
"floor": [
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"furniture": [
|
||||||
|
{"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": {
|
||||||
|
"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}
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"1": "#3a3a50",
|
||||||
|
"2": "#4a3a2a"
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/pages/agent-office/canvas/AgentSprite.js
Normal file
84
src/pages/agent-office/canvas/AgentSprite.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { drawAgent, getAnimSpeed } from './SpriteSheet';
|
||||||
|
|
||||||
|
export class AgentSprite {
|
||||||
|
constructor(agentId, waypoints) {
|
||||||
|
this.agentId = agentId;
|
||||||
|
this.waypoints = waypoints;
|
||||||
|
this.state = 'idle';
|
||||||
|
this.detail = '';
|
||||||
|
|
||||||
|
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.frameIndex = 0;
|
||||||
|
this._lastFrameTime = 0;
|
||||||
|
this._moveSpeed = 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(newState, detail = '') {
|
||||||
|
this.state = newState;
|
||||||
|
this.detail = detail;
|
||||||
|
this.frameIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
moveTo(target) {
|
||||||
|
const wp = this.waypoints[target];
|
||||||
|
if (wp) {
|
||||||
|
this.targetX = wp.x;
|
||||||
|
this.targetY = wp.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
moveToDesk() {
|
||||||
|
this.targetX = this.deskPos.x;
|
||||||
|
this.targetY = this.deskPos.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(now) {
|
||||||
|
const speed = getAnimSpeed(this.state);
|
||||||
|
if (now - this._lastFrameTime > speed) {
|
||||||
|
this.frameIndex++;
|
||||||
|
this._lastFrameTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
return canvasX >= cx - hitW && canvasX <= cx + hitW &&
|
||||||
|
canvasY >= cy - hitH && canvasY <= cy + hitH;
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/pages/agent-office/canvas/OfficeRenderer.js
Normal file
129
src/pages/agent-office/canvas/OfficeRenderer.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { drawTileMap } from './TileMap';
|
||||||
|
import { AgentSprite } from './AgentSprite';
|
||||||
|
import { getCharLabel } from './SpriteSheet';
|
||||||
|
|
||||||
|
const STATUS_ICONS = {
|
||||||
|
idle: null,
|
||||||
|
working: null,
|
||||||
|
waiting: '❗',
|
||||||
|
reporting: '📋',
|
||||||
|
break: '☕',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class OfficeRenderer {
|
||||||
|
constructor(canvas, mapData) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d');
|
||||||
|
this.mapData = mapData;
|
||||||
|
this.renderInfo = null;
|
||||||
|
this.agents = {};
|
||||||
|
this._animId = null;
|
||||||
|
this._onClick = null;
|
||||||
|
|
||||||
|
const agentIds = ['stock', 'music'];
|
||||||
|
for (const id of agentIds) {
|
||||||
|
this.agents[id] = new AgentSprite(id, mapData.waypoints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this._loop = this._loop.bind(this);
|
||||||
|
this._animId = requestAnimationFrame(this._loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this._animId) {
|
||||||
|
cancelAnimationFrame(this._animId);
|
||||||
|
this._animId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { ctx, canvas, mapData } = this;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.fillStyle = '#1a1a2e';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawOverlay(ctx, sprite, agentId) {
|
||||||
|
if (!this.renderInfo) return;
|
||||||
|
const { scale, offsetX, offsetY, tileSize } = this.renderInfo;
|
||||||
|
const cx = offsetX + sprite.x * tileSize * scale + (tileSize * scale) / 2;
|
||||||
|
const cy = offsetY + sprite.y * tileSize * scale - 10 * scale;
|
||||||
|
|
||||||
|
const icon = STATUS_ICONS[sprite.state];
|
||||||
|
if (icon) {
|
||||||
|
ctx.font = `${14 * scale}px serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(icon, cx, cy - 15 * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
90
src/pages/agent-office/canvas/TileMap.js
Normal file
90
src/pages/agent-office/canvas/TileMap.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
export function drawTileMap(ctx, mapData, width, height) {
|
||||||
|
const { tileSize, cols, rows, layers, furniture, colors } = mapData;
|
||||||
|
const scaleX = width / (cols * tileSize);
|
||||||
|
const scaleY = height / (rows * tileSize);
|
||||||
|
const scale = Math.min(scaleX, scaleY);
|
||||||
|
|
||||||
|
const offsetX = (width - cols * tileSize * scale) / 2;
|
||||||
|
const offsetY = (height - rows * tileSize * scale) / 2;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(offsetX, offsetY);
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
|
||||||
|
const floor = layers.floor;
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const tile = floor[r][c];
|
||||||
|
ctx.fillStyle = colors[String(tile)] || '#3a3a50';
|
||||||
|
ctx.fillRect(c * tileSize, r * tileSize, tileSize, tileSize);
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
|
||||||
|
ctx.strokeRect(c * tileSize, r * tileSize, tileSize, tileSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
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;
|
||||||
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;
|
||||||
88
src/pages/agent-office/hooks/useAgentManager.js
Normal file
88
src/pages/agent-office/hooks/useAgentManager.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
export function useAgentManager() {
|
||||||
|
const [agents, setAgents] = useState({});
|
||||||
|
const [pendingTasks, setPendingTasks] = useState([]);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const wsRef = useRef(null);
|
||||||
|
const reconnectTimer = useRef(null);
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/api/agent-office/ws`;
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setConnected(true);
|
||||||
|
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
setAgents(agentMap);
|
||||||
|
setPendingTasks(msg.pending || []);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'agent_state':
|
||||||
|
setAgents(prev => ({
|
||||||
|
...prev,
|
||||||
|
[msg.agent]: { state: msg.state, detail: msg.detail, taskId: msg.task_id },
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
case 'task_complete':
|
||||||
|
setAgents(prev => ({
|
||||||
|
...prev,
|
||||||
|
[msg.agent]: { ...prev[msg.agent], lastResult: msg.result },
|
||||||
|
}));
|
||||||
|
setPendingTasks(prev => prev.filter(id => id !== msg.task_id));
|
||||||
|
break;
|
||||||
|
case 'command_result':
|
||||||
|
setAgents(prev => ({
|
||||||
|
...prev,
|
||||||
|
[msg.agent]: { ...prev[msg.agent], lastCommand: msg.result },
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
connect();
|
||||||
|
return () => {
|
||||||
|
if (wsRef.current) wsRef.current.close();
|
||||||
|
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||||
|
};
|
||||||
|
}, [connect]);
|
||||||
|
|
||||||
|
const sendCommand = useCallback((agent, action, params = {}) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify({ type: 'command', agent, action, params }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendApproval = useCallback((agent, taskId, approved) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify({ type: 'approval', agent, task_id: taskId, approved }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { agents, pendingTasks, connected, sendCommand, sendApproval };
|
||||||
|
}
|
||||||
62
src/pages/agent-office/hooks/useOfficeCanvas.js
Normal file
62
src/pages/agent-office/hooks/useOfficeCanvas.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { OfficeRenderer } from '../canvas/OfficeRenderer';
|
||||||
|
import officeMap from '../assets/office-map.json';
|
||||||
|
|
||||||
|
export function useOfficeCanvas(containerRef, onAgentClick) {
|
||||||
|
const rendererRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.style.display = 'block';
|
||||||
|
canvas.style.width = '100%';
|
||||||
|
canvas.style.height = '100%';
|
||||||
|
canvas.style.imageRendering = 'pixelated';
|
||||||
|
containerRef.current.appendChild(canvas);
|
||||||
|
|
||||||
|
const renderer = new OfficeRenderer(canvas, officeMap);
|
||||||
|
rendererRef.current = renderer;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
renderer.resize(rect.width, rect.height);
|
||||||
|
};
|
||||||
|
|
||||||
|
resize();
|
||||||
|
renderer.start();
|
||||||
|
|
||||||
|
renderer.setOnClick((agentId) => {
|
||||||
|
if (onAgentClick) onAgentClick(agentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
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 moveAgent = useCallback((agentId, target) => {
|
||||||
|
rendererRef.current?.moveAgent(agentId, target);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { updateAgentState, moveAgent };
|
||||||
|
}
|
||||||
138
src/pages/blog-marketing/BlogMarketing.css
Normal file
138
src/pages/blog-marketing/BlogMarketing.css
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/* ── Blog Marketing ─────────────────────────────────────────────────────── */
|
||||||
|
.bm { max-width: 1100px; margin: 0 auto; padding: 24px 16px 80px; }
|
||||||
|
|
||||||
|
/* 헤더 */
|
||||||
|
.bm-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
||||||
|
.bm-header h1 { font-size: 1.5rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin: 0; }
|
||||||
|
.bm-status { display: flex; gap: 8px; margin-left: auto; }
|
||||||
|
.bm-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 99px; background: rgba(16,185,129,.15); color: #10b981; }
|
||||||
|
.bm-badge--off { background: rgba(239,68,68,.12); color: #ef4444; }
|
||||||
|
|
||||||
|
/* 탭 바 */
|
||||||
|
.bm-tabs { display: flex; gap: 4px; border-bottom: 1px solid rgba(255,255,255,.08); margin-bottom: 20px; }
|
||||||
|
.bm-tab { padding: 8px 16px; font-size: 0.85rem; background: none; border: none; color: rgba(255,255,255,.45); cursor: pointer; border-bottom: 2px solid transparent; transition: all .15s; }
|
||||||
|
.bm-tab:hover { color: rgba(255,255,255,.7); }
|
||||||
|
.bm-tab--active { color: #10b981; border-bottom-color: #10b981; }
|
||||||
|
|
||||||
|
/* ── Dashboard 탭 ─────────────────────────────────────────────────────────── */
|
||||||
|
.bm-dash-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
||||||
|
.bm-dash-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
|
||||||
|
.bm-dash-card__label { font-size: 0.75rem; color: rgba(255,255,255,.4); margin-bottom: 4px; }
|
||||||
|
.bm-dash-card__value { font-size: 1.4rem; font-weight: 700; color: var(--text-primary, #e4e4e7); }
|
||||||
|
.bm-dash-card__value--green { color: #10b981; }
|
||||||
|
|
||||||
|
.bm-dash-section { margin-bottom: 24px; }
|
||||||
|
.bm-dash-section h3 { font-size: 0.9rem; font-weight: 600; color: rgba(255,255,255,.6); margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.bm-top-posts { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.bm-top-post { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; background: rgba(255,255,255,.03); border-radius: 8px; }
|
||||||
|
.bm-top-post__title { font-size: 0.85rem; color: var(--text-primary, #e4e4e7); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.bm-top-post__rev { font-size: 0.85rem; font-weight: 600; color: #10b981; margin-left: 12px; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ── Research 탭 ──────────────────────────────────────────────────────────── */
|
||||||
|
.bm-research-form { display: flex; gap: 8px; margin-bottom: 20px; }
|
||||||
|
.bm-research-input { flex: 1; padding: 10px 14px; border-radius: 8px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: var(--text-primary, #e4e4e7); font-size: 0.9rem; outline: none; }
|
||||||
|
.bm-research-input:focus { border-color: #10b981; }
|
||||||
|
.bm-research-input::placeholder { color: rgba(255,255,255,.25); }
|
||||||
|
|
||||||
|
.bm-btn { padding: 8px 18px; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all .15s; display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.bm-btn--primary { background: #10b981; color: #fff; }
|
||||||
|
.bm-btn--primary:hover { background: #059669; }
|
||||||
|
.bm-btn--primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.bm-btn--secondary { background: rgba(255,255,255,.08); color: rgba(255,255,255,.7); }
|
||||||
|
.bm-btn--secondary:hover { background: rgba(255,255,255,.12); }
|
||||||
|
.bm-btn--danger { background: rgba(239,68,68,.15); color: #ef4444; }
|
||||||
|
.bm-btn--danger:hover { background: rgba(239,68,68,.25); }
|
||||||
|
.bm-btn--sm { padding: 4px 10px; font-size: 0.75rem; }
|
||||||
|
|
||||||
|
.bm-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: bm-spin .6s linear infinite; display: inline-block; }
|
||||||
|
@keyframes bm-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* 분석 카드 */
|
||||||
|
.bm-analyses { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.bm-analysis-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
|
||||||
|
.bm-analysis-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||||
|
.bm-analysis-card__keyword { font-size: 1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); }
|
||||||
|
.bm-analysis-card__date { font-size: 0.7rem; color: rgba(255,255,255,.3); }
|
||||||
|
.bm-analysis-card__scores { display: flex; gap: 16px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||||
|
.bm-score { text-align: center; }
|
||||||
|
.bm-score__label { font-size: 0.65rem; color: rgba(255,255,255,.4); display: block; margin-bottom: 2px; }
|
||||||
|
.bm-score__value { font-size: 1.1rem; font-weight: 700; }
|
||||||
|
.bm-score__value--high { color: #10b981; }
|
||||||
|
.bm-score__value--mid { color: #fbbf24; }
|
||||||
|
.bm-score__value--low { color: #ef4444; }
|
||||||
|
.bm-analysis-card__summary { font-size: 0.8rem; color: rgba(255,255,255,.5); line-height: 1.5; }
|
||||||
|
.bm-analysis-card__actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||||
|
|
||||||
|
/* ── Write 탭 ─────────────────────────────────────────────────────────────── */
|
||||||
|
.bm-write-empty { text-align: center; padding: 60px 20px; color: rgba(255,255,255,.3); }
|
||||||
|
.bm-write-empty p { font-size: 0.85rem; margin-top: 8px; }
|
||||||
|
|
||||||
|
.bm-progress { margin-bottom: 20px; }
|
||||||
|
.bm-progress__bar { height: 4px; background: rgba(255,255,255,.08); border-radius: 2px; overflow: hidden; margin-bottom: 6px; }
|
||||||
|
.bm-progress__fill { height: 100%; background: #10b981; border-radius: 2px; transition: width .3s; }
|
||||||
|
.bm-progress__text { font-size: 0.75rem; color: rgba(255,255,255,.4); }
|
||||||
|
|
||||||
|
.bm-preview { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 20px; margin-bottom: 16px; }
|
||||||
|
.bm-preview__title { font-size: 1.1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin-bottom: 12px; }
|
||||||
|
.bm-preview__body { font-size: 0.85rem; color: rgba(255,255,255,.6); line-height: 1.7; max-height: 400px; overflow-y: auto; }
|
||||||
|
.bm-preview__body h1, .bm-preview__body h2, .bm-preview__body h3 { color: var(--text-primary, #e4e4e7); margin: 16px 0 8px; }
|
||||||
|
.bm-preview__body table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
||||||
|
.bm-preview__body th, .bm-preview__body td { border: 1px solid rgba(255,255,255,.1); padding: 6px 10px; font-size: 0.8rem; }
|
||||||
|
.bm-preview__body th { background: rgba(255,255,255,.06); }
|
||||||
|
.bm-preview__tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; }
|
||||||
|
.bm-tag { font-size: 0.7rem; padding: 2px 8px; border-radius: 4px; background: rgba(16,185,129,.12); color: #10b981; }
|
||||||
|
|
||||||
|
.bm-review-box { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; margin-bottom: 16px; }
|
||||||
|
.bm-review-box h4 { font-size: 0.85rem; font-weight: 600; color: var(--text-primary, #e4e4e7); margin-bottom: 10px; }
|
||||||
|
.bm-review-scores { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||||
|
.bm-review-score { text-align: center; min-width: 60px; }
|
||||||
|
.bm-review-score__label { font-size: 0.65rem; color: rgba(255,255,255,.4); display: block; }
|
||||||
|
.bm-review-score__val { font-size: 1rem; font-weight: 700; }
|
||||||
|
.bm-review-total { font-size: 0.85rem; font-weight: 700; margin-bottom: 6px; }
|
||||||
|
.bm-review-total--pass { color: #10b981; }
|
||||||
|
.bm-review-total--fail { color: #ef4444; }
|
||||||
|
.bm-review-feedback { font-size: 0.8rem; color: rgba(255,255,255,.5); line-height: 1.5; }
|
||||||
|
|
||||||
|
.bm-write-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* ── Posts 탭 ─────────────────────────────────────────────────────────────── */
|
||||||
|
.bm-posts-filter { display: flex; gap: 4px; margin-bottom: 16px; }
|
||||||
|
.bm-filter-btn { padding: 4px 12px; border-radius: 6px; border: none; font-size: 0.75rem; background: rgba(255,255,255,.06); color: rgba(255,255,255,.5); cursor: pointer; transition: all .15s; }
|
||||||
|
.bm-filter-btn--active { background: rgba(16,185,129,.15); color: #10b981; }
|
||||||
|
|
||||||
|
.bm-posts-list { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.bm-post-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 14px 16px; }
|
||||||
|
.bm-post-card__top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 6px; }
|
||||||
|
.bm-post-card__title { font-size: 0.9rem; font-weight: 600; color: var(--text-primary, #e4e4e7); flex: 1; }
|
||||||
|
.bm-post-card__status { font-size: 0.65rem; padding: 2px 8px; border-radius: 4px; font-weight: 600; white-space: nowrap; margin-left: 8px; }
|
||||||
|
.bm-post-card__status--draft { background: rgba(255,255,255,.08); color: rgba(255,255,255,.5); }
|
||||||
|
.bm-post-card__status--reviewed { background: rgba(96,165,250,.15); color: #60a5fa; }
|
||||||
|
.bm-post-card__status--published { background: rgba(16,185,129,.15); color: #10b981; }
|
||||||
|
.bm-post-card__excerpt { font-size: 0.8rem; color: rgba(255,255,255,.4); margin-bottom: 8px; line-height: 1.4; }
|
||||||
|
.bm-post-card__meta { font-size: 0.7rem; color: rgba(255,255,255,.25); display: flex; gap: 12px; }
|
||||||
|
.bm-post-card__actions { display: flex; gap: 6px; margin-top: 10px; }
|
||||||
|
|
||||||
|
/* 발행 모달 */
|
||||||
|
.bm-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 100; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.bm-modal { background: #1e1e24; border: 1px solid rgba(255,255,255,.1); border-radius: 14px; padding: 24px; width: 90%; max-width: 440px; }
|
||||||
|
.bm-modal h3 { font-size: 1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin-bottom: 12px; }
|
||||||
|
.bm-modal__input { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: var(--text-primary, #e4e4e7); font-size: 0.85rem; outline: none; margin-bottom: 14px; }
|
||||||
|
.bm-modal__input:focus { border-color: #10b981; }
|
||||||
|
.bm-modal__buttons { display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
|
||||||
|
/* ── 공통 빈 상태 ─────────────────────────────────────────────────────────── */
|
||||||
|
.bm-empty { text-align: center; padding: 48px 20px; color: rgba(255,255,255,.25); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* ── 모바일 ───────────────────────────────────────────────────────────────── */
|
||||||
|
@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: 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; }
|
||||||
|
}
|
||||||
696
src/pages/blog-marketing/BlogMarketing.jsx
Normal file
696
src/pages/blog-marketing/BlogMarketing.jsx
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
getBlogMarketingStatus,
|
||||||
|
startResearch,
|
||||||
|
getResearchHistory,
|
||||||
|
getResearchDetail,
|
||||||
|
deleteResearch,
|
||||||
|
getBlogMarketingTask,
|
||||||
|
startGenerate,
|
||||||
|
startReview,
|
||||||
|
startRegenerate,
|
||||||
|
startMarket,
|
||||||
|
getBlogMarketingPosts,
|
||||||
|
getBlogMarketingPost,
|
||||||
|
deleteBlogMarketingPost,
|
||||||
|
publishBlogMarketingPost,
|
||||||
|
getBlogMarketingDashboard,
|
||||||
|
getBlogMarketingCommissions,
|
||||||
|
addBlogMarketingCommission,
|
||||||
|
deleteBlogMarketingCommission,
|
||||||
|
getBrandLinks,
|
||||||
|
createBrandLink,
|
||||||
|
deleteBrandLink,
|
||||||
|
} from '../../api';
|
||||||
|
import './BlogMarketing.css';
|
||||||
|
|
||||||
|
/* ────────────────────── 유틸 ────────────────────── */
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
function fmtMoney(n) {
|
||||||
|
if (n == null) return '-';
|
||||||
|
return n.toLocaleString('ko-KR') + '원';
|
||||||
|
}
|
||||||
|
function copyHtmlToClipboard(html) {
|
||||||
|
const blob = new Blob([html], { type: 'text/html' });
|
||||||
|
const plainBlob = new Blob([html.replace(/<[^>]*>/g, '')], { type: 'text/plain' });
|
||||||
|
navigator.clipboard.write([
|
||||||
|
new ClipboardItem({ 'text/html': blob, 'text/plain': plainBlob }),
|
||||||
|
]).then(() => alert('본문이 클립보드에 복사되었습니다! (서식 포함)'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreColor(v, max = 100) {
|
||||||
|
const r = v / max;
|
||||||
|
if (r >= 0.6) return 'bm-score__value--high';
|
||||||
|
if (r >= 0.3) return 'bm-score__value--mid';
|
||||||
|
return 'bm-score__value--low';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ────────────────────── 폴링 훅 ────────────────────── */
|
||||||
|
function usePollTask(onDone) {
|
||||||
|
const [taskId, setTaskId] = useState(null);
|
||||||
|
const [task, setTask] = useState(null);
|
||||||
|
const timer = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!taskId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const t = await getBlogMarketingTask(taskId);
|
||||||
|
if (cancelled) return;
|
||||||
|
setTask(t);
|
||||||
|
if (t.status === 'succeeded' || t.status === 'failed') {
|
||||||
|
setTaskId(null);
|
||||||
|
onDone?.(t);
|
||||||
|
} else {
|
||||||
|
timer.current = setTimeout(poll, 1500);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) timer.current = setTimeout(poll, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
return () => { cancelled = true; clearTimeout(timer.current); };
|
||||||
|
}, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return { taskId, task, start: setTaskId, clear: () => { setTaskId(null); setTask(null); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
export default function BlogMarketing() {
|
||||||
|
const [tab, setTab] = useState('dashboard');
|
||||||
|
const [status, setStatus] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getBlogMarketingStatus().then(setStatus).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'dashboard', label: 'Dashboard' },
|
||||||
|
{ id: 'research', label: 'Research' },
|
||||||
|
{ id: 'write', label: 'Write' },
|
||||||
|
{ id: 'posts', label: 'Posts' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bm">
|
||||||
|
<header className="bm-header">
|
||||||
|
<h1>Blog Lab</h1>
|
||||||
|
{status && (
|
||||||
|
<div className="bm-status">
|
||||||
|
<span className={`bm-badge ${status.naver_api ? '' : 'bm-badge--off'}`}>
|
||||||
|
Naver {status.naver_api ? 'ON' : 'OFF'}
|
||||||
|
</span>
|
||||||
|
<span className={`bm-badge ${status.claude_api ? '' : 'bm-badge--off'}`}>
|
||||||
|
Claude {status.claude_api ? 'ON' : 'OFF'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav className="bm-tabs">
|
||||||
|
{tabs.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
className={`bm-tab ${tab === t.id ? 'bm-tab--active' : ''}`}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{tab === 'dashboard' && <DashboardTab />}
|
||||||
|
{tab === 'research' && <ResearchTab onGenerate={(id) => { setTab('write'); }} />}
|
||||||
|
{tab === 'write' && <WriteTab />}
|
||||||
|
{tab === 'posts' && <PostsTab />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ Dashboard 탭 ═════════════════════════════════════ */
|
||||||
|
function DashboardTab() {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getBlogMarketingDashboard().then(setData).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!data) return <div className="bm-empty">로딩 중...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="bm-dash-cards">
|
||||||
|
<DashCard label="총 포스트" value={data.total_posts} />
|
||||||
|
<DashCard label="발행 완료" value={data.published_posts} />
|
||||||
|
<DashCard label="총 클릭" value={data.total_clicks.toLocaleString()} />
|
||||||
|
<DashCard label="총 수익" value={fmtMoney(data.total_revenue)} green />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.top_posts?.length > 0 && (
|
||||||
|
<div className="bm-dash-section">
|
||||||
|
<h3>Top 5 포스트 (수익 기준)</h3>
|
||||||
|
<div className="bm-top-posts">
|
||||||
|
{data.top_posts.map(p => (
|
||||||
|
<div key={p.id} className="bm-top-post">
|
||||||
|
<span className="bm-top-post__title">{p.title || '(제목 없음)'}</span>
|
||||||
|
<span className="bm-top-post__rev">{fmtMoney(p.total_revenue)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.monthly?.length > 0 && (
|
||||||
|
<div className="bm-dash-section">
|
||||||
|
<h3>월별 수익</h3>
|
||||||
|
<div className="bm-top-posts">
|
||||||
|
{data.monthly.map(m => (
|
||||||
|
<div key={m.month} className="bm-top-post">
|
||||||
|
<span className="bm-top-post__title">{m.month}</span>
|
||||||
|
<span style={{ fontSize: '0.8rem', color: 'rgba(255,255,255,.4)', marginRight: 12 }}>
|
||||||
|
클릭 {m.clicks} / 구매 {m.purchases}
|
||||||
|
</span>
|
||||||
|
<span className="bm-top-post__rev">{fmtMoney(m.revenue)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashCard({ label, value, green }) {
|
||||||
|
return (
|
||||||
|
<div className="bm-dash-card">
|
||||||
|
<div className="bm-dash-card__label">{label}</div>
|
||||||
|
<div className={`bm-dash-card__value ${green ? 'bm-dash-card__value--green' : ''}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ Research 탭 ══════════════════════════════════════ */
|
||||||
|
function ResearchTab() {
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [analyses, setAnalyses] = useState([]);
|
||||||
|
const [expanded, setExpanded] = useState(null);
|
||||||
|
|
||||||
|
const loadHistory = useCallback(() => {
|
||||||
|
getResearchHistory(30).then(r => setAnalyses(r.analyses || [])).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadHistory(); }, [loadHistory]);
|
||||||
|
|
||||||
|
const poll = usePollTask((t) => {
|
||||||
|
if (t.status === 'succeeded') loadHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (!keyword.trim() || poll.taskId) return;
|
||||||
|
try {
|
||||||
|
const { task_id } = await startResearch(keyword.trim());
|
||||||
|
poll.start(task_id);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (!confirm('이 분석을 삭제할까요?')) return;
|
||||||
|
await deleteResearch(id);
|
||||||
|
setAnalyses(prev => prev.filter(a => a.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerate = async (analysisId) => {
|
||||||
|
try {
|
||||||
|
const { task_id } = await startGenerate(analysisId);
|
||||||
|
alert(`글 생성 시작! (task: ${task_id.slice(0, 8)})\nWrite 탭에서 확인하세요.`);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="bm-research-form">
|
||||||
|
<input
|
||||||
|
className="bm-research-input"
|
||||||
|
placeholder="분석할 키워드를 입력하세요 (예: 무선 이어폰 추천)"
|
||||||
|
value={keyword}
|
||||||
|
onChange={e => setKeyword(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||||
|
disabled={!!poll.taskId}
|
||||||
|
/>
|
||||||
|
<button className="bm-btn bm-btn--primary" onClick={handleSearch} disabled={!!poll.taskId}>
|
||||||
|
{poll.taskId ? <><span className="bm-spinner" /> 분석 중...</> : '분석'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{poll.task && poll.task.status !== 'succeeded' && poll.task.status !== 'failed' && (
|
||||||
|
<div className="bm-progress">
|
||||||
|
<div className="bm-progress__bar">
|
||||||
|
<div className="bm-progress__fill" style={{ width: `${poll.task.progress || 0}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="bm-progress__text">{poll.task.message || '처리 중...'}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bm-analyses">
|
||||||
|
{analyses.length === 0 && !poll.taskId && (
|
||||||
|
<div className="bm-empty">아직 분석 결과가 없습니다. 키워드를 입력해 첫 분석을 시작하세요!</div>
|
||||||
|
)}
|
||||||
|
{analyses.map(a => (
|
||||||
|
<div key={a.id} className="bm-analysis-card">
|
||||||
|
<div className="bm-analysis-card__header">
|
||||||
|
<span className="bm-analysis-card__keyword">{a.keyword}</span>
|
||||||
|
<span className="bm-analysis-card__date">{fmtDate(a.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bm-analysis-card__scores">
|
||||||
|
<div className="bm-score">
|
||||||
|
<span className="bm-score__label">경쟁도</span>
|
||||||
|
<span className={`bm-score__value ${scoreColor(a.competition)}`}>{a.competition}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bm-score">
|
||||||
|
<span className="bm-score__label">기회</span>
|
||||||
|
<span className={`bm-score__value ${scoreColor(a.opportunity)}`}>{a.opportunity}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bm-score">
|
||||||
|
<span className="bm-score__label">블로그</span>
|
||||||
|
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
|
||||||
|
{(a.blog_total || 0).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bm-score">
|
||||||
|
<span className="bm-score__label">쇼핑</span>
|
||||||
|
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
|
||||||
|
{(a.shop_total || 0).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{a.avg_price != null && (
|
||||||
|
<div className="bm-score">
|
||||||
|
<span className="bm-score__label">평균가</span>
|
||||||
|
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
|
||||||
|
{fmtMoney(a.avg_price)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded === a.id && a.top_products?.length > 0 && (
|
||||||
|
<div className="bm-analysis-card__summary">
|
||||||
|
<strong>상위 상품:</strong>
|
||||||
|
<ul style={{ margin: '4px 0 0 16px', padding: 0 }}>
|
||||||
|
{a.top_products.map((p, i) => (
|
||||||
|
<li key={i}>{p.title} — {fmtMoney(p.lprice)} ({p.mallName})</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bm-analysis-card__actions">
|
||||||
|
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={() => handleGenerate(a.id)}>
|
||||||
|
글 생성
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="bm-btn bm-btn--secondary bm-btn--sm"
|
||||||
|
onClick={() => setExpanded(expanded === a.id ? null : a.id)}
|
||||||
|
>
|
||||||
|
{expanded === a.id ? '접기' : '상세'}
|
||||||
|
</button>
|
||||||
|
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDelete(a.id)}>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ Write 탭 ═════════════════════════════════════════ */
|
||||||
|
function WriteTab() {
|
||||||
|
const [posts, setPosts] = useState([]);
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [post, setPost] = useState(null);
|
||||||
|
|
||||||
|
// 브랜드 링크 상태
|
||||||
|
const [links, setLinks] = useState([]);
|
||||||
|
const [showLinkForm, setShowLinkForm] = useState(false);
|
||||||
|
const [linkForm, setLinkForm] = useState({ url: '', product_name: '', description: '', placement_hint: '' });
|
||||||
|
|
||||||
|
const loadPosts = useCallback(() => {
|
||||||
|
Promise.all([
|
||||||
|
getBlogMarketingPosts('draft', 20),
|
||||||
|
getBlogMarketingPosts('marketed', 20),
|
||||||
|
]).then(([draftRes, marketedRes]) => {
|
||||||
|
const all = [...(draftRes.posts || []), ...(marketedRes.posts || [])];
|
||||||
|
all.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||||
|
setPosts(all);
|
||||||
|
if (all.length > 0 && !selected) setSelected(all[0].id);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => { loadPosts(); }, [loadPosts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected) { setPost(null); setLinks([]); return; }
|
||||||
|
getBlogMarketingPost(selected).then(setPost).catch(() => {});
|
||||||
|
getBrandLinks({ post_id: selected }).then(r => setLinks(r.links || [])).catch(() => setLinks([]));
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
const reviewPoll = usePollTask((t) => {
|
||||||
|
if (t.status === 'succeeded' && t.result_id) {
|
||||||
|
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const regenPoll = usePollTask((t) => {
|
||||||
|
if (t.status === 'succeeded' && t.result_id) {
|
||||||
|
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const marketPoll = usePollTask((t) => {
|
||||||
|
if (t.status === 'succeeded' && t.result_id) {
|
||||||
|
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
|
||||||
|
loadPosts();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleReview = async () => {
|
||||||
|
if (!post) return;
|
||||||
|
try {
|
||||||
|
const { task_id } = await startReview(post.id);
|
||||||
|
reviewPoll.start(task_id);
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegenerate = async () => {
|
||||||
|
if (!post) return;
|
||||||
|
try {
|
||||||
|
const { task_id } = await startRegenerate(post.id);
|
||||||
|
regenPoll.start(task_id);
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarket = async () => {
|
||||||
|
if (!post) return;
|
||||||
|
if (links.length === 0) {
|
||||||
|
alert('마케터 실행 전 브랜드커넥트 링크를 먼저 추가하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { task_id } = await startMarket(post.id);
|
||||||
|
marketPoll.start(task_id);
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
if (!post) return;
|
||||||
|
copyHtmlToClipboard(post.body);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddLink = async () => {
|
||||||
|
if (!linkForm.url.trim() || !linkForm.product_name.trim()) {
|
||||||
|
alert('URL과 상품명은 필수입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createBrandLink({ ...linkForm, post_id: selected });
|
||||||
|
setLinkForm({ url: '', product_name: '', description: '', placement_hint: '' });
|
||||||
|
setShowLinkForm(false);
|
||||||
|
getBrandLinks({ post_id: selected }).then(r => setLinks(r.links || [])).catch(() => {});
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteLink = async (linkId) => {
|
||||||
|
if (!confirm('이 링크를 삭제할까요?')) return;
|
||||||
|
await deleteBrandLink(linkId);
|
||||||
|
setLinks(prev => prev.filter(l => l.id !== linkId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const activePoll = reviewPoll.task || regenPoll.task || marketPoll.task;
|
||||||
|
const isProcessing = activePoll && activePoll.status !== 'succeeded' && activePoll.status !== 'failed';
|
||||||
|
|
||||||
|
if (posts.length === 0 && !post) {
|
||||||
|
return (
|
||||||
|
<div className="bm-write-empty">
|
||||||
|
<div style={{ fontSize: '2rem', marginBottom: 8 }}>✍</div>
|
||||||
|
<p>아직 작성 중인 글이 없습니다.<br />Research 탭에서 키워드를 분석하고 글 생성을 시작하세요.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{posts.length > 1 && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||||
|
{posts.map(p => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
className={`bm-filter-btn ${selected === p.id ? 'bm-filter-btn--active' : ''}`}
|
||||||
|
onClick={() => setSelected(p.id)}
|
||||||
|
>
|
||||||
|
{p.title?.slice(0, 20) || `${p.status === 'marketed' ? 'Marketed' : 'Draft'} #${p.id}`}
|
||||||
|
{p.status === 'marketed' && <span style={{ marginLeft: 4, fontSize: '0.7rem', color: '#f59e0b' }}>[M]</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isProcessing && activePoll && (
|
||||||
|
<div className="bm-progress">
|
||||||
|
<div className="bm-progress__bar">
|
||||||
|
<div className="bm-progress__fill" style={{ width: `${activePoll.progress || 0}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="bm-progress__text">{activePoll.message || '처리 중...'}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{post && (
|
||||||
|
<>
|
||||||
|
{/* 브랜드커넥트 링크 섹션 */}
|
||||||
|
<div className="bm-links-section" style={{ marginBottom: 16, padding: 12, background: 'rgba(255,255,255,0.04)', borderRadius: 8 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
|
<h4 style={{ margin: 0, fontSize: '0.9rem' }}>브랜드커넥트 링크 ({links.length})</h4>
|
||||||
|
<button className="bm-btn bm-btn--secondary bm-btn--sm" onClick={() => setShowLinkForm(!showLinkForm)}>
|
||||||
|
{showLinkForm ? '취소' : '+ 링크 추가'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showLinkForm && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12, padding: 12, background: 'rgba(0,0,0,0.2)', borderRadius: 6 }}>
|
||||||
|
<input
|
||||||
|
className="bm-research-input"
|
||||||
|
placeholder="제휴 링크 URL (필수)"
|
||||||
|
value={linkForm.url}
|
||||||
|
onChange={e => setLinkForm(p => ({ ...p, url: e.target.value }))}
|
||||||
|
style={{ fontSize: '0.85rem' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="bm-research-input"
|
||||||
|
placeholder="상품명 (필수)"
|
||||||
|
value={linkForm.product_name}
|
||||||
|
onChange={e => setLinkForm(p => ({ ...p, product_name: e.target.value }))}
|
||||||
|
style={{ fontSize: '0.85rem' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="bm-research-input"
|
||||||
|
placeholder="상품 설명 (선택)"
|
||||||
|
value={linkForm.description}
|
||||||
|
onChange={e => setLinkForm(p => ({ ...p, description: e.target.value }))}
|
||||||
|
style={{ fontSize: '0.85rem' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="bm-research-input"
|
||||||
|
placeholder="배치 힌트 (선택, 예: 본문 중간 자연스럽게)"
|
||||||
|
value={linkForm.placement_hint}
|
||||||
|
onChange={e => setLinkForm(p => ({ ...p, placement_hint: e.target.value }))}
|
||||||
|
style={{ fontSize: '0.85rem' }}
|
||||||
|
/>
|
||||||
|
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={handleAddLink}>등록</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{links.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{links.map(l => (
|
||||||
|
<div key={l.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 8px', background: 'rgba(255,255,255,0.03)', borderRadius: 4, fontSize: '0.8rem' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<strong>{l.product_name}</strong>
|
||||||
|
{l.description && <span style={{ marginLeft: 8, color: 'rgba(255,255,255,.4)' }}>{l.description}</span>}
|
||||||
|
</div>
|
||||||
|
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDeleteLink(l.id)} style={{ fontSize: '0.7rem', padding: '2px 6px' }}>삭제</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bm-preview">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div className="bm-preview__title">{post.title || '(제목 없음)'}</div>
|
||||||
|
<span className={`bm-post-card__status bm-post-card__status--${post.status}`} style={{ fontSize: '0.75rem' }}>
|
||||||
|
{post.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bm-preview__body" dangerouslySetInnerHTML={{ __html: post.body }} />
|
||||||
|
{post.tags?.length > 0 && (
|
||||||
|
<div className="bm-preview__tags">
|
||||||
|
{post.tags.map((t, i) => <span key={i} className="bm-tag">#{t}</span>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{post.review_detail && post.review_score != null && (
|
||||||
|
<div className="bm-review-box">
|
||||||
|
<h4>품질 리뷰 결과</h4>
|
||||||
|
<div className="bm-review-scores">
|
||||||
|
{Object.entries(post.review_detail.scores || {}).map(([k, v]) => (
|
||||||
|
<div key={k} className="bm-review-score">
|
||||||
|
<span className="bm-review-score__label">{k}</span>
|
||||||
|
<span className={`bm-review-score__val ${scoreColor(v, 10)}`}>{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={`bm-review-total ${post.review_detail.pass ? 'bm-review-total--pass' : 'bm-review-total--fail'}`}>
|
||||||
|
총점: {post.review_score}/60 {post.review_detail.pass ? '(통과)' : '(미달)'}
|
||||||
|
</div>
|
||||||
|
{post.review_detail.feedback && (
|
||||||
|
<div className="bm-review-feedback">{post.review_detail.feedback}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bm-write-actions">
|
||||||
|
{post.status === 'draft' && (
|
||||||
|
<button className="bm-btn bm-btn--primary" onClick={handleMarket} disabled={isProcessing} title={links.length === 0 ? '브랜드 링크를 먼저 추가하세요' : ''}>
|
||||||
|
{marketPoll.taskId ? <><span className="bm-spinner" /> 마케팅 중...</> : '마케터 실행'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="bm-btn bm-btn--primary" onClick={handleReview} disabled={isProcessing}>
|
||||||
|
{reviewPoll.taskId ? <><span className="bm-spinner" /> 리뷰 중...</> : '품질 리뷰'}
|
||||||
|
</button>
|
||||||
|
<button className="bm-btn bm-btn--secondary" onClick={handleRegenerate} disabled={isProcessing}>
|
||||||
|
{regenPoll.taskId ? <><span className="bm-spinner" /> 재생성 중...</> : '재생성'}
|
||||||
|
</button>
|
||||||
|
<button className="bm-btn bm-btn--secondary" onClick={handleCopy}>
|
||||||
|
본문 복사
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ Posts 탭 ═════════════════════════════════════════ */
|
||||||
|
function PostsTab() {
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const [posts, setPosts] = useState([]);
|
||||||
|
const [publishModal, setPublishModal] = useState(null);
|
||||||
|
const [naverUrl, setNaverUrl] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
getBlogMarketingPosts(filter || undefined).then(r => setPosts(r.posts || [])).catch(() => {});
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (!confirm('이 포스트를 삭제할까요?')) return;
|
||||||
|
await deleteBlogMarketingPost(id);
|
||||||
|
setPosts(prev => prev.filter(p => p.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePublish = async () => {
|
||||||
|
if (!publishModal) return;
|
||||||
|
await publishBlogMarketingPost(publishModal, naverUrl);
|
||||||
|
setPublishModal(null);
|
||||||
|
setNaverUrl('');
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = (body) => {
|
||||||
|
copyHtmlToClipboard(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filters = [
|
||||||
|
{ id: '', label: '전체' },
|
||||||
|
{ id: 'draft', label: 'Draft' },
|
||||||
|
{ id: 'marketed', label: 'Marketed' },
|
||||||
|
{ id: 'reviewed', label: 'Reviewed' },
|
||||||
|
{ id: 'published', label: 'Published' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="bm-posts-filter">
|
||||||
|
{filters.map(f => (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
className={`bm-filter-btn ${filter === f.id ? 'bm-filter-btn--active' : ''}`}
|
||||||
|
onClick={() => setFilter(f.id)}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bm-posts-list">
|
||||||
|
{posts.length === 0 && <div className="bm-empty">포스트가 없습니다.</div>}
|
||||||
|
{posts.map(p => (
|
||||||
|
<div key={p.id} className="bm-post-card">
|
||||||
|
<div className="bm-post-card__top">
|
||||||
|
<span className="bm-post-card__title">{p.title || '(제목 없음)'}</span>
|
||||||
|
<span className={`bm-post-card__status bm-post-card__status--${p.status}`}>
|
||||||
|
{p.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{p.excerpt && <div className="bm-post-card__excerpt">{p.excerpt}</div>}
|
||||||
|
<div className="bm-post-card__meta">
|
||||||
|
{p.review_score != null && <span>리뷰: {p.review_score}/60</span>}
|
||||||
|
{p.naver_url && <a href={p.naver_url} target="_blank" rel="noreferrer" style={{ color: '#10b981' }}>네이버 링크</a>}
|
||||||
|
<span>{fmtDate(p.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bm-post-card__actions">
|
||||||
|
<button className="bm-btn bm-btn--secondary bm-btn--sm" onClick={() => handleCopy(p.body)}>복사</button>
|
||||||
|
{p.status !== 'published' && (
|
||||||
|
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={() => { setPublishModal(p.id); setNaverUrl(''); }}>
|
||||||
|
발행
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDelete(p.id)}>삭제</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{publishModal && (
|
||||||
|
<div className="bm-modal-overlay" onClick={() => setPublishModal(null)}>
|
||||||
|
<div className="bm-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3>네이버 블로그 발행</h3>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'rgba(255,255,255,.4)', marginBottom: 12 }}>
|
||||||
|
본문을 네이버 블로그에 붙여넣기한 후, 발행된 URL을 입력하세요.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
className="bm-modal__input"
|
||||||
|
placeholder="https://blog.naver.com/..."
|
||||||
|
value={naverUrl}
|
||||||
|
onChange={e => setNaverUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="bm-modal__buttons">
|
||||||
|
<button className="bm-btn bm-btn--secondary" onClick={() => setPublishModal(null)}>취소</button>
|
||||||
|
<button className="bm-btn bm-btn--primary" onClick={handlePublish}>발행 완료</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,11 +10,35 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blog-header__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-new-btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
border: 1px solid rgba(192, 132, 252, 0.45);
|
||||||
|
background: rgba(192, 132, 252, 0.1);
|
||||||
|
color: var(--accent-blog);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-new-btn:hover {
|
||||||
|
background: rgba(192, 132, 252, 0.2);
|
||||||
|
border-color: rgba(192, 132, 252, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
.blog-kicker {
|
.blog-kicker {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.3em;
|
letter-spacing: 0.3em;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--accent);
|
color: var(--accent-blog);
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,23 +80,27 @@
|
|||||||
.blog-toggle-list {
|
.blog-toggle-list {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 20px;
|
/* 사이드바 토글 버튼(top-left) 과 겹치지 않도록 오른쪽 하단 배치 */
|
||||||
left: 20px;
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
top: auto;
|
||||||
|
left: auto;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
width: 40px;
|
width: 44px;
|
||||||
height: 40px;
|
height: 44px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid rgba(192, 132, 252, 0.45);
|
||||||
background: rgba(10, 12, 20, 0.8);
|
background: rgba(10, 12, 20, 0.88);
|
||||||
color: var(--text);
|
color: var(--accent-blog);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(12px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-toggle-list:hover {
|
.blog-toggle-list:hover {
|
||||||
transform: scale(1.1);
|
transform: scale(1.08);
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,29 +126,87 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.blog-category-chip.is-active {
|
.blog-category-chip.is-active {
|
||||||
border-color: rgba(247, 168, 165, 0.6);
|
border-color: rgba(192, 132, 252, 0.55);
|
||||||
background: rgba(247, 168, 165, 0.2);
|
background: rgba(192, 132, 252, 0.15);
|
||||||
|
color: var(--accent-blog);
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-list__item {
|
.blog-list__item-wrap {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
box-shadow: var(--shadow-inset);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-list__item-wrap:hover {
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: var(--shadow-sm), var(--shadow-inset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-list__item-wrap.is-active {
|
||||||
|
border-color: rgba(192, 132, 252, 0.5);
|
||||||
|
box-shadow: 0 4px 20px rgba(192, 132, 252, 0.12), var(--shadow-inset);
|
||||||
|
background: rgba(192, 132, 252, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-list__item-btn {
|
||||||
|
width: 100%;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 18px;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
transition: border-color 0.2s ease;
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-list__item:hover {
|
.blog-list__actions {
|
||||||
border-color: rgba(255, 255, 255, 0.25);
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 12px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-list__item.is-active {
|
.blog-list__action-btn {
|
||||||
border-color: rgba(247, 168, 165, 0.6);
|
font-size: 11px;
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-list__action-btn:hover {
|
||||||
|
border-color: var(--accent-blog);
|
||||||
|
color: var(--accent-blog);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-list__action-btn--del:hover {
|
||||||
|
border-color: #f04452;
|
||||||
|
color: #f04452;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-article__edit-btn {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-article__edit-btn:hover {
|
||||||
|
border-color: var(--accent-blog);
|
||||||
|
color: var(--accent-blog);
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-pagination {
|
.blog-pagination {
|
||||||
@@ -168,14 +254,15 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.2em;
|
letter-spacing: 0.2em;
|
||||||
color: var(--accent);
|
color: var(--accent-blog);
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-article {
|
.blog-article {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 24px;
|
border-radius: var(--radius-lg);
|
||||||
background: rgba(9, 10, 16, 0.65);
|
background: rgba(9, 10, 16, 0.65);
|
||||||
padding: 24px;
|
padding: 28px;
|
||||||
|
box-shadow: var(--shadow-md), var(--shadow-inset);
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-article__meta {
|
.blog-article__meta {
|
||||||
@@ -277,8 +364,9 @@
|
|||||||
.md-quote {
|
.md-quote {
|
||||||
margin: 0 0 14px;
|
margin: 0 0 14px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-left: 3px solid rgba(247, 168, 165, 0.6);
|
border-left: 3px solid rgba(192, 132, 252, 0.5);
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(192, 132, 252, 0.05);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,6 +457,12 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blog-header__actions {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.blog-toggle-list {
|
.blog-toggle-list {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -420,7 +514,7 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-list__item {
|
.blog-list__item-btn {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,3 +563,207 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 블로그 에디터 모달 ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.blog-editor-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(4, 6, 14, 0.75);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor {
|
||||||
|
background: #0c0f1e;
|
||||||
|
border: 1px solid rgba(192, 132, 252, 0.25);
|
||||||
|
border-radius: var(--radius-xl, 20px);
|
||||||
|
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(192, 132, 252, 0.06);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 860px;
|
||||||
|
max-height: 92vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 18px 24px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor__heading {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-blog);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor__close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor__close:hover {
|
||||||
|
color: var(--text-bright, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor__title-input {
|
||||||
|
margin: 14px 24px 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md, 10px);
|
||||||
|
color: var(--text-bright, #f8f3ee);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor__title-input:focus {
|
||||||
|
border-color: rgba(192, 132, 252, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor__title-input::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor__tag-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor__tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 24px 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor__tab {
|
||||||
|
padding: 5px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor__tab.is-active {
|
||||||
|
border-color: rgba(192, 132, 252, 0.55);
|
||||||
|
background: rgba(192, 132, 252, 0.12);
|
||||||
|
color: var(--accent-blog);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor__textarea {
|
||||||
|
flex: 1;
|
||||||
|
margin: 10px 24px 0;
|
||||||
|
padding: 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md, 10px);
|
||||||
|
color: var(--text-bright, #f8f3ee);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
line-height: 1.75;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
min-height: 320px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor__textarea:focus {
|
||||||
|
border-color: rgba(192, 132, 252, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor__preview {
|
||||||
|
flex: 1;
|
||||||
|
margin: 10px 24px 0;
|
||||||
|
padding: 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md, 10px);
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 24px 18px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor__save-btn {
|
||||||
|
border-color: rgba(192, 132, 252, 0.55) !important;
|
||||||
|
background: rgba(192, 132, 252, 0.15) !important;
|
||||||
|
color: var(--accent-blog) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor__save-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(192, 132, 252, 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor__save-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.blog-editor-overlay {
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 95vh;
|
||||||
|
border-radius: var(--radius-xl, 20px) var(--radius-xl, 20px) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor__title-input,
|
||||||
|
.blog-editor__tag-row,
|
||||||
|
.blog-editor__tab-bar,
|
||||||
|
.blog-editor__textarea,
|
||||||
|
.blog-editor__preview {
|
||||||
|
margin-left: 16px;
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-editor__header,
|
||||||
|
.blog-editor__footer {
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-new-btn {
|
||||||
|
align-self: stretch;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { getBlogPosts } from '../../data/blog';
|
import { getBlogPosts } from '../../data/blog';
|
||||||
|
import {
|
||||||
|
getBlogPostsApi,
|
||||||
|
createBlogPost,
|
||||||
|
updateBlogPost,
|
||||||
|
deleteBlogPost,
|
||||||
|
} from '../../api';
|
||||||
import './Blog.css';
|
import './Blog.css';
|
||||||
|
|
||||||
|
// ── 마크다운 렌더러 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const renderInline = (text) => {
|
const renderInline = (text) => {
|
||||||
const normalized = text.replace(/<br\s*\/?>/gi, '\n');
|
const normalized = text.replace(/<br\s*\/?>/gi, '\n');
|
||||||
const pattern =
|
const pattern =
|
||||||
@@ -122,9 +130,7 @@ const renderMarkdown = (body) => {
|
|||||||
|
|
||||||
flushList();
|
flushList();
|
||||||
|
|
||||||
if (!line.trim()) {
|
if (!line.trim()) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line.startsWith('###### ')) {
|
if (line.startsWith('###### ')) {
|
||||||
blocks.push({ type: 'h6', value: line.replace(/^######\s+/, '') });
|
blocks.push({ type: 'h6', value: line.replace(/^######\s+/, '') });
|
||||||
@@ -193,62 +199,255 @@ const renderMarkdown = (body) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── 블로그 에디터 모달 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PRESET_TAGS = ['일상', '개발', '공부', '아이디어', '기타'];
|
||||||
|
|
||||||
|
const BlogEditor = ({ post, onSave, onClose }) => {
|
||||||
|
const [title, setTitle] = useState(post?.title || '');
|
||||||
|
const [tags, setTags] = useState(post?.tags || []);
|
||||||
|
const [body, setBody] = useState(post?.body || '');
|
||||||
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const textareaRef = useRef(null);
|
||||||
|
|
||||||
|
// Tab 키로 들여쓰기 삽입
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
const el = textareaRef.current;
|
||||||
|
const start = el.selectionStart;
|
||||||
|
const end = el.selectionEnd;
|
||||||
|
const next = body.substring(0, start) + ' ' + body.substring(end);
|
||||||
|
setBody(next);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.selectionStart = el.selectionEnd = start + 2;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTag = (tag) => {
|
||||||
|
setTags((prev) =>
|
||||||
|
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const excerpt = body
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.find((l) => l.trim() && !l.startsWith('#'))
|
||||||
|
?.trim()
|
||||||
|
.slice(0, 120) || '';
|
||||||
|
await onSave({
|
||||||
|
title: title.trim(),
|
||||||
|
tags,
|
||||||
|
body,
|
||||||
|
excerpt,
|
||||||
|
date: post?.date || today,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ESC 키로 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
return () => document.removeEventListener('keydown', handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="blog-editor-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
|
<div className="blog-editor">
|
||||||
|
<div className="blog-editor__header">
|
||||||
|
<h2 className="blog-editor__heading">
|
||||||
|
{post?.id ? '글 수정' : '새 글 쓰기'}
|
||||||
|
</h2>
|
||||||
|
<button type="button" className="blog-editor__close" onClick={onClose} aria-label="닫기">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="blog-editor__title-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="제목을 입력하세요"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="blog-editor__tag-row">
|
||||||
|
{PRESET_TAGS.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
type="button"
|
||||||
|
className={`blog-category-chip${tags.includes(tag) ? ' is-active' : ''}`}
|
||||||
|
onClick={() => toggleTag(tag)}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="blog-editor__tab-bar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`blog-editor__tab${!showPreview ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setShowPreview(false)}
|
||||||
|
>
|
||||||
|
편집
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`blog-editor__tab${showPreview ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setShowPreview(true)}
|
||||||
|
>
|
||||||
|
미리보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPreview ? (
|
||||||
|
<div className="blog-article__body blog-editor__preview">
|
||||||
|
{body
|
||||||
|
? renderMarkdown(body)
|
||||||
|
: <p style={{ color: 'var(--muted)' }}>본문을 입력하면 여기에 미리보기가 표시됩니다.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
className="blog-editor__textarea"
|
||||||
|
placeholder="마크다운으로 글을 작성하세요... 예시: # 제목 ## 소제목 **굵게** *기울임* `코드`"
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="blog-editor__footer">
|
||||||
|
<button type="button" className="button" onClick={onClose}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button blog-editor__save-btn"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !title.trim()}
|
||||||
|
>
|
||||||
|
{saving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 메인 Blog 컴포넌트 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const Blog = () => {
|
const Blog = () => {
|
||||||
const posts = useMemo(() => getBlogPosts(), []);
|
const staticPosts = useMemo(() => getBlogPosts(), []);
|
||||||
|
const [apiPosts, setApiPosts] = useState([]);
|
||||||
|
const [apiError, setApiError] = useState(false);
|
||||||
|
const [editorPost, setEditorPost] = useState(null); // null=닫힘, {}=새글, post=수정
|
||||||
|
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||||
|
|
||||||
|
// API 글 불러오기
|
||||||
|
useEffect(() => {
|
||||||
|
getBlogPostsApi()
|
||||||
|
.then((data) => {
|
||||||
|
const posts = Array.isArray(data) ? data : (data?.posts ?? []);
|
||||||
|
setApiPosts(posts.map((p) => ({ ...p, slug: `api-${p.id}` })));
|
||||||
|
})
|
||||||
|
.catch(() => setApiError(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 정적 + API 글 병합 (API 글이 앞에 표시)
|
||||||
|
const allPosts = useMemo(() => {
|
||||||
|
const combined = [...apiPosts, ...staticPosts];
|
||||||
|
return combined.sort((a, b) => {
|
||||||
|
const aDate = Date.parse(a.date || '') || 0;
|
||||||
|
const bDate = Date.parse(b.date || '') || 0;
|
||||||
|
return bDate - aDate;
|
||||||
|
});
|
||||||
|
}, [apiPosts, staticPosts]);
|
||||||
|
|
||||||
const categoryNames = ['일상', '개발', '공부', '아이디어'];
|
const categoryNames = ['일상', '개발', '공부', '아이디어'];
|
||||||
const categorized = useMemo(() => {
|
const categorized = useMemo(() => {
|
||||||
const map = new Map(categoryNames.map((name) => [name, []]));
|
const map = new Map(categoryNames.map((name) => [name, []]));
|
||||||
const misc = [];
|
const misc = [];
|
||||||
|
allPosts.forEach((post) => {
|
||||||
posts.forEach((post) => {
|
|
||||||
const matched = categoryNames.find((name) => post.tags.includes(name));
|
const matched = categoryNames.find((name) => post.tags.includes(name));
|
||||||
if (matched) {
|
if (matched) map.get(matched).push(post);
|
||||||
map.get(matched).push(post);
|
else misc.push(post);
|
||||||
} else {
|
|
||||||
misc.push(post);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
categories: categoryNames.map((name) => ({
|
categories: categoryNames.map((name) => ({ name, items: map.get(name) })),
|
||||||
name,
|
|
||||||
items: map.get(name),
|
|
||||||
})),
|
|
||||||
misc,
|
misc,
|
||||||
};
|
};
|
||||||
}, [posts]);
|
}, [allPosts]);
|
||||||
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState('전체');
|
const [selectedCategory, setSelectedCategory] = useState('전체');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [showList, setShowList] = useState(false);
|
const [showList, setShowList] = useState(false);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
|
||||||
const filteredPosts = useMemo(() => {
|
const filteredPosts = useMemo(() => {
|
||||||
if (selectedCategory === '전체') return posts;
|
if (selectedCategory === '전체') return allPosts;
|
||||||
if (selectedCategory === '기타') return categorized.misc;
|
if (selectedCategory === '기타') return categorized.misc;
|
||||||
return posts.filter((post) => post.tags.includes(selectedCategory));
|
return allPosts.filter((post) => post.tags.includes(selectedCategory));
|
||||||
}, [posts, categorized.misc, selectedCategory]);
|
}, [allPosts, categorized.misc, selectedCategory]);
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredPosts.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(filteredPosts.length / pageSize));
|
||||||
const pagedPosts = filteredPosts.slice((page - 1) * pageSize, page * pageSize);
|
const pagedPosts = filteredPosts.slice((page - 1) * pageSize, page * pageSize);
|
||||||
|
|
||||||
const [activeSlug, setActiveSlug] = useState(pagedPosts[0]?.slug);
|
const [activeSlug, setActiveSlug] = useState(pagedPosts[0]?.slug);
|
||||||
const activePost =
|
const activePost = pagedPosts.find((p) => p.slug === activeSlug) || pagedPosts[0];
|
||||||
pagedPosts.find((post) => post.slug === activeSlug) || pagedPosts[0];
|
|
||||||
|
|
||||||
|
useEffect(() => { if (page > totalPages) setPage(1); }, [page, totalPages]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (page > totalPages) {
|
if (!pagedPosts.find((p) => p.slug === activeSlug)) {
|
||||||
setPage(1);
|
|
||||||
}
|
|
||||||
}, [page, totalPages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pagedPosts.find((post) => post.slug === activeSlug)) {
|
|
||||||
setActiveSlug(pagedPosts[0]?.slug);
|
setActiveSlug(pagedPosts[0]?.slug);
|
||||||
}
|
}
|
||||||
}, [pagedPosts, activeSlug]);
|
}, [pagedPosts, activeSlug]);
|
||||||
|
useEffect(() => { setPage(1); }, [selectedCategory]);
|
||||||
|
|
||||||
useEffect(() => {
|
// 에디터 저장 핸들러
|
||||||
setPage(1);
|
const handleSave = useCallback(async (data) => {
|
||||||
}, [selectedCategory]);
|
if (editorPost?.id) {
|
||||||
|
// 수정
|
||||||
|
const updated = await updateBlogPost(editorPost.id, data);
|
||||||
|
setApiPosts((prev) =>
|
||||||
|
prev.map((p) =>
|
||||||
|
p.id === editorPost.id ? { ...p, ...updated, slug: `api-${updated.id ?? editorPost.id}` } : p
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 새 글
|
||||||
|
const created = await createBlogPost(data);
|
||||||
|
setApiPosts((prev) => [{ ...created, slug: `api-${created.id}` }, ...prev]);
|
||||||
|
setActiveSlug(`api-${created.id}`);
|
||||||
|
}
|
||||||
|
}, [editorPost]);
|
||||||
|
|
||||||
|
// 삭제 핸들러
|
||||||
|
const handleDelete = useCallback(async (post) => {
|
||||||
|
if (!window.confirm(`"${post.title}" 글을 삭제하시겠습니까?`)) return;
|
||||||
|
await deleteBlogPost(post.id);
|
||||||
|
setApiPosts((prev) => prev.filter((p) => p.id !== post.id));
|
||||||
|
if (activeSlug === post.slug) setActiveSlug(null);
|
||||||
|
}, [activeSlug]);
|
||||||
|
|
||||||
|
const openNewEditor = () => { setEditorPost({}); setIsEditorOpen(true); };
|
||||||
|
const openEditEditor = (post) => { setEditorPost(post); setIsEditorOpen(true); };
|
||||||
|
const closeEditor = useCallback(() => { setIsEditorOpen(false); setEditorPost(null); }, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="blog">
|
<div className="blog">
|
||||||
@@ -257,14 +456,19 @@ const Blog = () => {
|
|||||||
<p className="blog-kicker">Journal</p>
|
<p className="blog-kicker">Journal</p>
|
||||||
<h1>개인 블로그</h1>
|
<h1>개인 블로그</h1>
|
||||||
<p className="blog-sub">
|
<p className="blog-sub">
|
||||||
마크다운 파일을 추가하면 자동으로 글이 목록에 추가됩니다.
|
글을 작성하고 태그를 달아 정리하세요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="blog-status">
|
<div className="blog-header__actions">
|
||||||
<p className="blog-status__title">이번 주의 기록</p>
|
<div className="blog-status">
|
||||||
<p className="blog-status__desc">
|
<p className="blog-status__title">이번 주의 기록</p>
|
||||||
손에 닿는 생각을 즉시 적어두고, 나중에 다시 꺼내어 다듬습니다.
|
<p className="blog-status__desc">
|
||||||
</p>
|
손에 닿는 생각을 즉시 적어두고, 나중에 다시 꺼내어 다듬습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="blog-new-btn" onClick={openNewEditor}>
|
||||||
|
+ 새 글 쓰기
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -283,32 +487,54 @@ const Blog = () => {
|
|||||||
<button
|
<button
|
||||||
key={name}
|
key={name}
|
||||||
type="button"
|
type="button"
|
||||||
className={`blog-category-chip${
|
className={`blog-category-chip${selectedCategory === name ? ' is-active' : ''}`}
|
||||||
selectedCategory === name ? ' is-active' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedCategory(name)}
|
onClick={() => setSelectedCategory(name)}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pagedPosts.map((post) => (
|
{pagedPosts.map((post) => (
|
||||||
<button
|
<div
|
||||||
key={post.slug}
|
key={post.slug}
|
||||||
type="button"
|
className={`blog-list__item-wrap${post.slug === activeSlug ? ' is-active' : ''}`}
|
||||||
className={`blog-list__item${
|
|
||||||
post.slug === activeSlug ? ' is-active' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
setActiveSlug(post.slug);
|
|
||||||
setShowList(false); // 모바일에서 글 선택 시 리스트 숨김
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<p className="blog-list__title">{post.title}</p>
|
<button
|
||||||
<p className="blog-list__excerpt">{post.excerpt}</p>
|
type="button"
|
||||||
<span className="blog-list__meta">{post.date || '작성일 미정'}</span>
|
className="blog-list__item-btn"
|
||||||
</button>
|
onClick={() => {
|
||||||
|
setActiveSlug(post.slug);
|
||||||
|
setShowList(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="blog-list__title">{post.title}</p>
|
||||||
|
<p className="blog-list__excerpt">{post.excerpt}</p>
|
||||||
|
<span className="blog-list__meta">{post.date || '작성일 미정'}</span>
|
||||||
|
</button>
|
||||||
|
{post.id && (
|
||||||
|
<div className="blog-list__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="blog-list__action-btn"
|
||||||
|
title="수정"
|
||||||
|
onClick={() => openEditEditor(post)}
|
||||||
|
>
|
||||||
|
편집
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="blog-list__action-btn blog-list__action-btn--del"
|
||||||
|
title="삭제"
|
||||||
|
onClick={() => handleDelete(post)}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="blog-pagination">
|
<div className="blog-pagination">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -318,35 +544,41 @@ const Blog = () => {
|
|||||||
>
|
>
|
||||||
이전
|
이전
|
||||||
</button>
|
</button>
|
||||||
<span className="blog-page-indicator">
|
<span className="blog-page-indicator">{page} / {totalPages}</span>
|
||||||
{page} / {totalPages}
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="blog-page-btn"
|
className="blog-page-btn"
|
||||||
onClick={() =>
|
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
|
||||||
setPage((prev) => Math.min(totalPages, prev + 1))
|
|
||||||
}
|
|
||||||
disabled={page === totalPages}
|
disabled={page === totalPages}
|
||||||
>
|
>
|
||||||
다음
|
다음
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<article className="blog-article">
|
<article className="blog-article">
|
||||||
{activePost ? (
|
{activePost ? (
|
||||||
<>
|
<>
|
||||||
<div className="blog-article__meta">
|
<div className="blog-article__meta">
|
||||||
<span>{activePost.date || '작성일 미정'}</span>
|
<span>{activePost.date || '작성일 미정'}</span>
|
||||||
{activePost.tags.length > 0 && (
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
<span className="blog-tags">
|
{activePost.tags.length > 0 && (
|
||||||
{activePost.tags.map((tag) => (
|
<span className="blog-tags">
|
||||||
<span key={tag} className="blog-tag">
|
{activePost.tags.map((tag) => (
|
||||||
{tag}
|
<span key={tag} className="blog-tag">{tag}</span>
|
||||||
</span>
|
))}
|
||||||
))}
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
{activePost.id && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="blog-article__edit-btn"
|
||||||
|
onClick={() => openEditEditor(activePost)}
|
||||||
|
>
|
||||||
|
편집
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="blog-article__body">
|
<div className="blog-article__body">
|
||||||
{renderMarkdown(activePost.body)}
|
{renderMarkdown(activePost.body)}
|
||||||
@@ -354,8 +586,9 @@ const Blog = () => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="blog-empty">
|
<p className="blog-empty">
|
||||||
아직 작성된 글이 없습니다. `src/content/blog`에 마크다운 파일을
|
{apiError
|
||||||
추가해 주세요.
|
? '블로그 API에 연결할 수 없습니다. 백엔드 서버를 확인해 주세요.'
|
||||||
|
: '아직 작성된 글이 없습니다. 새 글 쓰기 버튼으로 첫 글을 작성해 보세요.'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
@@ -376,9 +609,7 @@ const Blog = () => {
|
|||||||
>
|
>
|
||||||
<div className="blog-category-card__head">
|
<div className="blog-category-card__head">
|
||||||
<span>{group.name}</span>
|
<span>{group.name}</span>
|
||||||
<span className="blog-category-card__count">
|
<span className="blog-category-card__count">{group.items.length}건</span>
|
||||||
{group.items.length}건
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="blog-category-card__list">
|
<div className="blog-category-card__list">
|
||||||
{group.items.length ? (
|
{group.items.length ? (
|
||||||
@@ -386,9 +617,7 @@ const Blog = () => {
|
|||||||
<span key={post.slug}>{post.title}</span>
|
<span key={post.slug}>{post.title}</span>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<span className="blog-category-card__empty">
|
<span className="blog-category-card__empty">아직 글이 없습니다.</span>
|
||||||
아직 글이 없습니다.
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -400,9 +629,7 @@ const Blog = () => {
|
|||||||
>
|
>
|
||||||
<div className="blog-category-card__head">
|
<div className="blog-category-card__head">
|
||||||
<span>기타</span>
|
<span>기타</span>
|
||||||
<span className="blog-category-card__count">
|
<span className="blog-category-card__count">{categorized.misc.length}건</span>
|
||||||
{categorized.misc.length}건
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="blog-category-card__list">
|
<div className="blog-category-card__list">
|
||||||
{categorized.misc.length ? (
|
{categorized.misc.length ? (
|
||||||
@@ -410,14 +637,20 @@ const Blog = () => {
|
|||||||
<span key={post.slug}>{post.title}</span>
|
<span key={post.slug}>{post.title}</span>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<span className="blog-category-card__empty">
|
<span className="blog-category-card__empty">아직 글이 없습니다.</span>
|
||||||
아직 글이 없습니다.
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{isEditorOpen && (
|
||||||
|
<BlogEditor
|
||||||
|
post={editorPost}
|
||||||
|
onSave={handleSave}
|
||||||
|
onClose={closeEditor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
448
src/pages/effect-lab/DayCalc.css
Normal file
448
src/pages/effect-lab/DayCalc.css
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
/* ── DayCalc ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.daycalc {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px 64px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.daycalc__back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
transition: color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__back:hover {
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
border-color: var(--line-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__kicker {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-lab);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-bright);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Input Section ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.daycalc__input-section {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__date-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__date-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__date-field label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__date-field input[type="date"] {
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-bright);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
width: 100%;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__date-field input[type="date"]:focus {
|
||||||
|
border-color: var(--neon-cyan-dim);
|
||||||
|
box-shadow: 0 0 0 3px var(--neon-cyan-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__date-fmt {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__arrow {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__arrow .fwd { color: var(--neon-cyan); }
|
||||||
|
.daycalc__arrow .bwd { color: var(--neon-purple); }
|
||||||
|
|
||||||
|
/* ── Presets ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.daycalc__presets {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__presets-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__preset-btn {
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.18s, color 0.18s, background 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__preset-btn:hover {
|
||||||
|
border-color: var(--neon-cyan-dim);
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
background: var(--neon-cyan-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__preset-btn--clear {
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__preset-btn--clear:hover {
|
||||||
|
border-color: rgba(248, 113, 113, 0.4);
|
||||||
|
color: #f87171;
|
||||||
|
background: rgba(248, 113, 113, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tabs ────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.daycalc__tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__tab {
|
||||||
|
padding: 8px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
transition: color 0.18s, border-color 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__tab:hover {
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__tab.is-active {
|
||||||
|
color: var(--accent-lab);
|
||||||
|
border-bottom-color: var(--accent-lab);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Result Section ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.daycalc__result {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__big-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__big-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 24px 20px;
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__big-card--primary {
|
||||||
|
border-color: rgba(251, 191, 36, 0.3);
|
||||||
|
background: rgba(251, 191, 36, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__big-num {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-bright);
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__big-card--primary .daycalc__big-num {
|
||||||
|
color: var(--accent-lab);
|
||||||
|
text-shadow: 0 0 24px rgba(251, 191, 36, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__big-label {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__big-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Breakdown ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.daycalc__breakdown {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__breakdown h3 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__breakdown-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__breakdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__breakdown-num {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__breakdown-unit {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__breakdown-summary {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-top: 1px solid var(--line-subtle);
|
||||||
|
padding-top: 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Milestones ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.daycalc__milestones {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__milestones-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__milestones-desc strong {
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__milestone-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__milestone-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100px 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: border-color 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__milestone-row:hover {
|
||||||
|
border-color: rgba(251, 191, 36, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__milestone-row.is-past {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__milestone-row.is-today {
|
||||||
|
border-color: var(--accent-lab);
|
||||||
|
background: rgba(251, 191, 36, 0.06);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__milestone-badge {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-lab);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__milestone-row.is-past .daycalc__milestone-badge {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__milestone-date {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__milestone-dday {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__milestone-row.is-today .daycalc__milestone-dday {
|
||||||
|
color: var(--accent-lab);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty State ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.daycalc__empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__empty-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__empty p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.daycalc {
|
||||||
|
padding: 20px 16px 48px;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__date-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__arrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__big-cards {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__milestone-row {
|
||||||
|
grid-template-columns: 80px 1fr auto;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__breakdown-row {
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daycalc__breakdown-num {
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
}
|
||||||
275
src/pages/effect-lab/DayCalc.jsx
Normal file
275
src/pages/effect-lab/DayCalc.jsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import './DayCalc.css';
|
||||||
|
|
||||||
|
const today = () => new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const fmt = (d) => {
|
||||||
|
if (!d) return '';
|
||||||
|
const [y, m, day] = d.split('-');
|
||||||
|
return `${y}년 ${parseInt(m)}월 ${parseInt(day)}일`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 두 날짜 사이 diff 계산
|
||||||
|
const calcDiff = (from, to) => {
|
||||||
|
const f = new Date(from);
|
||||||
|
const t = new Date(to);
|
||||||
|
|
||||||
|
const totalMs = t - f;
|
||||||
|
const totalDays = Math.round(totalMs / 86400000);
|
||||||
|
|
||||||
|
// 연/월/일 분리 계산
|
||||||
|
let years = t.getFullYear() - f.getFullYear();
|
||||||
|
let months = t.getMonth() - f.getMonth();
|
||||||
|
let days = t.getDate() - f.getDate();
|
||||||
|
|
||||||
|
if (days < 0) {
|
||||||
|
months -= 1;
|
||||||
|
const prevMonth = new Date(t.getFullYear(), t.getMonth(), 0);
|
||||||
|
days += prevMonth.getDate();
|
||||||
|
}
|
||||||
|
if (months < 0) {
|
||||||
|
years -= 1;
|
||||||
|
months += 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMonths = years * 12 + months;
|
||||||
|
const weeks = Math.floor(Math.abs(totalDays) / 7);
|
||||||
|
const remDays = Math.abs(totalDays) % 7;
|
||||||
|
|
||||||
|
return { totalDays, totalMonths, years, months, days, weeks, remDays };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 특정 날짜로부터 N일 후 날짜 계산
|
||||||
|
const addDays = (dateStr, n) => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
d.setDate(d.getDate() + n);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기념일 체크포인트
|
||||||
|
const MILESTONES = [100, 200, 365, 500, 730, 1000, 1461, 2000, 3000];
|
||||||
|
|
||||||
|
const QUICK_PRESETS = [
|
||||||
|
{ label: '오늘 기준', offset: 0 },
|
||||||
|
{ label: '1주 후', offset: 7 },
|
||||||
|
{ label: '1개월 후', offset: 30 },
|
||||||
|
{ label: '3개월 후', offset: 90 },
|
||||||
|
{ label: '6개월 후', offset: 180 },
|
||||||
|
{ label: '1년 후', offset: 365 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DayCalc = () => {
|
||||||
|
const [fromDate, setFromDate] = useState('');
|
||||||
|
const [toDate, setToDate] = useState(today());
|
||||||
|
const [tab, setTab] = useState('diff'); // diff | milestone | future
|
||||||
|
|
||||||
|
const result = useMemo(() => {
|
||||||
|
if (!fromDate || !toDate) return null;
|
||||||
|
try {
|
||||||
|
return calcDiff(fromDate, toDate);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [fromDate, toDate]);
|
||||||
|
|
||||||
|
const milestones = useMemo(() => {
|
||||||
|
if (!fromDate) return [];
|
||||||
|
return MILESTONES.map((n) => ({
|
||||||
|
days: n,
|
||||||
|
date: addDays(fromDate, n - 1),
|
||||||
|
}));
|
||||||
|
}, [fromDate]);
|
||||||
|
|
||||||
|
const isForward = result ? result.totalDays >= 0 : true;
|
||||||
|
|
||||||
|
const applyPreset = (offset) => {
|
||||||
|
setToDate(addDays(today(), offset));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="daycalc">
|
||||||
|
<header className="daycalc__header">
|
||||||
|
<div>
|
||||||
|
<Link to="/lab" className="daycalc__back">← Lab</Link>
|
||||||
|
<p className="daycalc__kicker">Lab · 날짜 도구</p>
|
||||||
|
<h1>일수 계산기</h1>
|
||||||
|
<p className="daycalc__desc">두 날짜 사이의 기간과 기념일 날짜를 계산합니다.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 날짜 입력 */}
|
||||||
|
<section className="daycalc__input-section">
|
||||||
|
<div className="daycalc__date-row">
|
||||||
|
<div className="daycalc__date-field">
|
||||||
|
<label>시작일</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={fromDate}
|
||||||
|
onChange={(e) => setFromDate(e.target.value)}
|
||||||
|
max={toDate || undefined}
|
||||||
|
/>
|
||||||
|
{fromDate && <span className="daycalc__date-fmt">{fmt(fromDate)}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="daycalc__arrow">
|
||||||
|
{result
|
||||||
|
? <span className={isForward ? 'fwd' : 'bwd'}>{isForward ? '→' : '←'}</span>
|
||||||
|
: <span>↔</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="daycalc__date-field">
|
||||||
|
<label>종료일</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={toDate}
|
||||||
|
onChange={(e) => setToDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
{toDate && <span className="daycalc__date-fmt">{fmt(toDate)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 빠른 종료일 설정 */}
|
||||||
|
<div className="daycalc__presets">
|
||||||
|
<span className="daycalc__presets-label">빠른 설정</span>
|
||||||
|
{QUICK_PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.label}
|
||||||
|
className="daycalc__preset-btn"
|
||||||
|
onClick={() => applyPreset(p.offset)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
className="daycalc__preset-btn daycalc__preset-btn--clear"
|
||||||
|
onClick={() => { setFromDate(''); setToDate(today()); }}
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 결과 탭 */}
|
||||||
|
{fromDate && (
|
||||||
|
<>
|
||||||
|
<div className="daycalc__tabs">
|
||||||
|
{[
|
||||||
|
{ id: 'diff', label: '기간 계산' },
|
||||||
|
{ id: 'milestone', label: '기념일' },
|
||||||
|
].map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
className={`daycalc__tab${tab === t.id ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기간 계산 탭 */}
|
||||||
|
{tab === 'diff' && result && (
|
||||||
|
<section className="daycalc__result">
|
||||||
|
{/* 메인 수치 */}
|
||||||
|
<div className="daycalc__big-cards">
|
||||||
|
<div className="daycalc__big-card daycalc__big-card--primary">
|
||||||
|
<p className="daycalc__big-num">
|
||||||
|
{isForward ? '+' : ''}{result.totalDays.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="daycalc__big-label">일</p>
|
||||||
|
<p className="daycalc__big-sub">
|
||||||
|
{isForward ? '경과' : '이전'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="daycalc__big-card">
|
||||||
|
<p className="daycalc__big-num">{result.totalMonths.toLocaleString()}</p>
|
||||||
|
<p className="daycalc__big-label">개월</p>
|
||||||
|
<p className="daycalc__big-sub">총 개월 수</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="daycalc__big-card">
|
||||||
|
<p className="daycalc__big-num">{result.weeks.toLocaleString()}</p>
|
||||||
|
<p className="daycalc__big-label">주 {result.remDays}일</p>
|
||||||
|
<p className="daycalc__big-sub">주 단위</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 세부 분해 */}
|
||||||
|
<div className="daycalc__breakdown">
|
||||||
|
<h3>상세 기간</h3>
|
||||||
|
<div className="daycalc__breakdown-row">
|
||||||
|
{result.years > 0 && (
|
||||||
|
<div className="daycalc__breakdown-item">
|
||||||
|
<span className="daycalc__breakdown-num">{result.years}</span>
|
||||||
|
<span className="daycalc__breakdown-unit">년</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="daycalc__breakdown-item">
|
||||||
|
<span className="daycalc__breakdown-num">{result.months}</span>
|
||||||
|
<span className="daycalc__breakdown-unit">개월</span>
|
||||||
|
</div>
|
||||||
|
<div className="daycalc__breakdown-item">
|
||||||
|
<span className="daycalc__breakdown-num">{result.days}</span>
|
||||||
|
<span className="daycalc__breakdown-unit">일</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="daycalc__breakdown-summary">
|
||||||
|
{fmt(fromDate)} 부터 {fmt(toDate)} 까지
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 기념일 탭 */}
|
||||||
|
{tab === 'milestone' && (
|
||||||
|
<section className="daycalc__milestones">
|
||||||
|
<p className="daycalc__milestones-desc">
|
||||||
|
<strong>{fmt(fromDate)}</strong> 을 기준으로 한 기념일 날짜입니다.
|
||||||
|
</p>
|
||||||
|
<div className="daycalc__milestone-list">
|
||||||
|
{milestones.map(({ days, date }) => {
|
||||||
|
const isPast = date < today();
|
||||||
|
const isToday = date === today();
|
||||||
|
const diff = calcDiff(today(), date);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={days}
|
||||||
|
className={`daycalc__milestone-row${isPast ? ' is-past' : ''}${isToday ? ' is-today' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="daycalc__milestone-badge">
|
||||||
|
{days < 365
|
||||||
|
? `D+${days}`
|
||||||
|
: days % 365 === 0
|
||||||
|
? `${days / 365}주년`
|
||||||
|
: `D+${days}`}
|
||||||
|
</div>
|
||||||
|
<div className="daycalc__milestone-date">{fmt(date)}</div>
|
||||||
|
<div className="daycalc__milestone-dday">
|
||||||
|
{isToday
|
||||||
|
? '🎉 오늘'
|
||||||
|
: isPast
|
||||||
|
? `${Math.abs(diff.totalDays)}일 전`
|
||||||
|
: `D-${diff.totalDays}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!fromDate && (
|
||||||
|
<div className="daycalc__empty">
|
||||||
|
<p className="daycalc__empty-icon">📅</p>
|
||||||
|
<p>시작일을 입력하면 기간 계산을 시작합니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DayCalc;
|
||||||
@@ -1,59 +1,196 @@
|
|||||||
.effect-lab {
|
/* ── Lab Landing Page ────────────────────────────────────────────────────── */
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
.lab {
|
||||||
height: 100%;
|
max-width: 1000px;
|
||||||
min-height: calc(100vh - 80px);
|
margin: 0 auto;
|
||||||
/* Adjust based on navbar height */
|
padding: 40px 24px 80px;
|
||||||
overflow: hidden;
|
|
||||||
background-color: #050505;
|
|
||||||
border-radius: 20px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.effect-lab canvas {
|
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||||
display: block;
|
|
||||||
outline: none;
|
.lab__header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.effect-lab-overlay {
|
.lab__kicker {
|
||||||
position: absolute;
|
font-size: 11px;
|
||||||
top: 20px;
|
font-weight: 700;
|
||||||
left: 20px;
|
letter-spacing: 0.12em;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
text-transform: uppercase;
|
||||||
pointer-events: none;
|
color: var(--accent-lab);
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.effect-lab-overlay h2 {
|
.lab__header h1 {
|
||||||
margin: 0 0 8px;
|
font-size: 36px;
|
||||||
font-family: var(--font-display);
|
font-weight: 700;
|
||||||
font-size: 28px;
|
color: var(--text-bright);
|
||||||
color: var(--text);
|
line-height: 1.1;
|
||||||
text-shadow: 0 0 20px rgba(68, 170, 221, 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-mode {
|
.lab__desc {
|
||||||
display: inline-block;
|
|
||||||
background: rgba(68, 170, 221, 0.1);
|
|
||||||
border: 1px solid rgba(68, 170, 221, 0.3);
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 99px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #44aadd;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.active-mode span {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.effect-lab-overlay p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--muted);
|
color: var(--text-dim);
|
||||||
max-width: 400px;
|
max-width: 560px;
|
||||||
line-height: 1.5;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Grid ────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.lab__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Lab Card ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.lab-card {
|
||||||
|
--card-accent: var(--neon-cyan);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.22s, transform 0.22s, box-shadow 0.22s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, color-mix(in srgb, var(--card-accent) 8%, transparent), transparent 60%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.22s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-card:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--card-accent) 40%, transparent);
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px color-mix(in srgb, var(--card-accent) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-card:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card Top ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.lab-card__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-card__icon {
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1;
|
||||||
|
filter: drop-shadow(0 0 10px color-mix(in srgb, var(--card-accent) 50%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-card__status {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card Body ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.lab-card__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-card__category {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: color-mix(in srgb, var(--card-accent) 80%, var(--text-dim));
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-card__title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-bright);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-card__desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card Footer ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.lab-card__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-card__tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-card__tag {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-card__arrow {
|
||||||
|
font-size: 18px;
|
||||||
|
color: color-mix(in srgb, var(--card-accent) 60%, transparent);
|
||||||
|
transition: transform 0.18s, color 0.18s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-card:hover .lab-card__arrow {
|
||||||
|
transform: translateX(4px);
|
||||||
|
color: var(--card-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.lab {
|
||||||
|
padding: 24px 16px 60px;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab__header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,218 +1,92 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React from 'react';
|
||||||
import * as THREE from 'three';
|
import { Link } from 'react-router-dom';
|
||||||
import './EffectLab.css';
|
import './EffectLab.css';
|
||||||
|
|
||||||
const EffectLab = () => {
|
const LAB_ITEMS = [
|
||||||
const containerRef = useRef(null);
|
{
|
||||||
const requestRef = useRef();
|
id: 'sword-stream',
|
||||||
const [mode, setMode] = useState('HOVER'); // HOVER, ATTACK, ORBIT
|
path: '/lab/sword-stream',
|
||||||
|
title: 'Sword Stream',
|
||||||
|
category: '3D · 인터랙티브',
|
||||||
|
desc: '1,500개의 검 파티클이 마우스를 따라 흐릅니다. 클릭하면 나선형 궤도로 전환됩니다.',
|
||||||
|
tags: ['Three.js', '파티클', '인터랙티브'],
|
||||||
|
accent: '#44aadd',
|
||||||
|
icon: '⚔️',
|
||||||
|
status: 'live',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'day-calc',
|
||||||
|
path: '/lab/day-calc',
|
||||||
|
title: '일수 계산기',
|
||||||
|
category: '유틸리티 · 날짜',
|
||||||
|
desc: '두 날짜 사이의 기간을 일, 주, 월, 연 단위로 계산하고 기념일 날짜를 확인합니다.',
|
||||||
|
tags: ['날짜', '계산기', '기념일'],
|
||||||
|
accent: '#fbbf24',
|
||||||
|
icon: '📅',
|
||||||
|
status: 'live',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'agent-office',
|
||||||
|
path: '/agent-office',
|
||||||
|
title: 'Agent Office',
|
||||||
|
category: 'AI · 자동화',
|
||||||
|
desc: 'AI 에이전트들이 사무실에서 자동으로 작업하는 가상 오피스',
|
||||||
|
tags: ['Canvas 2D', 'WebSocket', 'AI Agent', 'Telegram'],
|
||||||
|
accent: '#8b5cf6',
|
||||||
|
icon: '🏢',
|
||||||
|
status: 'wip',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
const STATUS_LABEL = {
|
||||||
if (!containerRef.current) return;
|
live: { label: 'LIVE', color: '#34d399' },
|
||||||
|
wip: { label: 'WIP', color: '#fbbf24' },
|
||||||
// --- Configuration ---
|
planned: { label: 'PLANNED', color: '#94a3b8' },
|
||||||
const COUNT = 1500;
|
};
|
||||||
const SWORD_COLOR = 0x44aadd;
|
|
||||||
const SWORD_EMISSIVE = 0x112244;
|
|
||||||
|
|
||||||
// --- Helper: Random Range ---
|
|
||||||
const rand = (min, max) => Math.random() * (max - min) + min;
|
|
||||||
|
|
||||||
// --- Setup Scene ---
|
|
||||||
const width = containerRef.current.clientWidth;
|
|
||||||
const height = containerRef.current.clientHeight;
|
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
|
||||||
scene.fog = new THREE.FogExp2(0x050505, 0.002);
|
|
||||||
|
|
||||||
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
|
||||||
camera.position.z = 80;
|
|
||||||
|
|
||||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
||||||
renderer.setSize(width, height);
|
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
||||||
// Tone mapping for better glow look
|
|
||||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
||||||
containerRef.current.appendChild(renderer.domElement);
|
|
||||||
|
|
||||||
// --- Lighting ---
|
|
||||||
const ambientLight = new THREE.AmbientLight(0x404040);
|
|
||||||
scene.add(ambientLight);
|
|
||||||
|
|
||||||
const pointLight = new THREE.PointLight(SWORD_COLOR, 2, 100);
|
|
||||||
scene.add(pointLight);
|
|
||||||
|
|
||||||
// --- Geometry & Material ---
|
|
||||||
// Sword shape: Cone stretched
|
|
||||||
const geometry = new THREE.CylinderGeometry(0.1, 0.4, 4, 8);
|
|
||||||
geometry.rotateX(Math.PI / 2); // Point towards Z
|
|
||||||
|
|
||||||
const material = new THREE.MeshPhongMaterial({
|
|
||||||
color: SWORD_COLOR,
|
|
||||||
emissive: SWORD_EMISSIVE,
|
|
||||||
shininess: 100,
|
|
||||||
flatShading: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mesh = new THREE.InstancedMesh(geometry, material, COUNT);
|
|
||||||
scene.add(mesh);
|
|
||||||
|
|
||||||
// --- Particle Data ---
|
|
||||||
const dummy = new THREE.Object3D();
|
|
||||||
const particles = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < COUNT; i++) {
|
|
||||||
particles.push({
|
|
||||||
pos: new THREE.Vector3(rand(-100, 100), rand(-100, 100), rand(-50, 50)),
|
|
||||||
vel: new THREE.Vector3(),
|
|
||||||
acc: new THREE.Vector3(),
|
|
||||||
// Orbit parameters
|
|
||||||
angle: rand(0, Math.PI * 2),
|
|
||||||
radius: rand(15, 30),
|
|
||||||
speed: rand(0.02, 0.05),
|
|
||||||
// Offset for natural movement
|
|
||||||
offset: new THREE.Vector3(rand(-5, 5), rand(-5, 5), rand(-5, 5))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Mouse & Interaction State ---
|
|
||||||
const mouse = new THREE.Vector3();
|
|
||||||
const target = new THREE.Vector3();
|
|
||||||
const mousePlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
|
|
||||||
const raycaster = new THREE.Raycaster();
|
|
||||||
|
|
||||||
let isMouseDown = false;
|
|
||||||
let time = 0;
|
|
||||||
|
|
||||||
const handleMouseMove = (e) => {
|
|
||||||
const rect = renderer.domElement.getBoundingClientRect();
|
|
||||||
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
||||||
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
||||||
|
|
||||||
raycaster.setFromCamera(new THREE.Vector2(x, y), camera);
|
|
||||||
raycaster.ray.intersectPlane(mousePlane, mouse);
|
|
||||||
|
|
||||||
// Allow light to follow mouse
|
|
||||||
pointLight.position.copy(mouse);
|
|
||||||
pointLight.position.z = 20;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseDown = () => { isMouseDown = true; setMode('ORBIT'); };
|
|
||||||
const handleMouseUp = () => { isMouseDown = false; setMode('HOVER'); };
|
|
||||||
|
|
||||||
window.addEventListener('mousemove', handleMouseMove);
|
|
||||||
window.addEventListener('mousedown', handleMouseDown);
|
|
||||||
window.addEventListener('mouseup', handleMouseUp);
|
|
||||||
|
|
||||||
// --- Animation Loop ---
|
|
||||||
const animate = () => {
|
|
||||||
requestRef.current = requestAnimationFrame(animate);
|
|
||||||
time += 0.01;
|
|
||||||
|
|
||||||
for (let i = 0; i < COUNT; i++) {
|
|
||||||
const p = particles[i];
|
|
||||||
|
|
||||||
// --- Behavior Logic ---
|
|
||||||
if (isMouseDown) {
|
|
||||||
// 1. ORBIT MODE: Rotate around mouse
|
|
||||||
p.angle += p.speed + 0.02; // Spin faster
|
|
||||||
|
|
||||||
const orbitX = mouse.x + Math.cos(p.angle + time) * p.radius;
|
|
||||||
const orbitY = mouse.y + Math.sin(p.angle + time) * p.radius;
|
|
||||||
// Spiraling Z for depth
|
|
||||||
const orbitZ = Math.sin(p.angle * 2 + time) * 10;
|
|
||||||
|
|
||||||
target.set(orbitX, orbitY, orbitZ);
|
|
||||||
|
|
||||||
// Strong pull to orbit positions
|
|
||||||
p.acc.subVectors(target, p.pos).multiplyScalar(0.08);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// 2. HOVER/FOLLOW MODE: Follow mouse with flocking feel
|
|
||||||
|
|
||||||
// Add noise/wandering
|
|
||||||
const noiseX = Math.sin(time + i * 0.1) * 5;
|
|
||||||
const noiseY = Math.cos(time + i * 0.1) * 5;
|
|
||||||
|
|
||||||
target.copy(mouse).add(p.offset).add(new THREE.Vector3(noiseX, noiseY, 0));
|
|
||||||
|
|
||||||
// Gentle pull
|
|
||||||
p.acc.subVectors(target, p.pos).multiplyScalar(0.008);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Physics update
|
|
||||||
p.vel.add(p.acc);
|
|
||||||
p.vel.multiplyScalar(isMouseDown ? 0.90 : 0.94); // Drag
|
|
||||||
p.pos.add(p.vel);
|
|
||||||
|
|
||||||
// Update Matrix
|
|
||||||
dummy.position.copy(p.pos);
|
|
||||||
|
|
||||||
// Rotation: Look at velocity direction (dynamic) or mouse (focused)
|
|
||||||
// Blending lookAt target for smoother rotation
|
|
||||||
const lookPos = new THREE.Vector3().copy(p.pos).add(p.vel.clone().multiplyScalar(5));
|
|
||||||
|
|
||||||
// If moving very slowly, keep previous rotation to avoid jitter
|
|
||||||
if (p.vel.lengthSq() > 0.01) {
|
|
||||||
dummy.lookAt(lookPos);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scale effect based on speed (stretch when fast)
|
|
||||||
const speedScale = 1 + Math.min(p.vel.length(), 2) * 0.5;
|
|
||||||
dummy.scale.set(1, 1, speedScale);
|
|
||||||
|
|
||||||
dummy.updateMatrix();
|
|
||||||
mesh.setMatrixAt(i, dummy.matrix);
|
|
||||||
}
|
|
||||||
|
|
||||||
mesh.instanceMatrix.needsUpdate = true;
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
};
|
|
||||||
|
|
||||||
animate();
|
|
||||||
|
|
||||||
// --- Resize ---
|
|
||||||
const handleResize = () => {
|
|
||||||
if (!containerRef.current) return;
|
|
||||||
const newWidth = containerRef.current.clientWidth;
|
|
||||||
const newHeight = containerRef.current.clientHeight;
|
|
||||||
|
|
||||||
camera.aspect = newWidth / newHeight;
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
renderer.setSize(newWidth, newHeight);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
window.removeEventListener('mousedown', handleMouseDown);
|
|
||||||
window.removeEventListener('mouseup', handleMouseUp);
|
|
||||||
window.removeEventListener('resize', handleResize);
|
|
||||||
cancelAnimationFrame(requestRef.current);
|
|
||||||
if (containerRef.current && renderer.domElement) {
|
|
||||||
containerRef.current.removeChild(renderer.domElement);
|
|
||||||
}
|
|
||||||
geometry.dispose();
|
|
||||||
material.dispose();
|
|
||||||
renderer.dispose();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
const LabCard = ({ item }) => {
|
||||||
|
const st = STATUS_LABEL[item.status] || STATUS_LABEL.planned;
|
||||||
return (
|
return (
|
||||||
<div className="effect-lab" ref={containerRef}>
|
<Link to={item.path} className="lab-card" style={{ '--card-accent': item.accent }}>
|
||||||
<div className="effect-lab-overlay">
|
<div className="lab-card__top">
|
||||||
<h2>Sword Stream</h2>
|
<span className="lab-card__icon">{item.icon}</span>
|
||||||
<div className="active-mode">
|
<span className="lab-card__status" style={{ color: st.color, borderColor: `${st.color}40` }}>
|
||||||
MODE: <span>{mode}</span>
|
{st.label}
|
||||||
</div>
|
</span>
|
||||||
<p>
|
|
||||||
<strong>Move</strong> to Guide |
|
|
||||||
<strong>Click & Hold</strong> to Orbit & Charge
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="lab-card__body">
|
||||||
|
<p className="lab-card__category">{item.category}</p>
|
||||||
|
<h2 className="lab-card__title">{item.title}</h2>
|
||||||
|
<p className="lab-card__desc">{item.desc}</p>
|
||||||
|
</div>
|
||||||
|
<div className="lab-card__footer">
|
||||||
|
<div className="lab-card__tags">
|
||||||
|
{item.tags.map((t) => (
|
||||||
|
<span key={t} className="lab-card__tag">{t}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="lab-card__arrow">→</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EffectLab = () => (
|
||||||
|
<div className="lab">
|
||||||
|
<header className="lab__header">
|
||||||
|
<p className="lab__kicker">STREAM</p>
|
||||||
|
<h1>Lab</h1>
|
||||||
|
<p className="lab__desc">
|
||||||
|
실험적인 UI, 인터랙티브 효과, 유틸리티 도구를 테스트하고 탐구하는 공간입니다.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="lab__grid">
|
||||||
|
{LAB_ITEMS.map((item) => (
|
||||||
|
<LabCard key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export default EffectLab;
|
export default EffectLab;
|
||||||
|
|||||||
82
src/pages/effect-lab/SwordStream.css
Normal file
82
src/pages/effect-lab/SwordStream.css
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
.sword-stream {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: calc(100vh - 80px);
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #050505;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sword-stream canvas {
|
||||||
|
display: block;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sword-stream__overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sword-stream__back {
|
||||||
|
pointer-events: all;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(68, 170, 221, 0.7);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid rgba(68, 170, 221, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(68, 170, 221, 0.06);
|
||||||
|
transition: color 0.2s, border-color 0.2s, background 0.2s;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sword-stream__back:hover {
|
||||||
|
color: #44aadd;
|
||||||
|
border-color: rgba(68, 170, 221, 0.5);
|
||||||
|
background: rgba(68, 170, 221, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sword-stream__overlay h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 28px;
|
||||||
|
color: var(--text);
|
||||||
|
text-shadow: 0 0 20px rgba(68, 170, 221, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sword-stream__mode {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(68, 170, 221, 0.1);
|
||||||
|
border: 1px solid rgba(68, 170, 221, 0.3);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 99px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #44aadd;
|
||||||
|
font-weight: 600;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sword-stream__mode span {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sword-stream__overlay p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 400px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
184
src/pages/effect-lab/SwordStream.jsx
Normal file
184
src/pages/effect-lab/SwordStream.jsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import './SwordStream.css';
|
||||||
|
|
||||||
|
const SwordStream = () => {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const requestRef = useRef();
|
||||||
|
const [mode, setMode] = useState('HOVER'); // HOVER, ORBIT
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const COUNT = 1500;
|
||||||
|
const SWORD_COLOR = 0x44aadd;
|
||||||
|
const SWORD_EMISSIVE = 0x112244;
|
||||||
|
|
||||||
|
const rand = (min, max) => Math.random() * (max - min) + min;
|
||||||
|
|
||||||
|
const width = containerRef.current.clientWidth;
|
||||||
|
const height = containerRef.current.clientHeight;
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.fog = new THREE.FogExp2(0x050505, 0.002);
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
||||||
|
camera.position.z = 80;
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
containerRef.current.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
const ambientLight = new THREE.AmbientLight(0x404040);
|
||||||
|
scene.add(ambientLight);
|
||||||
|
|
||||||
|
const pointLight = new THREE.PointLight(SWORD_COLOR, 2, 100);
|
||||||
|
scene.add(pointLight);
|
||||||
|
|
||||||
|
const geometry = new THREE.CylinderGeometry(0.1, 0.4, 4, 8);
|
||||||
|
geometry.rotateX(Math.PI / 2);
|
||||||
|
|
||||||
|
const material = new THREE.MeshPhongMaterial({
|
||||||
|
color: SWORD_COLOR,
|
||||||
|
emissive: SWORD_EMISSIVE,
|
||||||
|
shininess: 100,
|
||||||
|
flatShading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mesh = new THREE.InstancedMesh(geometry, material, COUNT);
|
||||||
|
scene.add(mesh);
|
||||||
|
|
||||||
|
const dummy = new THREE.Object3D();
|
||||||
|
const particles = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
particles.push({
|
||||||
|
pos: new THREE.Vector3(rand(-100, 100), rand(-100, 100), rand(-50, 50)),
|
||||||
|
vel: new THREE.Vector3(),
|
||||||
|
acc: new THREE.Vector3(),
|
||||||
|
angle: rand(0, Math.PI * 2),
|
||||||
|
radius: rand(15, 30),
|
||||||
|
speed: rand(0.02, 0.05),
|
||||||
|
offset: new THREE.Vector3(rand(-5, 5), rand(-5, 5), rand(-5, 5)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const mouse = new THREE.Vector3();
|
||||||
|
const target = new THREE.Vector3();
|
||||||
|
const mousePlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
|
||||||
|
const raycaster = new THREE.Raycaster();
|
||||||
|
|
||||||
|
let isMouseDown = false;
|
||||||
|
let time = 0;
|
||||||
|
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
const rect = renderer.domElement.getBoundingClientRect();
|
||||||
|
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
|
||||||
|
raycaster.setFromCamera(new THREE.Vector2(x, y), camera);
|
||||||
|
raycaster.ray.intersectPlane(mousePlane, mouse);
|
||||||
|
|
||||||
|
pointLight.position.copy(mouse);
|
||||||
|
pointLight.position.z = 20;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = () => { isMouseDown = true; setMode('ORBIT'); };
|
||||||
|
const handleMouseUp = () => { isMouseDown = false; setMode('HOVER'); };
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
|
window.addEventListener('mousedown', handleMouseDown);
|
||||||
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
requestRef.current = requestAnimationFrame(animate);
|
||||||
|
time += 0.01;
|
||||||
|
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
const p = particles[i];
|
||||||
|
|
||||||
|
if (isMouseDown) {
|
||||||
|
p.angle += p.speed + 0.02;
|
||||||
|
const orbitX = mouse.x + Math.cos(p.angle + time) * p.radius;
|
||||||
|
const orbitY = mouse.y + Math.sin(p.angle + time) * p.radius;
|
||||||
|
const orbitZ = Math.sin(p.angle * 2 + time) * 10;
|
||||||
|
target.set(orbitX, orbitY, orbitZ);
|
||||||
|
p.acc.subVectors(target, p.pos).multiplyScalar(0.08);
|
||||||
|
} else {
|
||||||
|
const noiseX = Math.sin(time + i * 0.1) * 5;
|
||||||
|
const noiseY = Math.cos(time + i * 0.1) * 5;
|
||||||
|
target.copy(mouse).add(p.offset).add(new THREE.Vector3(noiseX, noiseY, 0));
|
||||||
|
p.acc.subVectors(target, p.pos).multiplyScalar(0.008);
|
||||||
|
}
|
||||||
|
|
||||||
|
p.vel.add(p.acc);
|
||||||
|
p.vel.multiplyScalar(isMouseDown ? 0.90 : 0.94);
|
||||||
|
p.pos.add(p.vel);
|
||||||
|
|
||||||
|
dummy.position.copy(p.pos);
|
||||||
|
|
||||||
|
const lookPos = new THREE.Vector3().copy(p.pos).add(p.vel.clone().multiplyScalar(5));
|
||||||
|
if (p.vel.lengthSq() > 0.01) {
|
||||||
|
dummy.lookAt(lookPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
const speedScale = 1 + Math.min(p.vel.length(), 2) * 0.5;
|
||||||
|
dummy.scale.set(1, 1, speedScale);
|
||||||
|
|
||||||
|
dummy.updateMatrix();
|
||||||
|
mesh.setMatrixAt(i, dummy.matrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
};
|
||||||
|
|
||||||
|
animate();
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const newWidth = containerRef.current.clientWidth;
|
||||||
|
const newHeight = containerRef.current.clientHeight;
|
||||||
|
camera.aspect = newWidth / newHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(newWidth, newHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
window.removeEventListener('mousedown', handleMouseDown);
|
||||||
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
cancelAnimationFrame(requestRef.current);
|
||||||
|
if (containerRef.current && renderer.domElement) {
|
||||||
|
containerRef.current.removeChild(renderer.domElement);
|
||||||
|
}
|
||||||
|
geometry.dispose();
|
||||||
|
material.dispose();
|
||||||
|
renderer.dispose();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sword-stream" ref={containerRef}>
|
||||||
|
<div className="sword-stream__overlay">
|
||||||
|
<Link to="/lab" className="sword-stream__back">← Lab</Link>
|
||||||
|
<h2>Sword Stream</h2>
|
||||||
|
<div className="sword-stream__mode">
|
||||||
|
MODE: <span>{mode}</span>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<strong>Move</strong> to Guide |
|
||||||
|
<strong>Click & Hold</strong> to Orbit & Charge
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SwordStream;
|
||||||
@@ -1,77 +1,113 @@
|
|||||||
|
/* ═══════════════════════════════════════════════════════════════════════
|
||||||
|
Home Page — Dashboard Style
|
||||||
|
═══════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
.home {
|
.home {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 60px;
|
gap: 32px;
|
||||||
|
animation: fadeIn 0.4s var(--ease-out) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home > section {
|
/* ── Hero ────────────────────────────────────────────────────────────── */
|
||||||
animation: fadeUp 0.7s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home > section:nth-child(1) {
|
|
||||||
animation-delay: 0.05s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home > section:nth-child(2) {
|
|
||||||
animation-delay: 0.12s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home > section:nth-child(3) {
|
|
||||||
animation-delay: 0.18s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-hero {
|
.home-hero {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
|
grid-template-columns: minmax(0, 1.3fr) minmax(0, 0.7fr);
|
||||||
gap: 32px;
|
gap: 24px;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-hero__kicker {
|
.home-hero__kicker {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.28em;
|
letter-spacing: 0.3em;
|
||||||
color: var(--accent);
|
color: var(--neon-cyan);
|
||||||
margin: 0 0 12px;
|
margin: 0 0 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero__kicker::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 24px;
|
||||||
|
height: 1.5px;
|
||||||
|
background: var(--neon-cyan);
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 0 6px var(--neon-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-hero h1 {
|
.home-hero h1 {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: clamp(32px, 4vw, 46px);
|
font-size: clamp(28px, 3.5vw, 44px);
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-bright);
|
||||||
|
letter-spacing: -0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-hero__lead {
|
.home-hero__lead {
|
||||||
color: var(--muted);
|
color: var(--text-dim);
|
||||||
line-height: 1.7;
|
line-height: 1.75;
|
||||||
margin: 0 0 24px;
|
margin: 0 0 24px;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-hero__actions {
|
.home-hero__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Hero Card ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.home-hero__card {
|
.home-hero__card {
|
||||||
background: var(--surface);
|
background: var(--surface-card);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 24px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
|
box-shadow: var(--shadow-card);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-hero__card-title {
|
.home-hero__card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--grad-accent);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero__card-eyebrow {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
color: var(--muted);
|
color: var(--text-muted);
|
||||||
font-size: 13px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.22em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-hero__card-body h2 {
|
.home-hero__card-body h2 {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 24px;
|
font-size: 20px;
|
||||||
margin: 0 0 12px;
|
margin: 0 0 8px;
|
||||||
|
color: var(--text-bright);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero__card-body p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-hero__stats {
|
.home-hero__stats {
|
||||||
@@ -85,81 +121,184 @@
|
|||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
margin: 6px 0 0;
|
margin: 5px 0 0;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
font-size: 18px;
|
font-size: 20px;
|
||||||
|
color: var(--text-bright);
|
||||||
|
line-height: 1;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
letter-spacing: -0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-unit {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value--sm {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section Header ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.home-section__header {
|
.home-section__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 4px;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-section__header h2 {
|
.home-section__header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 26px;
|
font-size: clamp(17px, 2vw, 22px);
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
|
color: var(--text-bright);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-section__header p {
|
.home-section__header p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Navigation Cards Grid ───────────────────────────────────────────── */
|
||||||
|
|
||||||
.home-grid {
|
.home-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-card {
|
.home-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
gap: 12px;
|
||||||
gap: 16px;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
border-radius: 18px;
|
border-radius: var(--radius-md);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.01));
|
background: var(--surface-card);
|
||||||
transition: transform 0.2s ease, border-color 0.2s ease;
|
box-shadow: var(--shadow-card);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
transition:
|
||||||
|
transform 0.22s var(--ease-out),
|
||||||
|
border-color 0.22s ease,
|
||||||
|
box-shadow 0.22s ease,
|
||||||
|
background 0.22s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--grad-accent);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(
|
||||||
|
circle at 30% 0%,
|
||||||
|
rgba(var(--card-accent-rgb, 0, 212, 255), 0.08),
|
||||||
|
transparent 60%
|
||||||
|
);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-card:hover {
|
.home-card:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
border-color: rgba(255, 255, 255, 0.22);
|
border-color: rgba(0, 212, 255, 0.2);
|
||||||
|
box-shadow:
|
||||||
|
var(--shadow-md),
|
||||||
|
0 0 0 1px rgba(0, 212, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card:hover::before {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card__icon {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 212, 255, 0.08);
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.15);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.22s var(--ease-spring);
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card:hover .home-card__icon {
|
||||||
|
transform: scale(1.1) rotate(-4deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card__body {
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-card__title {
|
.home-card__title {
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
font-size: 18px;
|
font-size: 15px;
|
||||||
margin: 0 0 8px;
|
margin: 0 0 5px;
|
||||||
|
color: var(--text-bright);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-card__desc {
|
.home-card__desc {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--text-dim);
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-card__cta {
|
.home-card__arrow {
|
||||||
font-size: 13px;
|
font-size: 16px;
|
||||||
text-transform: uppercase;
|
color: var(--neon-cyan);
|
||||||
letter-spacing: 0.2em;
|
opacity: 0;
|
||||||
color: var(--accent);
|
transform: translateX(-4px);
|
||||||
|
transition: opacity 0.22s ease, transform 0.22s ease;
|
||||||
|
align-self: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-card:hover .home-card__arrow {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Blog Posts ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.home-posts {
|
.home-posts {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-post {
|
.home-post {
|
||||||
@@ -167,46 +306,300 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
padding: 16px 18px;
|
padding: 16px 18px;
|
||||||
border-radius: 16px;
|
border-radius: var(--radius-md);
|
||||||
background: var(--surface);
|
background: var(--surface-card);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
grid-template-columns: auto 1fr auto;
|
||||||
transition: border-color 0.2s ease;
|
align-items: start;
|
||||||
|
gap: 14px;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-post:hover {
|
.home-post:hover {
|
||||||
border-color: rgba(255, 255, 255, 0.25);
|
border-color: rgba(192, 132, 252, 0.25);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-post__dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--neon-purple);
|
||||||
|
box-shadow: 0 0 6px var(--neon-purple);
|
||||||
|
margin-top: 7px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-post:hover .home-post__dot {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-post__content {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-post__title {
|
.home-post__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 18px;
|
font-size: 15px;
|
||||||
|
color: var(--text-bright);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-post__excerpt {
|
.home-post__excerpt {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-post__meta {
|
.home-post__meta {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: var(--accent);
|
color: var(--neon-purple-dim);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.12em;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── TODO Board ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.home-todo-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--line-bright);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--text-bright);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-nav:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-color: var(--neon-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-nav--left { left: -16px; }
|
||||||
|
.home-todo-nav--right { right: -16px; }
|
||||||
|
|
||||||
|
.home-todo-board {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--line) transparent;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-board::-webkit-scrollbar {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-board::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-board::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--line);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-col {
|
||||||
|
flex: 1 0 260px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 340px;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-card);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-col__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-col__dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-col__label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-bright);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-col__count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-col__body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: calc(40vh);
|
||||||
|
min-height: 60px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--line) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-col__body::-webkit-scrollbar {
|
||||||
|
width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-col__body::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--line);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-col__empty {
|
||||||
|
margin: auto;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 11px 13px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-card:hover {
|
||||||
|
border-color: rgba(0, 212, 255, 0.18);
|
||||||
|
background: rgba(0, 212, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-card__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-bright);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-card__desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.55;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-card__date {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-footer__link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #34d399;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 6px 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-footer__link:hover {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Profile ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.home-profile {
|
.home-profile {
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-profile__card {
|
.home-profile__card {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 22px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 22px;
|
padding: 24px;
|
||||||
background: var(--surface);
|
background: var(--surface-card);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 18px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--grad-accent);
|
||||||
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-profile__identity {
|
.home-profile__identity {
|
||||||
@@ -216,31 +609,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.home-profile__avatar {
|
.home-profile__avatar {
|
||||||
width: 52px;
|
width: 56px;
|
||||||
height: 52px;
|
height: 56px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(0, 212, 255, 0.2),
|
||||||
|
0 0 12px rgba(0, 212, 255, 0.1),
|
||||||
|
0 4px 16px rgba(0, 0, 0, 0.5);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-profile__role {
|
.home-profile__role {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: 10px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.2em;
|
letter-spacing: 0.22em;
|
||||||
color: var(--accent);
|
color: var(--neon-cyan);
|
||||||
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-profile__name {
|
.home-profile__name {
|
||||||
margin: 6px 0 0;
|
margin: 4px 0 0;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
|
color: var(--text-bright);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-profile__bio {
|
.home-profile__bio {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--text-dim);
|
||||||
line-height: 1.6;
|
line-height: 1.75;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-profile__timeline {
|
.home-profile__timeline {
|
||||||
@@ -250,10 +651,11 @@
|
|||||||
|
|
||||||
.home-profile__section-title {
|
.home-profile__section-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: 10px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.22em;
|
letter-spacing: 0.24em;
|
||||||
color: var(--accent);
|
color: var(--neon-cyan);
|
||||||
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-profile__timeline ul {
|
.home-profile__timeline ul {
|
||||||
@@ -261,87 +663,137 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-profile__timeline li {
|
.home-profile__timeline li {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border-radius: 16px;
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-profile__timeline span {
|
.home-profile__timeline li:hover {
|
||||||
font-size: 12px;
|
border-color: rgba(0, 212, 255, 0.15);
|
||||||
color: var(--muted);
|
background: rgba(0, 212, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-period {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-profile__timeline strong {
|
.home-profile__timeline strong {
|
||||||
font-size: 15px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__timeline span:not(.timeline-period) {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-profile__tags {
|
.home-profile__tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-profile__tags span {
|
.home-profile__tags span {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 6px 10px;
|
padding: 4px 10px;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: var(--muted);
|
color: var(--text-dim);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
transition: border-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__tags span:hover {
|
||||||
|
border-color: rgba(0, 212, 255, 0.2);
|
||||||
|
color: var(--neon-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-profile__actions {
|
.home-profile__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
.home-hero {
|
.home-hero {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-hero__card {
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.home {
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-col {
|
||||||
|
flex: 0 0 80vw;
|
||||||
|
max-width: 80vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-col__body {
|
||||||
|
max-height: 30vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-todo-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.home-hero h1 {
|
.home-hero h1 {
|
||||||
font-size: clamp(24px, 6vw, 36px);
|
font-size: clamp(22px, 6vw, 32px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-grid {
|
.home-grid {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-card {
|
.home-card {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-card__title {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-card__desc {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-posts {
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-card__icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card__title {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card__desc {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.home-post {
|
.home-post {
|
||||||
padding: 14px 16px;
|
padding: 12px 14px;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-post__meta {
|
||||||
|
grid-column: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-post__title {
|
.home-post__title {
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-profile__card {
|
.home-profile__card {
|
||||||
@@ -351,8 +803,15 @@
|
|||||||
.home-profile__name {
|
.home-profile__name {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.home-profile__bio {
|
@media (max-width: 480px) {
|
||||||
font-size: 14px;
|
.home-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero__stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,48 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { navLinks } from '../../routes.jsx';
|
import { navLinks } from '../../routes.jsx';
|
||||||
import { getBlogPosts } from '../../data/blog';
|
import { getBlogPosts } from '../../data/blog';
|
||||||
|
import { getTodos } from '../../api';
|
||||||
|
import { getCurrentTheme } from '../../data/heroConfig';
|
||||||
import myPhoto from '../../assets/myPhoto.jpg';
|
import myPhoto from '../../assets/myPhoto.jpg';
|
||||||
import './Home.css';
|
import './Home.css';
|
||||||
|
|
||||||
|
const TODO_COLUMNS = [
|
||||||
|
{ id: 'todo', label: '계획', color: 'var(--neon-purple)' },
|
||||||
|
{ id: 'in_progress', label: '진행 중', color: '#f59e0b' },
|
||||||
|
{ id: 'done', label: '완료', color: '#34d399' },
|
||||||
|
];
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
const posts = getBlogPosts().slice(0, 3);
|
const posts = getBlogPosts().slice(0, 3);
|
||||||
const highlights = navLinks.filter((link) => link.id !== 'home');
|
const highlights = navLinks.filter((link) => link.id !== 'home');
|
||||||
|
const theme = getCurrentTheme();
|
||||||
|
|
||||||
|
const [todosByStatus, setTodosByStatus] = useState({ todo: [], in_progress: [], done: [] });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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 totalTasks = todosByStatus.todo.length + todosByStatus.in_progress.length + todosByStatus.done.length;
|
||||||
|
const doneTasks = todosByStatus.done.length;
|
||||||
|
const inProgress = todosByStatus.in_progress.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="home">
|
<div className="home">
|
||||||
<section className="home-hero">
|
<section className="home-hero">
|
||||||
<div className="home-hero__text">
|
<div className="home-hero__text">
|
||||||
<p className="home-hero__kicker">Personal Archive</p>
|
<p className="home-hero__kicker">Personal Archive</p>
|
||||||
<h1>기록을 모으고, 이야기를 이어붙이는 작은 집.</h1>
|
<h1>기록을 모으고,<br />이야기를 이어붙이는 작은 집.</h1>
|
||||||
<p className="home-hero__lead">
|
<p className="home-hero__lead">
|
||||||
개발, 여행 스냅, 그리고 생각을 모아두는 공간입니다.
|
개발, 여행 스냅, 그리고 생각을 모아두는 공간입니다.
|
||||||
</p>
|
</p>
|
||||||
@@ -28,22 +56,23 @@ const Home = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="home-hero__card">
|
<div className="home-hero__card">
|
||||||
<p className="home-hero__card-title">이번 달 집중 테마</p>
|
<p className="home-hero__card-eyebrow">이번 달 집중 테마</p>
|
||||||
<div className="home-hero__card-body">
|
<div className="home-hero__card-body">
|
||||||
<h2>느린 기록, 깊은 회고</h2>
|
<h2>{theme.theme}</h2>
|
||||||
<p>
|
<p>{theme.desc}</p>
|
||||||
빠르게 업데이트하는 대신, 한 번쯤 되돌아보며 기록하는 걸 목표로
|
|
||||||
합니다. 글은 매주 한 편씩 추가될 예정이에요.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="home-hero__stats">
|
<div className="home-hero__stats">
|
||||||
<div>
|
<div className="home-hero__stat">
|
||||||
<p className="stat-label">게시 글</p>
|
<p className="stat-label">전체 태스크</p>
|
||||||
<p className="stat-value">{posts.length}편</p>
|
<p className="stat-value">
|
||||||
|
{totalTasks}<span className="stat-unit">개</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="home-hero__stat">
|
||||||
<p className="stat-label">다음 업데이트</p>
|
<p className="stat-label">진행 중 / 완료</p>
|
||||||
<p className="stat-value">이번 주말</p>
|
<p className="stat-value stat-value--sm">
|
||||||
|
{inProgress}<span className="stat-unit"> / </span>{doneTasks}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,12 +85,23 @@ const Home = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="home-grid">
|
<div className="home-grid">
|
||||||
{highlights.map((item) => (
|
{highlights.map((item) => (
|
||||||
<Link key={item.id} to={item.path} className="home-card">
|
<Link
|
||||||
<div>
|
key={item.id}
|
||||||
|
to={item.path}
|
||||||
|
className="home-card"
|
||||||
|
style={{ '--card-accent': item.accent }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="home-card__icon"
|
||||||
|
style={{ color: item.accent }}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<div className="home-card__body">
|
||||||
<p className="home-card__title">{item.label}</p>
|
<p className="home-card__title">{item.label}</p>
|
||||||
<p className="home-card__desc">{item.description}</p>
|
<p className="home-card__desc">{item.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="home-card__cta">열기</span>
|
<span className="home-card__arrow">→</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -75,14 +115,26 @@ const Home = () => {
|
|||||||
<div className="home-posts">
|
<div className="home-posts">
|
||||||
{posts.map((post) => (
|
{posts.map((post) => (
|
||||||
<Link key={post.slug} to="/blog" className="home-post">
|
<Link key={post.slug} to="/blog" className="home-post">
|
||||||
<p className="home-post__title">{post.title}</p>
|
<div className="home-post__dot" />
|
||||||
<p className="home-post__excerpt">{post.excerpt}</p>
|
<div className="home-post__content">
|
||||||
|
<p className="home-post__title">{post.title}</p>
|
||||||
|
<p className="home-post__excerpt">{post.excerpt}</p>
|
||||||
|
</div>
|
||||||
<span className="home-post__meta">{post.date || '작성일 미정'}</span>
|
<span className="home-post__meta">{post.date || '작성일 미정'}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ── TODO 보드 ──────────────────────────────────────────── */}
|
||||||
|
<section className="home-section">
|
||||||
|
<div className="home-section__header">
|
||||||
|
<h2>TODO</h2>
|
||||||
|
<p>계획 · 진행 중 · 완료 태스크를 한눈에 확인합니다.</p>
|
||||||
|
</div>
|
||||||
|
<TodoBoard todosByStatus={todosByStatus} />
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="home-section">
|
<section className="home-section">
|
||||||
<div className="home-section__header">
|
<div className="home-section__header">
|
||||||
<h2>Profile</h2>
|
<h2>Profile</h2>
|
||||||
@@ -110,31 +162,26 @@ const Home = () => {
|
|||||||
<p className="home-profile__section-title">연혁</p>
|
<p className="home-profile__section-title">연혁</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<span>2023.02 - 현재</span>
|
<span className="timeline-period">2023.02 - 현재</span>
|
||||||
<strong>Server Developer</strong>
|
<strong>Server Developer</strong>
|
||||||
<span>내비 TIS 교통 서버/현대오토에버</span>
|
<span>내비 TIS 교통 서버 / 현대오토에버</span>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span>2020.01 - 2023.02</span>
|
<span className="timeline-period">2020.01 - 2023.02</span>
|
||||||
<strong>Embedded Device SW Developer</strong>
|
<strong>Embedded Device SW Developer</strong>
|
||||||
<span>캐시비 단말기 개발/롯데정보통신</span>
|
<span>캐시비 단말기 개발 / 롯데정보통신</span>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span>2019.07 - 2019.12</span>
|
<span className="timeline-period">2019.07 - 2019.12</span>
|
||||||
<strong>SSAFY - 삼성 SW Academy</strong>
|
<strong>SSAFY - 삼성 SW Academy</strong>
|
||||||
<span>SSAFY</span>
|
<span>SSAFY 1기 수료</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="home-profile__tags">
|
<div className="home-profile__tags">
|
||||||
<span>C++</span>
|
{['C++', 'Git', 'AWS', 'Jira', 'MySQL', 'Docker', 'Kubernetes', 'Linux'].map((tag) => (
|
||||||
<span>Git</span>
|
<span key={tag}>{tag}</span>
|
||||||
<span>AWS</span>
|
))}
|
||||||
<span>Jira</span>
|
|
||||||
<span>MySQL</span>
|
|
||||||
<span>Docker</span>
|
|
||||||
<span>Kubernetes</span>
|
|
||||||
<span>Linux</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="home-profile__actions">
|
<div className="home-profile__actions">
|
||||||
<button className="button ghost">프로필 수정</button>
|
<button className="button ghost">프로필 수정</button>
|
||||||
@@ -149,4 +196,99 @@ const Home = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ── TodoBoard ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const TodoBoard = ({ todosByStatus }) => {
|
||||||
|
const boardRef = useRef(null);
|
||||||
|
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||||
|
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||||
|
|
||||||
|
const checkScroll = () => {
|
||||||
|
const el = boardRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
setCanScrollLeft(el.scrollLeft > 4);
|
||||||
|
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkScroll();
|
||||||
|
const el = boardRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener('scroll', checkScroll, { passive: true });
|
||||||
|
const ro = new ResizeObserver(checkScroll);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => { el.removeEventListener('scroll', checkScroll); ro.disconnect(); };
|
||||||
|
}, [todosByStatus]);
|
||||||
|
|
||||||
|
const scroll = (dir) => {
|
||||||
|
const el = boardRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollBy({ left: dir * 280, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEmpty = TODO_COLUMNS.every((col) => todosByStatus[col.id].length === 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="home-todo-wrapper">
|
||||||
|
{canScrollLeft && (
|
||||||
|
<button
|
||||||
|
className="home-todo-nav home-todo-nav--left"
|
||||||
|
onClick={() => scroll(-1)}
|
||||||
|
aria-label="왼쪽으로"
|
||||||
|
>‹</button>
|
||||||
|
)}
|
||||||
|
{canScrollRight && (
|
||||||
|
<button
|
||||||
|
className="home-todo-nav home-todo-nav--right"
|
||||||
|
onClick={() => scroll(1)}
|
||||||
|
aria-label="오른쪽으로"
|
||||||
|
>›</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="home-todo-board" ref={boardRef}>
|
||||||
|
{TODO_COLUMNS.map((col) => {
|
||||||
|
const items = todosByStatus[col.id] ?? [];
|
||||||
|
return (
|
||||||
|
<div key={col.id} className="home-todo-col">
|
||||||
|
<div className="home-todo-col__head">
|
||||||
|
<span
|
||||||
|
className="home-todo-col__dot"
|
||||||
|
style={{ background: col.color, boxShadow: `0 0 6px ${col.color}` }}
|
||||||
|
/>
|
||||||
|
<span className="home-todo-col__label">{col.label}</span>
|
||||||
|
<span className="home-todo-col__count">{items.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="home-todo-col__body">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="home-todo-col__empty">태스크가 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
items.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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="home-todo-footer">
|
||||||
|
<Link to="/todo" className="home-todo-footer__link">
|
||||||
|
Todo 보드 열기 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default Home;
|
export default Home;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.3em;
|
letter-spacing: 0.3em;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--accent);
|
color: var(--accent-lotto);
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,10 +63,11 @@
|
|||||||
.lotto-panel {
|
.lotto-panel {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-radius: 24px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
box-shadow: var(--shadow-sm), var(--shadow-inset);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lotto-panel--wide .lotto-chart {
|
.lotto-panel--wide .lotto-chart {
|
||||||
@@ -94,7 +95,7 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.22em;
|
letter-spacing: 0.22em;
|
||||||
color: var(--accent);
|
color: var(--accent-lotto);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lotto-panel__sub {
|
.lotto-panel__sub {
|
||||||
@@ -213,7 +214,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lotto-field input:focus {
|
.lotto-field input:focus {
|
||||||
border-color: rgba(247, 168, 165, 0.6);
|
border-color: rgba(52, 211, 153, 0.6);
|
||||||
|
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lotto-result {
|
.lotto-result {
|
||||||
@@ -589,6 +591,489 @@
|
|||||||
background: rgba(247, 116, 125, 0.15);
|
background: rgba(247, 116, 125, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 시뮬레이션 추천 (Best Picks) ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.lotto-chip--active {
|
||||||
|
background: rgba(151, 201, 170, 0.2);
|
||||||
|
border-color: rgba(151, 201, 170, 0.5);
|
||||||
|
color: #97c9aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-sim-result {
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(151, 201, 170, 0.08);
|
||||||
|
border: 1px solid rgba(151, 201, 170, 0.3);
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-picks {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-pick {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-pick__rank {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-pick__content {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-pick__score {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 46px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-pick__score-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-pick__bar {
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-pick__bar span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(151, 201, 170, 0.85),
|
||||||
|
rgba(133, 165, 216, 0.85)
|
||||||
|
);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 통계 분석 (Analysis) ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.lotto-analysis {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-analysis__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-analysis__group {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-analysis__label {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-analysis__label span {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-analysis__stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-analysis__stats strong {
|
||||||
|
color: var(--text);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 오버듀 번호 ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.lotto-overdue {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-overdue__gap {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 신뢰도 배너 ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.lotto-perf-banner {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 14px 20px;
|
||||||
|
background: rgba(151, 201, 170, 0.06);
|
||||||
|
border-color: rgba(151, 201, 170, 0.25);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-perf-banner__label {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: rgba(151, 201, 170, 0.85);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-perf-banner__items {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-perf-banner__item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-perf-banner__divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 32px;
|
||||||
|
background: var(--line);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-perf-banner__val {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-perf-banner__val.is-pos { color: #97c9aa; }
|
||||||
|
.lotto-perf-banner__val.is-neg { color: #f7a8a5; }
|
||||||
|
.lotto-perf-banner__val.is-prize { color: #fdd4b1; }
|
||||||
|
|
||||||
|
.lotto-perf-banner__lbl {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 공략 리포트 ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.lotto-report-history {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-report-top {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-report-confidence {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-confidence-ring {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-report-confidence__title {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-report-confidence__factors {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-report-confidence__factor {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 90px minmax(0, 1fr) 28px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-report-confidence__factor-lbl {
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-report-confidence__factor-val {
|
||||||
|
text-align: right;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-report-pattern {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-report-pattern__title {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-report-pattern__stats {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-report-pattern__stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-report-pattern__stat strong {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 전략 카드 */
|
||||||
|
.lotto-strategy-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-strategy-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(133, 165, 216, 0.05);
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-strategy-card__name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(133, 165, 216, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-strategy-card__desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 개인 패턴 분석 ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.lotto-personal-tendency {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-personal-tendency__badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(253, 212, 177, 0.4);
|
||||||
|
background: rgba(253, 212, 177, 0.1);
|
||||||
|
color: #fdd4b1;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 구매 기록 ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.lotto-purchase-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-stat:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-stat__val {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-stat__val.is-pos { color: #97c9aa; }
|
||||||
|
.lotto-purchase-stat__val.is-neg { color: #f7a8a5; }
|
||||||
|
.lotto-purchase-stat__val.is-prize { color: #fdd4b1; }
|
||||||
|
|
||||||
|
.lotto-purchase-stat__lbl {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 구매 폼 */
|
||||||
|
.lotto-purchase-form {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-form__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-form__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-form__note {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-form__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 구매 목록 */
|
||||||
|
.lotto-purchase-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-list__head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 60px 100px 100px 100px minmax(0, 1fr) 120px;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--muted);
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 60px 100px 100px 100px minmax(0, 1fr) 120px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-row:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-row__drw {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-row__note {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-row__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-pos { color: #97c9aa; }
|
||||||
|
.is-neg { color: #f7a8a5; }
|
||||||
|
.is-prize { color: #fdd4b1; }
|
||||||
|
|
||||||
|
/* ── 반응형 ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.lotto-header {
|
.lotto-header {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -597,6 +1082,78 @@
|
|||||||
.lotto-history__item {
|
.lotto-history__item {
|
||||||
grid-template-columns: 1fr;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-stat {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-stat:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-list__head,
|
||||||
|
.lotto-purchase-row {
|
||||||
|
grid-template-columns: 56px minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-list__head span:nth-child(n+3):nth-child(-n+5),
|
||||||
|
.lotto-purchase-row span:nth-child(n+3):nth-child(-n+5) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-form__note {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-perf-banner {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-perf-banner__items {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-perf-banner__item {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -638,3 +1195,283 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
종합 추론 패널
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.lotto-combined {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 기법별 추천 행 */
|
||||||
|
.lotto-combined__methods {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--line, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__method {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__method-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__method-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__method-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__method-weight {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__method-desc {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted, rgba(255,255,255,0.45));
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__method-nums {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 최종 결과 */
|
||||||
|
.lotto-combined__final {
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(129, 140, 248, 0.06);
|
||||||
|
border: 1px solid rgba(129, 140, 248, 0.25);
|
||||||
|
border-radius: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__final-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__final-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #818cf8;
|
||||||
|
background: rgba(129, 140, 248, 0.15);
|
||||||
|
border: 1px solid rgba(129, 140, 248, 0.3);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__final-balls {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__final-ball-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__final-ball-wrap .lotto-ball {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__vote-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__vote-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__vote-dot.is-on {
|
||||||
|
background: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__final-sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted, rgba(255,255,255,0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 볼 상태 */
|
||||||
|
.lotto-ball.is-dim {
|
||||||
|
opacity: 0.35;
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-ball.is-final {
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: 0 0 0 2px rgba(129, 140, 248, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 점수 바 */
|
||||||
|
.lotto-combined__scores {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__scores-title {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted, rgba(255,255,255,0.5));
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__score-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__score-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted, rgba(255,255,255,0.5));
|
||||||
|
width: 72px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__score-weight {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted, rgba(255,255,255,0.35));
|
||||||
|
width: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__score-bar-wrap {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__score-bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__score-val {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-bright, #fff);
|
||||||
|
width: 28px;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__score-total {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted, rgba(255,255,255,0.5));
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__score-total strong {
|
||||||
|
color: #818cf8;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__disclaimer {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted, rgba(255,255,255,0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 이력 */
|
||||||
|
.lotto-combined__history {
|
||||||
|
border-top: 1px solid var(--line, rgba(255,255,255,0.08));
|
||||||
|
padding-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__history-title {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted, rgba(255,255,255,0.45));
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__history-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
border: 1px solid var(--line, rgba(255,255,255,0.06));
|
||||||
|
border-radius: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__history-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted, rgba(255,255,255,0.4));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.lotto-combined__method {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__final-ball-wrap .lotto-ball {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-combined__final-balls {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ const Lotto = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="lotto-card">
|
<div className="lotto-card">
|
||||||
<p className="lotto-card__title">다음 업데이트 아이디어</p>
|
<p className="lotto-card__title">시뮬레이션 추천 시스템</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>로또 기록을 캘린더 형태로 정리</li>
|
<li>하루 6회 몬테카를로 시뮬레이션 자동 실행</li>
|
||||||
<li>자주 등장하는 번호 조합 분석</li>
|
<li>20,000개 후보를 5가지 통계 기법으로 스코어링</li>
|
||||||
<li>그래프로 추첨 추세 확인</li>
|
<li>핫·콜드·오버듀 번호 통계 분석 제공</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
157
src/pages/lotto/components/CombinedRecommendPanel.jsx
Normal file
157
src/pages/lotto/components/CombinedRecommendPanel.jsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ballClass, NumberRow, METHOD_META, METHOD_ORDER, SCORE_META, fmtKST } from '../lottoUtils';
|
||||||
|
|
||||||
|
const CombinedRecommendPanel = ({ combined, history, loading, histLoading, onRun, onCopy }) => {
|
||||||
|
const [histExpand, setHistExpand] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="lotto-panel lotto-panel--wide lotto-combined">
|
||||||
|
<div className="lotto-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="lotto-panel__eyebrow">AI · 종합 추론</p>
|
||||||
|
<h3>종합 추론 번호 추천</h3>
|
||||||
|
<p className="lotto-panel__sub">
|
||||||
|
5가지 통계 기법(빈도·지문·갭·공동출현·다양성)을 가중 투표로 합산해
|
||||||
|
최적 6개 번호를 도출합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-panel__actions">
|
||||||
|
{loading && <span className="lotto-chip">분석 중…</span>}
|
||||||
|
<button className="button primary small" onClick={onRun} disabled={loading}>
|
||||||
|
{loading ? '추론 중…' : '🔮 종합 추론 실행'}
|
||||||
|
</button>
|
||||||
|
{history.length > 0 && (
|
||||||
|
<button className="button ghost small" onClick={() => setHistExpand(p => !p)}>
|
||||||
|
이력 {history.length}건 {histExpand ? '▲' : '▼'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!combined && !loading && (
|
||||||
|
<p className="lotto-empty">버튼을 눌러 종합 추론을 실행하세요.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{combined && (
|
||||||
|
<>
|
||||||
|
{/* 기법별 추천 번호 */}
|
||||||
|
<div className="lotto-combined__methods">
|
||||||
|
{METHOD_ORDER.map((key) => {
|
||||||
|
const meta = METHOD_META[key];
|
||||||
|
const m = combined.methods?.[key];
|
||||||
|
if (!m) return null;
|
||||||
|
return (
|
||||||
|
<div key={key} className="lotto-combined__method">
|
||||||
|
<div className="lotto-combined__method-head">
|
||||||
|
<span className="lotto-combined__method-icon">{meta.icon}</span>
|
||||||
|
<div>
|
||||||
|
<p className="lotto-combined__method-name" style={{ color: meta.color }}>
|
||||||
|
{meta.label}
|
||||||
|
<span className="lotto-combined__method-weight"> ({m.weight_pct}%)</span>
|
||||||
|
</p>
|
||||||
|
<p className="lotto-combined__method-desc">{meta.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-combined__method-nums">
|
||||||
|
{m.numbers.map((n) => {
|
||||||
|
const inFinal = combined.final_numbers.includes(n);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={n}
|
||||||
|
className={`lotto-ball ${ballClass(n).replace('lotto-ball ', '')} ${inFinal ? 'is-final' : 'is-dim'}`}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 최종 추론 결과 */}
|
||||||
|
<div className="lotto-combined__final">
|
||||||
|
<div className="lotto-combined__final-head">
|
||||||
|
<span className="lotto-combined__final-badge">종합 추론 결과</span>
|
||||||
|
{combined.deduped && (
|
||||||
|
<span className="lotto-chip lotto-chip--muted">중복 (이미 저장됨)</span>
|
||||||
|
)}
|
||||||
|
<button className="button ghost small" onClick={() => onCopy(combined.final_numbers)}>
|
||||||
|
복사
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-combined__final-balls">
|
||||||
|
{combined.final_numbers.map((n) => {
|
||||||
|
const votes = combined.vote_counts?.[String(n)] ?? 0;
|
||||||
|
return (
|
||||||
|
<div key={n} className="lotto-combined__final-ball-wrap">
|
||||||
|
<span className={ballClass(n)}>{n}</span>
|
||||||
|
<span className="lotto-combined__vote-dots">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<span key={i} className={`lotto-combined__vote-dot ${i < votes ? 'is-on' : ''}`} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="lotto-combined__final-sub">
|
||||||
|
● 점은 해당 번호가 채택된 기법 수 (최대 5개)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 점수 바 */}
|
||||||
|
<div className="lotto-combined__scores">
|
||||||
|
<p className="lotto-combined__scores-title">조합 품질 점수</p>
|
||||||
|
{SCORE_META.map(({ key, label, color, weight }) => {
|
||||||
|
const val = combined.scores?.[key] ?? 0;
|
||||||
|
const pct = Math.round(val * 100);
|
||||||
|
return (
|
||||||
|
<div key={key} className="lotto-combined__score-row">
|
||||||
|
<span className="lotto-combined__score-label">{label}</span>
|
||||||
|
<span className="lotto-combined__score-weight">{weight}%</span>
|
||||||
|
<div className="lotto-combined__score-bar-wrap">
|
||||||
|
<div
|
||||||
|
className="lotto-combined__score-bar"
|
||||||
|
style={{ width: `${pct}%`, background: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="lotto-combined__score-val">{pct}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="lotto-combined__score-total">
|
||||||
|
종합 점수 <strong>{Math.round((combined.scores?.score_total ?? 0) * 100)}</strong> / 100
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="lotto-combined__disclaimer">
|
||||||
|
※ 이 추천은 역대 통계 패턴 기반 참고 자료이며, 당첨을 보장하지 않습니다.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 추천 이력 */}
|
||||||
|
{histExpand && (
|
||||||
|
<div className="lotto-combined__history">
|
||||||
|
<p className="lotto-combined__history-title">종합 추론 이력</p>
|
||||||
|
{histLoading && <p className="lotto-empty">로딩 중…</p>}
|
||||||
|
{history.map((item) => (
|
||||||
|
<div key={item.id} className="lotto-combined__history-item">
|
||||||
|
<div className="lotto-combined__history-meta">
|
||||||
|
<span>#{item.id}</span>
|
||||||
|
<span>{fmtKST(item.created_at)}</span>
|
||||||
|
<span>기준 {item.based_on_draw ?? '-'}회</span>
|
||||||
|
</div>
|
||||||
|
<NumberRow nums={item.numbers} />
|
||||||
|
<button className="button ghost small" onClick={() => onCopy(item.numbers)}>복사</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CombinedRecommendPanel;
|
||||||
25
src/pages/lotto/components/ConfidenceRing.jsx
Normal file
25
src/pages/lotto/components/ConfidenceRing.jsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const ConfidenceRing = ({ score }) => {
|
||||||
|
const r = 28, c = 2 * Math.PI * r;
|
||||||
|
const fill = (score / 100) * c;
|
||||||
|
const color = score >= 80 ? '#97c9aa' : score >= 60 ? '#fdd4b1' : '#f7a8a5';
|
||||||
|
return (
|
||||||
|
<svg width="72" height="72" viewBox="0 0 72 72" className="lotto-confidence-ring" aria-hidden>
|
||||||
|
<circle cx="36" cy="36" r={r} stroke="rgba(255,255,255,0.08)" strokeWidth="6" fill="none" />
|
||||||
|
<circle
|
||||||
|
cx="36" cy="36" r={r}
|
||||||
|
stroke={color} strokeWidth="6" fill="none"
|
||||||
|
strokeDasharray={`${fill} ${c - fill}`}
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform="rotate(-90 36 36)"
|
||||||
|
/>
|
||||||
|
<text x="36" y="41" textAnchor="middle" fill={color} fontSize="16" fontWeight="600"
|
||||||
|
style={{ fontFamily: 'inherit' }}>
|
||||||
|
{score}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfidenceRing;
|
||||||
39
src/pages/lotto/components/FrequencyChart.jsx
Normal file
39
src/pages/lotto/components/FrequencyChart.jsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { buildFrequencySeries } from '../lottoUtils';
|
||||||
|
|
||||||
|
const FrequencyChart = ({ stats }) => {
|
||||||
|
const { series, max } = useMemo(() => buildFrequencySeries(stats?.frequency), [stats]);
|
||||||
|
const ticks = useMemo(() => [max, Math.round(max * 0.5), 0], [max]);
|
||||||
|
if (!stats) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lotto-chart">
|
||||||
|
<div className="lotto-chart__y">
|
||||||
|
<span>횟수</span>
|
||||||
|
<div className="lotto-chart__ticks">
|
||||||
|
{ticks.map((value) => <span key={value}>{value}</span>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-chart__plot" role="list">
|
||||||
|
{series.map((item) => {
|
||||||
|
const showLabel = item.number === 1 || item.number % 5 === 0;
|
||||||
|
return (
|
||||||
|
<div key={item.number} className="lotto-chart__col" role="listitem">
|
||||||
|
<span
|
||||||
|
className="lotto-chart__bar"
|
||||||
|
style={{ height: `${(item.count / max) * 100}%` }}
|
||||||
|
title={`${item.number}번: ${item.count}회`}
|
||||||
|
aria-label={`${item.number}번 ${item.count}회`}
|
||||||
|
/>
|
||||||
|
<span className="lotto-chart__x" aria-hidden={!showLabel}>
|
||||||
|
{showLabel ? item.number : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FrequencyChart;
|
||||||
59
src/pages/lotto/components/MetricBlock.jsx
Normal file
59
src/pages/lotto/components/MetricBlock.jsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { toBucketEntries } from '../lottoUtils';
|
||||||
|
|
||||||
|
const MetricBlock = ({ title, metrics }) => {
|
||||||
|
if (!metrics) return null;
|
||||||
|
const buckets = toBucketEntries(metrics);
|
||||||
|
const maxBucket = buckets.length ? Math.max(...buckets.map(([, v]) => Number(v) || 0), 1) : 1;
|
||||||
|
const odd = Number(metrics.odd) || 0;
|
||||||
|
const even = Number(metrics.even) || 0;
|
||||||
|
const totalOE = odd + even || 1;
|
||||||
|
const oddPct = (odd / totalOE) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lotto-metrics">
|
||||||
|
<div className="lotto-metrics__head">
|
||||||
|
<p className="lotto-metrics__title">{title}</p>
|
||||||
|
<span className="lotto-metrics__sum">총 출현 횟수 {metrics.sum ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-metric-cards">
|
||||||
|
<div className="lotto-metric-card">
|
||||||
|
<p className="lotto-metric-card__label">최소 출현</p>
|
||||||
|
<p className="lotto-metric-card__value">{metrics.min ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-metric-card">
|
||||||
|
<p className="lotto-metric-card__label">최대 출현</p>
|
||||||
|
<p className="lotto-metric-card__value">{metrics.max ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-metric-card">
|
||||||
|
<p className="lotto-metric-card__label">출현 편차</p>
|
||||||
|
<p className="lotto-metric-card__value">{metrics.range ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-odd-even">
|
||||||
|
<div className="lotto-odd-even__labels">
|
||||||
|
<span>홀 {odd}</span><span>짝 {even}</span>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-odd-even__bar" aria-hidden>
|
||||||
|
<span className="lotto-odd-even__odd" style={{ width: `${oddPct}%` }} />
|
||||||
|
<span className="lotto-odd-even__even" style={{ width: `${100 - oddPct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{buckets.length ? (
|
||||||
|
<div className="lotto-buckets">
|
||||||
|
{buckets.map(([label, value]) => (
|
||||||
|
<div key={label} className="lotto-bucket">
|
||||||
|
<span className="lotto-bucket__label">{label}</span>
|
||||||
|
<div className="lotto-bucket__bar" aria-hidden>
|
||||||
|
<span style={{ width: `${((Number(value) || 0) / maxBucket) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="lotto-bucket__value">{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MetricBlock;
|
||||||
48
src/pages/lotto/components/PerformanceBanner.jsx
Normal file
48
src/pages/lotto/components/PerformanceBanner.jsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const PerformanceBanner = ({ perf }) => {
|
||||||
|
if (!perf || perf.total_checked === 0) return null;
|
||||||
|
const imp = perf.vs_random?.improvement_pct ?? 0;
|
||||||
|
const prizeHits = (perf.by_rank?.rank_3 ?? 0) + (perf.by_rank?.rank_4 ?? 0) + (perf.by_rank?.rank_5 ?? 0);
|
||||||
|
return (
|
||||||
|
<div className="lotto-perf-banner">
|
||||||
|
<span className="lotto-perf-banner__label">신뢰도 지표</span>
|
||||||
|
<div className="lotto-perf-banner__items">
|
||||||
|
<div className="lotto-perf-banner__item">
|
||||||
|
<span className="lotto-perf-banner__val">{perf.total_checked}</span>
|
||||||
|
<span className="lotto-perf-banner__lbl">검증 회차</span>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-perf-banner__divider" />
|
||||||
|
<div className="lotto-perf-banner__item">
|
||||||
|
<span className="lotto-perf-banner__val">{(perf.avg_correct ?? 0).toFixed(1)}</span>
|
||||||
|
<span className="lotto-perf-banner__lbl">평균 일치수</span>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-perf-banner__divider" />
|
||||||
|
<div className="lotto-perf-banner__item">
|
||||||
|
<span className={`lotto-perf-banner__val ${imp > 0 ? 'is-pos' : ''}`}>
|
||||||
|
{imp > 0 ? '+' : ''}{imp.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<span className="lotto-perf-banner__lbl">무작위 대비</span>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-perf-banner__divider" />
|
||||||
|
<div className="lotto-perf-banner__item">
|
||||||
|
<span className="lotto-perf-banner__val">
|
||||||
|
{((perf.rate_3plus ?? 0) * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<span className="lotto-perf-banner__lbl">3개↑ 일치율</span>
|
||||||
|
</div>
|
||||||
|
{prizeHits > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="lotto-perf-banner__divider" />
|
||||||
|
<div className="lotto-perf-banner__item">
|
||||||
|
<span className="lotto-perf-banner__val is-prize">{prizeHits}건</span>
|
||||||
|
<span className="lotto-perf-banner__lbl">3~5등 당첨</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PerformanceBanner;
|
||||||
83
src/pages/lotto/components/PersonalAnalysisPanel.jsx
Normal file
83
src/pages/lotto/components/PersonalAnalysisPanel.jsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { NumberRow } from '../lottoUtils';
|
||||||
|
|
||||||
|
const PersonalAnalysisPanel = ({ data, loading }) => {
|
||||||
|
const zones = Object.entries(data?.pattern?.zone_avg ?? {});
|
||||||
|
const maxZone = zones.length ? Math.max(...zones.map(([, v]) => Number(v) || 0), 1) : 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="lotto-panel lotto-panel--wide">
|
||||||
|
<div className="lotto-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="lotto-panel__eyebrow">My Pattern</p>
|
||||||
|
<h3>내 번호 패턴</h3>
|
||||||
|
{data && data.total_analyzed > 0 && (
|
||||||
|
<p className="lotto-panel__sub">총 {data.total_analyzed}회 추천 기반 분석</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(loading || !data || data.total_analyzed === 0) ? (
|
||||||
|
<p className="lotto-empty">
|
||||||
|
{loading ? '불러오는 중...' : '추천 이력이 없습니다.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="lotto-analysis">
|
||||||
|
<div className="lotto-analysis__row">
|
||||||
|
<div className="lotto-analysis__group">
|
||||||
|
<p className="lotto-analysis__label">
|
||||||
|
내가 자주 선택한 번호 <span>TOP 10</span>
|
||||||
|
</p>
|
||||||
|
<NumberRow nums={data.top_picks ?? []} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lotto-analysis__group">
|
||||||
|
<p className="lotto-analysis__label">선택 성향</p>
|
||||||
|
<div className="lotto-personal-tendency">
|
||||||
|
{data.vs_draw_avg?.odd_tendency && (
|
||||||
|
<span className="lotto-personal-tendency__badge">
|
||||||
|
{data.vs_draw_avg.odd_tendency}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{data.vs_draw_avg?.sum_tendency && (
|
||||||
|
<span className="lotto-personal-tendency__badge">
|
||||||
|
{data.vs_draw_avg.sum_tendency}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="lotto-analysis__stats">
|
||||||
|
<span>홀수 평균 <strong>{data.pattern?.avg_odd_count?.toFixed(1)}</strong></span>
|
||||||
|
<span>합계 평균 <strong>{data.pattern?.avg_sum?.toFixed(1)}</strong></span>
|
||||||
|
<span>
|
||||||
|
연속번호 포함률{' '}
|
||||||
|
<strong>
|
||||||
|
{((data.pattern?.consecutive_rate ?? 0) * 100).toFixed(0)}%
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{zones.length > 0 && (
|
||||||
|
<div className="lotto-analysis__group">
|
||||||
|
<p className="lotto-analysis__label">구간별 선택 비율</p>
|
||||||
|
<div className="lotto-buckets">
|
||||||
|
{zones.map(([zone, avg]) => (
|
||||||
|
<div key={zone} className="lotto-bucket">
|
||||||
|
<span className="lotto-bucket__label">{zone}</span>
|
||||||
|
<div className="lotto-bucket__bar" aria-hidden>
|
||||||
|
<span style={{ width: `${((Number(avg) || 0) / maxZone) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="lotto-bucket__value">{Number(avg).toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PersonalAnalysisPanel;
|
||||||
173
src/pages/lotto/components/PurchasePanel.jsx
Normal file
173
src/pages/lotto/components/PurchasePanel.jsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { fmtWon } from '../lottoUtils';
|
||||||
|
|
||||||
|
const PurchasePanel = ({
|
||||||
|
records, stats, loading,
|
||||||
|
formOpen, form, formSaving, formError, editId,
|
||||||
|
onFormOpen, onFormClose, onFormChange, onFormSubmit,
|
||||||
|
onEditStart, onDelete,
|
||||||
|
}) => {
|
||||||
|
const winRate = stats?.total_records > 0
|
||||||
|
? ((stats.prize_count / stats.total_records) * 100).toFixed(1)
|
||||||
|
: '0.0';
|
||||||
|
const netColor = (stats?.net ?? 0) >= 0 ? 'is-pos' : 'is-neg';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="lotto-panel lotto-panel--wide">
|
||||||
|
<div className="lotto-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="lotto-panel__eyebrow">Purchase Tracker</p>
|
||||||
|
<h3>구매 기록</h3>
|
||||||
|
<p className="lotto-panel__sub">구매 내역 기록 및 수익률 추적</p>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-panel__actions">
|
||||||
|
{loading && <span className="lotto-chip">로딩 중</span>}
|
||||||
|
<button className="button small" onClick={onFormOpen} disabled={formOpen}>
|
||||||
|
+ 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 통계 바 */}
|
||||||
|
{stats && stats.total_records > 0 && (
|
||||||
|
<div className="lotto-purchase-stats">
|
||||||
|
<div className="lotto-purchase-stat">
|
||||||
|
<span className="lotto-purchase-stat__val">{fmtWon(stats.total_invested)}</span>
|
||||||
|
<span className="lotto-purchase-stat__lbl">총 투자</span>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-purchase-stat">
|
||||||
|
<span className="lotto-purchase-stat__val">{fmtWon(stats.total_prize)}</span>
|
||||||
|
<span className="lotto-purchase-stat__lbl">총 당첨금</span>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-purchase-stat">
|
||||||
|
<span className={`lotto-purchase-stat__val ${netColor}`}>
|
||||||
|
{(stats.net ?? 0) >= 0 ? '+' : ''}{fmtWon(stats.net)}
|
||||||
|
</span>
|
||||||
|
<span className="lotto-purchase-stat__lbl">순손익</span>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-purchase-stat">
|
||||||
|
<span className="lotto-purchase-stat__val">{stats.return_rate?.toFixed(1)}%</span>
|
||||||
|
<span className="lotto-purchase-stat__lbl">회수율</span>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-purchase-stat">
|
||||||
|
<span className="lotto-purchase-stat__val">{winRate}%</span>
|
||||||
|
<span className="lotto-purchase-stat__lbl">당첨률</span>
|
||||||
|
</div>
|
||||||
|
{stats.max_prize > 0 && (
|
||||||
|
<div className="lotto-purchase-stat">
|
||||||
|
<span className="lotto-purchase-stat__val is-prize">{fmtWon(stats.max_prize)}</span>
|
||||||
|
<span className="lotto-purchase-stat__lbl">최대 당첨금</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 입력 폼 */}
|
||||||
|
{formOpen && (
|
||||||
|
<form className="lotto-purchase-form" onSubmit={onFormSubmit}>
|
||||||
|
<p className="lotto-purchase-form__title">
|
||||||
|
{editId != null ? '기록 수정' : '구매 기록 추가'}
|
||||||
|
</p>
|
||||||
|
<div className="lotto-purchase-form__grid">
|
||||||
|
<label className="lotto-field">
|
||||||
|
회차
|
||||||
|
<input
|
||||||
|
type="number" min={1}
|
||||||
|
value={form.draw_no}
|
||||||
|
onChange={(e) => onFormChange('draw_no', e.target.value)}
|
||||||
|
placeholder="예: 1181"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="lotto-field">
|
||||||
|
구매금액
|
||||||
|
<input
|
||||||
|
type="number" step={1000} min={1000}
|
||||||
|
value={form.amount}
|
||||||
|
onChange={(e) => onFormChange('amount', Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="lotto-field">
|
||||||
|
세트 수
|
||||||
|
<input
|
||||||
|
type="number" min={1} max={20}
|
||||||
|
value={form.sets}
|
||||||
|
onChange={(e) => onFormChange('sets', Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="lotto-field">
|
||||||
|
당첨금
|
||||||
|
<input
|
||||||
|
type="number" min={0}
|
||||||
|
value={form.prize}
|
||||||
|
onChange={(e) => onFormChange('prize', Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="lotto-field lotto-purchase-form__note">
|
||||||
|
메모
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.note}
|
||||||
|
onChange={(e) => onFormChange('note', e.target.value)}
|
||||||
|
placeholder="예: 5등 1개"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{formError && (
|
||||||
|
<p className="lotto-empty" style={{ color: '#f9b6b1' }}>{formError}</p>
|
||||||
|
)}
|
||||||
|
<div className="lotto-purchase-form__actions">
|
||||||
|
<button type="button" className="button ghost small" onClick={onFormClose}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="button primary small" disabled={formSaving}>
|
||||||
|
{formSaving ? '저장 중...' : editId != null ? '수정 완료' : '추가'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 기록 목록 */}
|
||||||
|
{records.length === 0 ? (
|
||||||
|
<p className="lotto-empty">구매 기록이 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
<div className="lotto-purchase-list">
|
||||||
|
<div className="lotto-purchase-list__head">
|
||||||
|
<span>회차</span>
|
||||||
|
<span>투자금</span>
|
||||||
|
<span>당첨금</span>
|
||||||
|
<span>손익</span>
|
||||||
|
<span>메모</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
{records.map((rec) => {
|
||||||
|
const net = (rec.prize ?? 0) - (rec.amount ?? 0);
|
||||||
|
return (
|
||||||
|
<div key={rec.id} className="lotto-purchase-row">
|
||||||
|
<span className="lotto-purchase-row__drw">{rec.draw_no}회</span>
|
||||||
|
<span>{fmtWon(rec.amount)}</span>
|
||||||
|
<span className={(rec.prize ?? 0) > 0 ? 'is-prize' : ''}>
|
||||||
|
{fmtWon(rec.prize)}
|
||||||
|
</span>
|
||||||
|
<span className={net >= 0 ? 'is-pos' : 'is-neg'}>
|
||||||
|
{net >= 0 ? '+' : ''}{fmtWon(net)}
|
||||||
|
</span>
|
||||||
|
<span className="lotto-purchase-row__note">{rec.note || '-'}</span>
|
||||||
|
<div className="lotto-purchase-row__actions">
|
||||||
|
<button className="button ghost small" onClick={() => onEditStart(rec)}>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button className="button danger small" onClick={() => onDelete(rec.id)}>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PurchasePanel;
|
||||||
142
src/pages/lotto/components/ReportPanel.jsx
Normal file
142
src/pages/lotto/components/ReportPanel.jsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { NumberRow } from '../lottoUtils';
|
||||||
|
import ConfidenceRing from './ConfidenceRing';
|
||||||
|
|
||||||
|
const ReportPanel = ({ report, history, loading, onRefresh, onSelectDrw }) => {
|
||||||
|
const [histExpand, setHistExpand] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="lotto-panel lotto-panel--wide">
|
||||||
|
<div className="lotto-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="lotto-panel__eyebrow">Weekly Report</p>
|
||||||
|
<h3>이번 주 공략 리포트</h3>
|
||||||
|
{report && (
|
||||||
|
<p className="lotto-panel__sub">
|
||||||
|
{report.target_drw_no}회 대상 · {report.based_on_draw}회 기준
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="lotto-panel__actions">
|
||||||
|
{loading && <span className="lotto-chip">로딩 중</span>}
|
||||||
|
<button className="button ghost small" onClick={onRefresh} disabled={loading}>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
{history?.length > 0 && (
|
||||||
|
<button className="button ghost small" onClick={() => setHistExpand((p) => !p)}>
|
||||||
|
지난 리포트 {histExpand ? '▲' : '▼'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 지난 리포트 목록 */}
|
||||||
|
{histExpand && history?.length > 0 && (
|
||||||
|
<div className="lotto-report-history">
|
||||||
|
{history.map((h) => (
|
||||||
|
<button
|
||||||
|
key={h.drw_no}
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => { onSelectDrw(h.drw_no); setHistExpand(false); }}
|
||||||
|
>
|
||||||
|
{h.drw_no}회
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!report && !loading && (
|
||||||
|
<p className="lotto-empty">리포트 데이터가 없습니다.</p>
|
||||||
|
)}
|
||||||
|
{loading && !report && (
|
||||||
|
<p className="lotto-empty">불러오는 중...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report && (
|
||||||
|
<>
|
||||||
|
{/* 신뢰도 + 패턴 요약 */}
|
||||||
|
<div className="lotto-report-top">
|
||||||
|
<div className="lotto-report-confidence">
|
||||||
|
<ConfidenceRing score={report.confidence_score ?? 0} />
|
||||||
|
<div>
|
||||||
|
<p className="lotto-report-confidence__title">신뢰도 점수</p>
|
||||||
|
<div className="lotto-report-confidence__factors">
|
||||||
|
{Object.entries(report.confidence_factors ?? {}).map(([k, v]) => (
|
||||||
|
<div key={k} className="lotto-report-confidence__factor">
|
||||||
|
<span className="lotto-report-confidence__factor-lbl">
|
||||||
|
{k === 'data_volume' ? '데이터 충분도'
|
||||||
|
: k === 'pattern_consistency' ? '패턴 안정성'
|
||||||
|
: k === 'recent_trend' ? '최근 트렌드' : k}
|
||||||
|
</span>
|
||||||
|
<div className="lotto-pick__bar">
|
||||||
|
<span style={{ width: `${v}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="lotto-report-confidence__factor-val">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lotto-report-pattern">
|
||||||
|
<p className="lotto-report-pattern__title">최근 패턴</p>
|
||||||
|
<div className="lotto-report-pattern__stats">
|
||||||
|
<div className="lotto-report-pattern__stat">
|
||||||
|
<span>합계 평균</span>
|
||||||
|
<strong>{report.recent_pattern?.recent_sum_avg?.toFixed(1) ?? '-'}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-report-pattern__stat">
|
||||||
|
<span>홀수 평균</span>
|
||||||
|
<strong>{report.recent_pattern?.recent_odd_avg?.toFixed(1) ?? '-'}</strong>
|
||||||
|
</div>
|
||||||
|
{(report.recent_pattern?.triple_appear ?? []).length > 0 && (
|
||||||
|
<div className="lotto-report-pattern__stat">
|
||||||
|
<span>3회 연속 출현</span>
|
||||||
|
<NumberRow nums={report.recent_pattern.triple_appear} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 핫 / 콜드 / 오버듀 */}
|
||||||
|
<div className="lotto-analysis__row">
|
||||||
|
<div className="lotto-analysis__group">
|
||||||
|
<p className="lotto-analysis__label">
|
||||||
|
🔥 핫 번호 <span>최근 10회 과출현</span>
|
||||||
|
</p>
|
||||||
|
<NumberRow nums={report.hot_numbers ?? []} />
|
||||||
|
</div>
|
||||||
|
<div className="lotto-analysis__group">
|
||||||
|
<p className="lotto-analysis__label">
|
||||||
|
🧊 콜드 번호 <span>역대 저빈도 10개</span>
|
||||||
|
</p>
|
||||||
|
<NumberRow nums={report.cold_numbers ?? []} />
|
||||||
|
</div>
|
||||||
|
<div className="lotto-analysis__group">
|
||||||
|
<p className="lotto-analysis__label">
|
||||||
|
⏰ 오버듀 <span>가장 오래 미출현</span>
|
||||||
|
</p>
|
||||||
|
<NumberRow nums={report.overdue_numbers ?? []} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 전략 추천 세트 */}
|
||||||
|
{(report.recommended_sets ?? []).length > 0 && (
|
||||||
|
<div className="lotto-strategy-cards">
|
||||||
|
{report.recommended_sets.map((set, i) => (
|
||||||
|
<div key={i} className="lotto-strategy-card">
|
||||||
|
<p className="lotto-strategy-card__name">{set.strategy}</p>
|
||||||
|
<NumberRow nums={set.numbers} />
|
||||||
|
<p className="lotto-strategy-card__desc">{set.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportPanel;
|
||||||
162
src/pages/lotto/hooks/useLottoData.js
Normal file
162
src/pages/lotto/hooks/useLottoData.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
getLatest, getStats, getBestPicks, getAnalysis,
|
||||||
|
getPerformanceStats, getLatestReport, getReportHistory, getReport,
|
||||||
|
getPersonalAnalysis, getCombinedRecommend, getCombinedHistory,
|
||||||
|
} from '../../../api';
|
||||||
|
import { readStatsCache, writeStatsCache } from '../lottoUtils';
|
||||||
|
|
||||||
|
export default function useLottoData() {
|
||||||
|
const [latest, setLatest] = useState(null);
|
||||||
|
const [stats, setStats] = useState(() => readStatsCache());
|
||||||
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
|
const [statsError, setStatsError] = useState('');
|
||||||
|
const [loading, setLoading] = useState({
|
||||||
|
latest: false, bestPicks: false, analysis: false,
|
||||||
|
});
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [bestPicks, setBestPicks] = useState([]);
|
||||||
|
const [bestPicksExpanded, setBestPicksExpanded] = useState(false);
|
||||||
|
const [analysis, setAnalysis] = useState(null);
|
||||||
|
const [simulating, setSimulating] = useState(false);
|
||||||
|
const [simResult, setSimResult] = useState(null);
|
||||||
|
|
||||||
|
// 종합 추론
|
||||||
|
const [combined, setCombined] = useState(null);
|
||||||
|
const [combinedLoading, setCombinedLoading] = useState(false);
|
||||||
|
const [combinedHistory, setCombinedHistory] = useState([]);
|
||||||
|
const [combinedHistLoading, setCombinedHistLoading] = useState(false);
|
||||||
|
|
||||||
|
// 신뢰도·리포트·개인분석
|
||||||
|
const [perfStats, setPerfStats] = useState(null);
|
||||||
|
const [report, setReport] = useState(null);
|
||||||
|
const [reportHistory, setReportHistory] = useState([]);
|
||||||
|
const [reportLoading, setReportLoading] = useState(false);
|
||||||
|
const [personalAnalysis, setPersonalAnalysis] = useState(null);
|
||||||
|
const [personalLoading, setPersonalLoading] = useState(false);
|
||||||
|
|
||||||
|
const refreshLatest = useCallback(async () => {
|
||||||
|
setLoading((s) => ({ ...s, latest: true }));
|
||||||
|
setError('');
|
||||||
|
try { setLatest(await getLatest()); }
|
||||||
|
catch (e) { setError(e?.message ?? String(e)); }
|
||||||
|
finally { setLoading((s) => ({ ...s, latest: false })); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshStats = useCallback(async () => {
|
||||||
|
setStatsLoading(true); setStatsError('');
|
||||||
|
try {
|
||||||
|
const cached = readStatsCache();
|
||||||
|
if (cached && !stats) setStats(cached);
|
||||||
|
const data = await getStats();
|
||||||
|
if (!cached || cached.total_draws !== data?.total_draws) {
|
||||||
|
setStats(data); writeStatsCache(data);
|
||||||
|
}
|
||||||
|
} catch (e) { setStatsError(e?.message ?? String(e)); }
|
||||||
|
finally { setStatsLoading(false); }
|
||||||
|
}, [stats]);
|
||||||
|
|
||||||
|
const refreshBestPicks = useCallback(async () => {
|
||||||
|
setLoading((s) => ({ ...s, bestPicks: true }));
|
||||||
|
try { setBestPicks((await getBestPicks(20)).items ?? []); }
|
||||||
|
catch {}
|
||||||
|
finally { setLoading((s) => ({ ...s, bestPicks: false })); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshAnalysis = useCallback(async () => {
|
||||||
|
setLoading((s) => ({ ...s, analysis: true }));
|
||||||
|
try { setAnalysis(await getAnalysis()); }
|
||||||
|
catch {}
|
||||||
|
finally { setLoading((s) => ({ ...s, analysis: false })); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshPerfStats = useCallback(async () => {
|
||||||
|
try { setPerfStats(await getPerformanceStats()); } catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshReport = useCallback(async () => {
|
||||||
|
setReportLoading(true);
|
||||||
|
try {
|
||||||
|
const [rep, hist] = await Promise.all([
|
||||||
|
getLatestReport(),
|
||||||
|
getReportHistory(10),
|
||||||
|
]);
|
||||||
|
setReport(rep);
|
||||||
|
setReportHistory(hist?.reports ?? []);
|
||||||
|
} catch {}
|
||||||
|
finally { setReportLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSpecificReport = useCallback(async (drwNo) => {
|
||||||
|
setReportLoading(true);
|
||||||
|
try { setReport(await getReport(drwNo)); }
|
||||||
|
catch {}
|
||||||
|
finally { setReportLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runCombinedRecommend = useCallback(async () => {
|
||||||
|
setCombinedLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getCombinedRecommend();
|
||||||
|
setCombined(data);
|
||||||
|
const hist = await getCombinedHistory(30);
|
||||||
|
setCombinedHistory(hist?.items ?? []);
|
||||||
|
} catch (e) { setError(e?.message ?? String(e)); }
|
||||||
|
finally { setCombinedLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadCombinedHistory = useCallback(async () => {
|
||||||
|
setCombinedHistLoading(true);
|
||||||
|
try {
|
||||||
|
const hist = await getCombinedHistory(30);
|
||||||
|
setCombinedHistory(hist?.items ?? []);
|
||||||
|
} catch {}
|
||||||
|
finally { setCombinedHistLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshPersonalAnalysis = useCallback(async () => {
|
||||||
|
setPersonalLoading(true);
|
||||||
|
try { setPersonalAnalysis(await getPersonalAnalysis()); }
|
||||||
|
catch {}
|
||||||
|
finally { setPersonalLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSimulate = useCallback(async () => {
|
||||||
|
const ok = confirm('시뮬레이션을 즉시 실행할까요?\n20,000개 후보를 분석합니다. (약 1~3분 소요)');
|
||||||
|
if (!ok) return;
|
||||||
|
setSimulating(true); setSimResult(null); setError('');
|
||||||
|
try {
|
||||||
|
const { triggerSimulate } = await import('../../../api');
|
||||||
|
const data = await triggerSimulate();
|
||||||
|
setSimResult(data);
|
||||||
|
await refreshBestPicks();
|
||||||
|
} catch (e) { setError(e?.message ?? String(e)); }
|
||||||
|
finally { setSimulating(false); }
|
||||||
|
}, [refreshBestPicks]);
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
refreshLatest();
|
||||||
|
refreshStats();
|
||||||
|
refreshBestPicks();
|
||||||
|
refreshAnalysis();
|
||||||
|
refreshPerfStats();
|
||||||
|
refreshReport();
|
||||||
|
refreshPersonalAnalysis();
|
||||||
|
loadCombinedHistory();
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return {
|
||||||
|
latest, loading, error, setError,
|
||||||
|
stats, statsLoading, statsError, refreshStats,
|
||||||
|
refreshLatest,
|
||||||
|
bestPicks, bestPicksExpanded, setBestPicksExpanded, refreshBestPicks,
|
||||||
|
analysis, refreshAnalysis,
|
||||||
|
simulating, simResult, onSimulate,
|
||||||
|
combined, combinedLoading, combinedHistory, combinedHistLoading,
|
||||||
|
runCombinedRecommend,
|
||||||
|
perfStats,
|
||||||
|
report, reportHistory, reportLoading, refreshReport, loadSpecificReport,
|
||||||
|
personalAnalysis, personalLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
75
src/pages/lotto/hooks/useManualRecommend.js
Normal file
75
src/pages/lotto/hooks/useManualRecommend.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { deleteHistory, getHistory, recommend } from '../../../api';
|
||||||
|
import { buildMetricsFromHistory } from '../lottoUtils';
|
||||||
|
|
||||||
|
export default function useManualRecommend() {
|
||||||
|
const [params, setParams] = useState({
|
||||||
|
recent_window: 200, recent_weight: 2.0, avoid_recent_k: 5,
|
||||||
|
});
|
||||||
|
const presets = useMemo(() => [
|
||||||
|
{ name: '기본', recent_window: 200, recent_weight: 2.0, avoid_recent_k: 5 },
|
||||||
|
{ name: '최근 가중치↑', recent_window: 100, recent_weight: 3.0, avoid_recent_k: 10 },
|
||||||
|
{ name: '안전(분산)', recent_window: 300, recent_weight: 1.6, avoid_recent_k: 8 },
|
||||||
|
{ name: '공격(최근)', recent_window: 80, recent_weight: 3.5, avoid_recent_k: 12 },
|
||||||
|
], []);
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
const [history, setHistory] = useState([]);
|
||||||
|
const [historyExpanded, setHistoryExpanded] = useState(false);
|
||||||
|
const historyEndRef = useRef(null);
|
||||||
|
const prevHistoryExpandedRef = useRef(false);
|
||||||
|
const [loading, setLoading] = useState({ recommend: false, history: false });
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const historyMetrics = useMemo(() => buildMetricsFromHistory(history), [history]);
|
||||||
|
const visibleHistory = historyExpanded ? history : history.slice(0, 5);
|
||||||
|
|
||||||
|
const refreshHistory = useCallback(async () => {
|
||||||
|
setLoading((s) => ({ ...s, history: true }));
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const limit = 100; let offset = 0; const allItems = [];
|
||||||
|
while (true) {
|
||||||
|
const data = await getHistory(limit, offset);
|
||||||
|
const items = data.items ?? [];
|
||||||
|
allItems.push(...items);
|
||||||
|
if (items.length < limit) break;
|
||||||
|
offset += limit;
|
||||||
|
}
|
||||||
|
setHistory(allItems);
|
||||||
|
} catch (e) { setError(e?.message ?? String(e)); }
|
||||||
|
finally { setLoading((s) => ({ ...s, history: false })); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onRecommend = useCallback(async () => {
|
||||||
|
setLoading((s) => ({ ...s, recommend: true })); setError('');
|
||||||
|
try { const data = await recommend(params); setResult(data); await refreshHistory(); }
|
||||||
|
catch (e) { setError(e?.message ?? String(e)); }
|
||||||
|
finally { setLoading((s) => ({ ...s, recommend: false })); }
|
||||||
|
}, [params, refreshHistory]);
|
||||||
|
|
||||||
|
const onDelete = useCallback(async (id) => {
|
||||||
|
if (!confirm(`히스토리 #${id}를 삭제할까요?`)) return;
|
||||||
|
setError('');
|
||||||
|
try { await deleteHistory(id); setHistory((prev) => prev.filter((item) => item.id !== id)); }
|
||||||
|
catch (e) { setError(e?.message ?? String(e)); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (historyExpanded && !prevHistoryExpandedRef.current) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
historyEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prevHistoryExpandedRef.current = historyExpanded;
|
||||||
|
}, [historyExpanded, visibleHistory.length]);
|
||||||
|
|
||||||
|
useEffect(() => { refreshHistory(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return {
|
||||||
|
params, setParams, presets,
|
||||||
|
result, history, historyExpanded, setHistoryExpanded,
|
||||||
|
historyEndRef, loading, error, setError,
|
||||||
|
historyMetrics, visibleHistory,
|
||||||
|
refreshHistory, onRecommend, onDelete,
|
||||||
|
};
|
||||||
|
}
|
||||||
105
src/pages/lotto/hooks/usePurchases.js
Normal file
105
src/pages/lotto/hooks/usePurchases.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
getPurchases, getPurchaseStats, addPurchase, updatePurchase, deletePurchase,
|
||||||
|
} from '../../../api';
|
||||||
|
import { emptyPurchaseForm } from '../lottoUtils';
|
||||||
|
|
||||||
|
export default function usePurchases() {
|
||||||
|
const [purchases, setPurchases] = useState([]);
|
||||||
|
const [purchaseStats, setPurchaseStats] = useState(null);
|
||||||
|
const [purchaseLoading, setPurchaseLoading] = useState(false);
|
||||||
|
|
||||||
|
// 폼 상태
|
||||||
|
const [purchaseFormOpen, setPurchaseFormOpen] = useState(false);
|
||||||
|
const [purchaseForm, setPurchaseForm] = useState(emptyPurchaseForm);
|
||||||
|
const [purchaseFormSaving, setPurchaseFormSaving] = useState(false);
|
||||||
|
const [purchaseFormError, setPurchaseFormError] = useState('');
|
||||||
|
const [purchaseEditId, setPurchaseEditId] = useState(null);
|
||||||
|
|
||||||
|
const refreshPurchases = useCallback(async () => {
|
||||||
|
setPurchaseLoading(true);
|
||||||
|
try {
|
||||||
|
const [recs, st] = await Promise.all([getPurchases(), getPurchaseStats()]);
|
||||||
|
setPurchases(recs?.records ?? []);
|
||||||
|
setPurchaseStats(st);
|
||||||
|
} catch {}
|
||||||
|
finally { setPurchaseLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePurchaseFormOpen = useCallback(() => {
|
||||||
|
setPurchaseEditId(null);
|
||||||
|
setPurchaseForm(emptyPurchaseForm());
|
||||||
|
setPurchaseFormError('');
|
||||||
|
setPurchaseFormOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePurchaseFormClose = useCallback(() => {
|
||||||
|
setPurchaseFormOpen(false);
|
||||||
|
setPurchaseEditId(null);
|
||||||
|
setPurchaseFormError('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePurchaseFormChange = useCallback((field, value) => {
|
||||||
|
setPurchaseForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePurchaseEditStart = useCallback((rec) => {
|
||||||
|
setPurchaseEditId(rec.id);
|
||||||
|
setPurchaseForm({
|
||||||
|
draw_no: String(rec.draw_no ?? ''),
|
||||||
|
amount: rec.amount ?? 5000,
|
||||||
|
sets: rec.sets ?? 5,
|
||||||
|
prize: rec.prize ?? 0,
|
||||||
|
note: rec.note ?? '',
|
||||||
|
});
|
||||||
|
setPurchaseFormError('');
|
||||||
|
setPurchaseFormOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePurchaseFormSubmit = useCallback(async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPurchaseFormSaving(true); setPurchaseFormError('');
|
||||||
|
const payload = {
|
||||||
|
draw_no: Number(purchaseForm.draw_no),
|
||||||
|
amount: Number(purchaseForm.amount),
|
||||||
|
sets: Number(purchaseForm.sets),
|
||||||
|
prize: Number(purchaseForm.prize),
|
||||||
|
note: purchaseForm.note.trim(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (purchaseEditId != null) {
|
||||||
|
const updated = await updatePurchase(purchaseEditId, payload);
|
||||||
|
setPurchases((prev) =>
|
||||||
|
prev.map((r) => r.id === purchaseEditId ? (updated ?? { ...payload, id: purchaseEditId }) : r)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const saved = await addPurchase(payload);
|
||||||
|
setPurchases((prev) => [saved ?? { ...payload, id: Date.now() }, ...prev]);
|
||||||
|
}
|
||||||
|
try { setPurchaseStats(await getPurchaseStats()); } catch {}
|
||||||
|
handlePurchaseFormClose();
|
||||||
|
} catch (err) {
|
||||||
|
setPurchaseFormError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setPurchaseFormSaving(false);
|
||||||
|
}
|
||||||
|
}, [purchaseForm, purchaseEditId, handlePurchaseFormClose]);
|
||||||
|
|
||||||
|
const handlePurchaseDelete = useCallback(async (id) => {
|
||||||
|
if (!confirm('이 구매 기록을 삭제할까요?')) return;
|
||||||
|
setPurchases((prev) => prev.filter((r) => r.id !== id));
|
||||||
|
try {
|
||||||
|
await deletePurchase(id);
|
||||||
|
try { setPurchaseStats(await getPurchaseStats()); } catch {}
|
||||||
|
} catch { refreshPurchases(); }
|
||||||
|
}, [refreshPurchases]);
|
||||||
|
|
||||||
|
useEffect(() => { refreshPurchases(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return {
|
||||||
|
purchases, purchaseStats, purchaseLoading,
|
||||||
|
purchaseFormOpen, purchaseForm, purchaseFormSaving, purchaseFormError, purchaseEditId,
|
||||||
|
handlePurchaseFormOpen, handlePurchaseFormClose, handlePurchaseFormChange,
|
||||||
|
handlePurchaseFormSubmit, handlePurchaseEditStart, handlePurchaseDelete,
|
||||||
|
};
|
||||||
|
}
|
||||||
141
src/pages/lotto/lottoUtils.jsx
Normal file
141
src/pages/lotto/lottoUtils.jsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/* ─────────────────────────────────────────────
|
||||||
|
로또 공통 유틸리티
|
||||||
|
───────────────────────────────────────────── */
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const fmtKST = (value) => value?.replace('T', ' ') ?? '';
|
||||||
|
|
||||||
|
export const fmtWon = (n) => {
|
||||||
|
if (n == null || isNaN(Number(n))) return '-';
|
||||||
|
return new Intl.NumberFormat('ko-KR').format(Math.round(Number(n))) + '원';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ballClass = (n) => {
|
||||||
|
if (n <= 10) return 'lotto-ball range-a';
|
||||||
|
if (n <= 20) return 'lotto-ball range-b';
|
||||||
|
if (n <= 30) return 'lotto-ball range-c';
|
||||||
|
if (n <= 40) return 'lotto-ball range-d';
|
||||||
|
return 'lotto-ball range-e';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Ball = ({ n }) => <span className={ballClass(n)}>{n}</span>;
|
||||||
|
|
||||||
|
export const NumberRow = ({ nums }) => (
|
||||||
|
<div className="lotto-row">
|
||||||
|
{nums.map((n) => <Ball key={n} n={n} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────
|
||||||
|
통계 헬퍼
|
||||||
|
───────────────────────────────────────────── */
|
||||||
|
export const bucketOrder = ['1-10', '11-20', '21-30', '31-40', '41-45'];
|
||||||
|
export const STATS_CACHE_KEY = 'lotto_stats_v1';
|
||||||
|
export const BEST_PICKS_DEFAULT_SHOW = 5;
|
||||||
|
|
||||||
|
export const readStatsCache = () => {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STATS_CACHE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed || !Array.isArray(parsed.frequency)) return null;
|
||||||
|
return parsed;
|
||||||
|
} catch { return null; }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeStatsCache = (data) => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try { localStorage.setItem(STATS_CACHE_KEY, JSON.stringify(data)); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildFrequencySeries = (frequency) => {
|
||||||
|
const map = new Map();
|
||||||
|
(frequency ?? []).forEach((item) => {
|
||||||
|
const number = Number(item?.number);
|
||||||
|
const count = Number(item?.count) || 0;
|
||||||
|
if (Number.isFinite(number) && number >= 1 && number <= 45) map.set(number, count);
|
||||||
|
});
|
||||||
|
const series = Array.from({ length: 45 }, (_, idx) => ({
|
||||||
|
number: idx + 1, count: map.get(idx + 1) ?? 0,
|
||||||
|
}));
|
||||||
|
const max = Math.max(1, ...series.map((item) => item.count));
|
||||||
|
return { series, max };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildMetricsFromCounts = (counts) => {
|
||||||
|
if (!counts?.length) return null;
|
||||||
|
const total = counts.reduce((acc, v) => acc + v, 0);
|
||||||
|
if (!total) return null;
|
||||||
|
const min = Math.min(...counts), max = Math.max(...counts);
|
||||||
|
const odd = counts.reduce((acc, v, idx) => (idx % 2 === 0 ? acc + v : acc), 0);
|
||||||
|
const even = total - odd;
|
||||||
|
const buckets = {
|
||||||
|
'1-10': counts.slice(0, 10).reduce((a, b) => a + b, 0),
|
||||||
|
'11-20': counts.slice(10, 20).reduce((a, b) => a + b, 0),
|
||||||
|
'21-30': counts.slice(20, 30).reduce((a, b) => a + b, 0),
|
||||||
|
'31-40': counts.slice(30, 40).reduce((a, b) => a + b, 0),
|
||||||
|
'41-45': counts.slice(40, 45).reduce((a, b) => a + b, 0),
|
||||||
|
};
|
||||||
|
return { sum: total, min, max, range: max - min, odd, even, buckets };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildMetricsFromFrequency = (frequency) => {
|
||||||
|
if (!frequency?.length) return null;
|
||||||
|
const counts = Array.from({ length: 45 }, () => 0);
|
||||||
|
frequency.forEach((item) => {
|
||||||
|
const number = Number(item?.number), count = Number(item?.count) || 0;
|
||||||
|
if (number >= 1 && number <= 45) counts[number - 1] = count;
|
||||||
|
});
|
||||||
|
return buildMetricsFromCounts(counts);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildMetricsFromHistory = (items) => {
|
||||||
|
if (!items?.length) return null;
|
||||||
|
const counts = Array.from({ length: 45 }, () => 0);
|
||||||
|
items.forEach((item) => {
|
||||||
|
(item?.numbers ?? []).forEach((value) => {
|
||||||
|
const number = Number(value);
|
||||||
|
if (number >= 1 && number <= 45) counts[number - 1] += 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return buildMetricsFromCounts(counts);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toBucketEntries = (metrics) => {
|
||||||
|
if (!metrics?.buckets) return [];
|
||||||
|
const ordered = bucketOrder
|
||||||
|
.filter((key) => Object.prototype.hasOwnProperty.call(metrics.buckets, key))
|
||||||
|
.map((key) => [key, metrics.buckets[key]]);
|
||||||
|
const rest = Object.entries(metrics.buckets)
|
||||||
|
.filter(([key]) => !bucketOrder.includes(key))
|
||||||
|
.sort((a, b) => Number(a[0].split('-')[0]) - Number(b[0].split('-')[0]));
|
||||||
|
return [...ordered, ...rest];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyPurchaseForm = () => ({ draw_no: '', amount: 5000, sets: 5, prize: 0, note: '' });
|
||||||
|
|
||||||
|
export const copyNumbers = async (nums) => {
|
||||||
|
const text = nums.join(', ');
|
||||||
|
try { await navigator.clipboard.writeText(text); alert(`복사 완료: ${text}`); }
|
||||||
|
catch { prompt('복사해서 사용하세요:', text); }
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 종합 추론 상수 */
|
||||||
|
export const METHOD_META = {
|
||||||
|
frequency: { label: '빈도 Z-score', desc: '역대 출현 빈도가 기댓값보다 높은 번호', color: '#818cf8', icon: '📊' },
|
||||||
|
fingerprint: { label: '조합 지문', desc: '역대 당첨 조합의 합계·홀짝·구간 분포에 맞는 번호', color: '#fbbf24', icon: '🔏' },
|
||||||
|
gap: { label: '갭 분석', desc: '가장 오래 등장하지 않은 오버듀 번호', color: '#34d399', icon: '⏳' },
|
||||||
|
cooccur: { label: '공동 출현', desc: '역대에 함께 출현한 빈도가 높은 번호', color: '#f472b6', icon: '🔗' },
|
||||||
|
diversity: { label: '다양성', desc: '구간 커버리지와 번호 범위를 극대화한 번호', color: '#fb923c', icon: '🌈' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const METHOD_ORDER = ['fingerprint', 'frequency', 'gap', 'cooccur', 'diversity'];
|
||||||
|
|
||||||
|
export const SCORE_META = [
|
||||||
|
{ key: 'score_fingerprint', label: '조합 지문', color: '#fbbf24', weight: 30 },
|
||||||
|
{ key: 'score_frequency', label: '빈도 Z', color: '#818cf8', weight: 25 },
|
||||||
|
{ key: 'score_gap', label: '갭 분석', color: '#34d399', weight: 20 },
|
||||||
|
{ key: 'score_cooccur', label: '공동 출현', color: '#f472b6', weight: 15 },
|
||||||
|
{ key: 'score_diversity', label: '다양성', color: '#fb923c', weight: 10 },
|
||||||
|
];
|
||||||
2598
src/pages/music/MusicStudio.css
Normal file
2598
src/pages/music/MusicStudio.css
Normal file
File diff suppressed because it is too large
Load Diff
1765
src/pages/music/MusicStudio.jsx
Normal file
1765
src/pages/music/MusicStudio.jsx
Normal file
File diff suppressed because it is too large
Load Diff
128
src/pages/music/components/AudioPlayer.jsx
Normal file
128
src/pages/music/components/AudioPlayer.jsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import React, { useRef, useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────
|
||||||
|
유틸
|
||||||
|
───────────────────────────────────────────── */
|
||||||
|
const pad = (n) => String(Math.floor(n)).padStart(2, '0');
|
||||||
|
export const fmtTime = (s) => `${pad(s / 60)}:${pad(s % 60)}`;
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────
|
||||||
|
Audio Player (실제 <audio> 기반)
|
||||||
|
───────────────────────────────────────────── */
|
||||||
|
const AudioPlayer = ({ audioUrl, totalSec, accentColor }) => {
|
||||||
|
const audioRef = useRef(null);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(totalSec ?? 0);
|
||||||
|
const [volume, setVolume] = useState(1);
|
||||||
|
|
||||||
|
/* 실제 오디오가 없으면 가짜 타이머로 폴백 */
|
||||||
|
const isFake = !audioUrl;
|
||||||
|
const timerRef = useRef(null);
|
||||||
|
|
||||||
|
const total = duration || totalSec || 60;
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (isFake) {
|
||||||
|
if (playing) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
setPlaying(false);
|
||||||
|
} else {
|
||||||
|
setPlaying(true);
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setElapsed((e) => {
|
||||||
|
if (e >= total - 1) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
setPlaying(false);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return e + 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
playing ? el.pause() : el.play();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = (e) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const ratio = (e.clientX - rect.left) / rect.width;
|
||||||
|
const newTime = ratio * total;
|
||||||
|
if (!isFake && audioRef.current) {
|
||||||
|
audioRef.current.currentTime = newTime;
|
||||||
|
}
|
||||||
|
setElapsed(newTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeChange = (e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
setVolume(v);
|
||||||
|
if (!isFake && audioRef.current) audioRef.current.volume = v;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => () => clearInterval(timerRef.current), []);
|
||||||
|
|
||||||
|
const progress = (elapsed / total) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ms-audio-player" style={{ '--player-accent': accentColor }}>
|
||||||
|
{!isFake && (
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={audioUrl}
|
||||||
|
onLoadedMetadata={(e) => setDuration(e.target.duration)}
|
||||||
|
onTimeUpdate={(e) => setElapsed(e.target.currentTime)}
|
||||||
|
onPlay={() => setPlaying(true)}
|
||||||
|
onPause={() => setPlaying(false)}
|
||||||
|
onEnded={() => { setPlaying(false); setElapsed(0); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ms-player__play ${playing ? 'is-playing' : ''}`}
|
||||||
|
onClick={togglePlay}
|
||||||
|
aria-label={playing ? '일시정지' : '재생'}
|
||||||
|
>
|
||||||
|
{playing ? (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<rect x="3" y="2" width="4" height="12" rx="1" />
|
||||||
|
<rect x="9" y="2" width="4" height="12" rx="1" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M4 2l10 6-10 6V2z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="ms-player__timeline">
|
||||||
|
<div className="ms-player__bar" onClick={handleSeek} role="slider"
|
||||||
|
aria-label="재생 위치" aria-valuenow={Math.round(elapsed)} aria-valuemin={0} aria-valuemax={Math.round(total)}>
|
||||||
|
<div className="ms-player__fill" style={{ width: `${progress}%` }} />
|
||||||
|
<div className="ms-player__thumb" style={{ left: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="ms-player__times">
|
||||||
|
<span>{fmtTime(elapsed)}</span>
|
||||||
|
<span>{fmtTime(total)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ms-volume">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden>
|
||||||
|
<path d="M2 5h2.5l3-3v10l-3-3H2V5zm8.5-1.5a4.5 4.5 0 010 7" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="range" min={0} max={1} step={0.02} value={volume}
|
||||||
|
onChange={handleVolumeChange}
|
||||||
|
className="ms-volume__slider"
|
||||||
|
aria-label="볼륨"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AudioPlayer;
|
||||||
40
src/pages/music/components/CoverArtModal.jsx
Normal file
40
src/pages/music/components/CoverArtModal.jsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const CoverArtModal = ({ images, onSelect, onClose }) => {
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
|
||||||
|
if (!images || images.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ms-modal-overlay" onClick={onClose}>
|
||||||
|
<div className="ms-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="ms-modal__header">
|
||||||
|
<h3 className="ms-modal__title">Cover Art 선택</h3>
|
||||||
|
<button type="button" className="ms-modal__close" onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="ms-cover-grid">
|
||||||
|
{images.map((url, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
className={`ms-cover-option ${selected === idx ? 'is-selected' : ''}`}
|
||||||
|
onClick={() => setSelected(idx)}
|
||||||
|
>
|
||||||
|
<img src={url} alt={`Cover option ${idx + 1}`} className="ms-cover-option__img" />
|
||||||
|
<span className="ms-cover-option__label">Option {idx + 1}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="ms-modal__actions">
|
||||||
|
<button type="button" className="ms-btn ms-btn--accent" disabled={selected === null}
|
||||||
|
onClick={() => { if (selected !== null) onSelect(images[selected]); }}>
|
||||||
|
이 이미지 사용
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ms-btn ms-btn--ghost" onClick={onClose}>취소</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CoverArtModal;
|
||||||
36
src/pages/music/components/CreditsBadge.jsx
Normal file
36
src/pages/music/components/CreditsBadge.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { getMusicCredits } from '../../../api';
|
||||||
|
|
||||||
|
const CreditsBadge = () => {
|
||||||
|
const [credits, setCredits] = useState(null);
|
||||||
|
|
||||||
|
const fetchCredits = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await getMusicCredits();
|
||||||
|
setCredits(data);
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCredits();
|
||||||
|
const interval = setInterval(fetchCredits, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchCredits]);
|
||||||
|
|
||||||
|
if (!credits) return null;
|
||||||
|
|
||||||
|
const remaining = credits.credits_left ?? credits.remaining ?? credits.data ?? null;
|
||||||
|
if (remaining == null) return null;
|
||||||
|
|
||||||
|
const isLow = remaining <= 10;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`ms-credits-badge ${isLow ? 'is-low' : ''}`}>
|
||||||
|
<span className="ms-credits-badge__icon">⚡</span>
|
||||||
|
<span className="ms-credits-badge__value">{remaining}</span>
|
||||||
|
<span className="ms-credits-badge__label">credits</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreditsBadge;
|
||||||
245
src/pages/music/components/LyricsTab.jsx
Normal file
245
src/pages/music/components/LyricsTab.jsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
generateMusicLyrics,
|
||||||
|
getSavedLyrics,
|
||||||
|
saveLyrics,
|
||||||
|
updateLyrics,
|
||||||
|
deleteLyrics,
|
||||||
|
} from '../../../api';
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────
|
||||||
|
Lyrics Tab
|
||||||
|
───────────────────────────────────────────── */
|
||||||
|
const LyricsTab = ({ onUseInCreate }) => {
|
||||||
|
const [lyrPrompt, setLyrPrompt] = useState('');
|
||||||
|
const [lyrLoading, setLyrLoading] = useState(false);
|
||||||
|
const [lyrError, setLyrError] = useState(null);
|
||||||
|
const [copied, setCopied] = useState(null); // id
|
||||||
|
const [saved, setSaved] = useState([]); // DB에 저장된 가사
|
||||||
|
const [loadingSaved, setLoadingSaved] = useState(true);
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [editTitle, setEditTitle] = useState('');
|
||||||
|
const [editText, setEditText] = useState('');
|
||||||
|
|
||||||
|
/* ── 저장된 가사 로드 ── */
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadingSaved(true);
|
||||||
|
getSavedLyrics()
|
||||||
|
.then((data) => setSaved(data.lyrics ?? []))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoadingSaved(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ── AI 생성 → 즉시 저장 ── */
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!lyrPrompt.trim() || lyrLoading) return;
|
||||||
|
setLyrLoading(true);
|
||||||
|
setLyrError(null);
|
||||||
|
try {
|
||||||
|
const res = await generateMusicLyrics(lyrPrompt.trim());
|
||||||
|
if (res?.text) {
|
||||||
|
const record = await saveLyrics({
|
||||||
|
title: res.title || '',
|
||||||
|
text: res.text,
|
||||||
|
prompt: lyrPrompt.trim(),
|
||||||
|
});
|
||||||
|
setSaved((prev) => [record, ...prev]);
|
||||||
|
} else {
|
||||||
|
setLyrError('가사 생성 결과가 없습니다');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setLyrError(e.message || '가사 생성에 실패했습니다');
|
||||||
|
} finally {
|
||||||
|
setLyrLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 복사 ── */
|
||||||
|
const handleCopy = (text, id) => {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setCopied(id);
|
||||||
|
setTimeout(() => setCopied(null), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 삭제 ── */
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
try {
|
||||||
|
await deleteLyrics(id);
|
||||||
|
setSaved((prev) => prev.filter((l) => l.id !== id));
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 수정 시작 ── */
|
||||||
|
const startEdit = (item) => {
|
||||||
|
setEditingId(item.id);
|
||||||
|
setEditTitle(item.title);
|
||||||
|
setEditText(item.text);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 수정 저장 ── */
|
||||||
|
const handleSaveEdit = async () => {
|
||||||
|
if (editingId == null) return;
|
||||||
|
try {
|
||||||
|
const updated = await updateLyrics(editingId, { title: editTitle, text: editText });
|
||||||
|
setSaved((prev) => prev.map((l) => l.id === editingId ? updated : l));
|
||||||
|
setEditingId(null);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 수정 취소 ── */
|
||||||
|
const cancelEdit = () => setEditingId(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ms-lyrics-tab">
|
||||||
|
<div className="ms-lyrics-tab__form">
|
||||||
|
<div className="ms-lyrics-tab__head">
|
||||||
|
<h2 className="ms-lyrics-tab__title">AI Lyrics Generator</h2>
|
||||||
|
<p className="ms-lyrics-tab__desc">
|
||||||
|
원하는 분위기, 주제, 스타일을 설명하면 AI가 가사를 작성합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ms-lyrics-tab__input-wrap">
|
||||||
|
<textarea
|
||||||
|
className="ms-lyrics-tab__input"
|
||||||
|
placeholder="예: 비 오는 밤, 혼자 걷는 도시의 거리를 배경으로 한 감성적인 발라드 가사"
|
||||||
|
value={lyrPrompt}
|
||||||
|
onChange={(e) => setLyrPrompt(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
maxLength={200}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleGenerate(); } }}
|
||||||
|
/>
|
||||||
|
<div className="ms-lyrics-tab__input-footer">
|
||||||
|
<span className="ms-lyrics-tab__count">{lyrPrompt.length}/200</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ms-btn ms-btn--accent ${lyrLoading ? 'is-loading' : ''}`}
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={!lyrPrompt.trim() || lyrLoading}
|
||||||
|
>
|
||||||
|
{lyrLoading ? (
|
||||||
|
<><span className="ms-btn__spinner" /> 생성 중...</>
|
||||||
|
) : (
|
||||||
|
'✨ 가사 생성'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lyrError && (
|
||||||
|
<div className="ms-error-banner">
|
||||||
|
<span>⚠ {lyrError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lyrLoading && (
|
||||||
|
<div className="ms-lyrics-tab__loading">
|
||||||
|
<div className="ms-lyrics-tab__loading-bar" />
|
||||||
|
<p>AI가 가사를 작성하고 있습니다...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 저장된 가사 목록 */}
|
||||||
|
{loadingSaved && (
|
||||||
|
<div className="ms-lyrics-tab__loading">
|
||||||
|
<div className="ms-lyrics-tab__loading-bar" />
|
||||||
|
<p>저장된 가사를 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loadingSaved && saved.length === 0 && !lyrLoading && (
|
||||||
|
<div className="ms-lyrics-tab__empty">
|
||||||
|
<span className="ms-lyrics-tab__empty-icon">🎤</span>
|
||||||
|
<p>저장된 가사가 없습니다</p>
|
||||||
|
<p className="ms-lyrics-tab__empty-hint">
|
||||||
|
프롬프트를 입력하면 AI가 [Verse], [Chorus] 등 섹션이 포함된 가사를 작성합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ms-lyrics-tab__results">
|
||||||
|
{saved.map((item) => (
|
||||||
|
<div key={item.id} className={`ms-lyrics-card ${editingId === item.id ? 'is-editing' : ''}`}>
|
||||||
|
<div className="ms-lyrics-card__header">
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<input
|
||||||
|
className="ms-lyrics-card__title-input"
|
||||||
|
value={editTitle}
|
||||||
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
|
placeholder="제목"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{item.title && <h3 className="ms-lyrics-card__title">{item.title}</h3>}
|
||||||
|
<span className="ms-lyrics-card__prompt">{item.prompt}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="ms-lyrics-card__date">
|
||||||
|
{item.created_at ? new Date(item.created_at).toLocaleDateString('ko-KR') : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<textarea
|
||||||
|
className="ms-lyrics-card__text-input"
|
||||||
|
value={editText}
|
||||||
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
|
rows={12}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<pre className="ms-lyrics-card__text">{item.text}</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ms-lyrics-card__actions">
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<>
|
||||||
|
<button type="button" className="ms-btn ms-btn--accent ms-btn--sm" onClick={handleSaveEdit}>
|
||||||
|
✓ 저장
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm" onClick={cancelEdit}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||||
|
onClick={() => handleCopy(item.text, item.id)}
|
||||||
|
>
|
||||||
|
{copied === item.id ? '✓ 복사됨' : '📋 복사'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||||
|
onClick={() => onUseInCreate(item.text)}
|
||||||
|
>
|
||||||
|
🎵 Create에서 사용
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||||
|
onClick={() => startEdit(item)}
|
||||||
|
>
|
||||||
|
✏️ 수정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ms-btn ms-btn--ghost ms-btn--sm ms-btn--danger-text"
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
>
|
||||||
|
🗑 삭제
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LyricsTab;
|
||||||
193
src/pages/music/components/RemixTab.jsx
Normal file
193
src/pages/music/components/RemixTab.jsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { uploadAndCover, uploadAndExtend, addVocals, addInstrumental } from '../../../api';
|
||||||
|
|
||||||
|
const REMIX_ACTIONS = [
|
||||||
|
{ id: 'cover', label: 'AI Cover', icon: '🎨', desc: '외부 음원을 Suno AI 스타일로 리메이크' },
|
||||||
|
{ id: 'extend', label: 'Extend', icon: '⏩', desc: '외부 음원을 이어서 확장' },
|
||||||
|
{ id: 'add-vocals', label: 'Add Vocals', icon: '🎤', desc: '인스트루멘탈에 AI 보컬 입히기' },
|
||||||
|
{ id: 'add-instrumental', label: 'Add Instrumental', icon: '🎹', desc: '보컬에 AI 반주 입히기' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RemixTab = ({ onTaskStarted, model, isGenerating }) => {
|
||||||
|
const [uploadUrl, setUploadUrl] = useState('');
|
||||||
|
const [activeAction, setActiveAction] = useState(null);
|
||||||
|
|
||||||
|
// 각 액션별 파라미터
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [style, setStyle] = useState('');
|
||||||
|
const [prompt, setPrompt] = useState('');
|
||||||
|
const [tags, setTags] = useState('');
|
||||||
|
const [negativeTags, setNegativeTags] = useState('');
|
||||||
|
const [vocalGender, setVocalGender] = useState(null);
|
||||||
|
const [continueAt, setContinueAt] = useState(0);
|
||||||
|
const [instrumental, setInstrumental] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!uploadUrl || !activeAction || isGenerating) return;
|
||||||
|
|
||||||
|
let apiCall;
|
||||||
|
let payload = {};
|
||||||
|
|
||||||
|
switch (activeAction) {
|
||||||
|
case 'cover':
|
||||||
|
apiCall = uploadAndCover;
|
||||||
|
payload = {
|
||||||
|
upload_url: uploadUrl, model, custom_mode: true,
|
||||||
|
instrumental, prompt, style, title,
|
||||||
|
vocal_gender: vocalGender || undefined,
|
||||||
|
negative_tags: negativeTags || undefined,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'extend':
|
||||||
|
apiCall = uploadAndExtend;
|
||||||
|
payload = {
|
||||||
|
upload_url: uploadUrl, model,
|
||||||
|
default_param_flag: !prompt,
|
||||||
|
continue_at: continueAt || undefined,
|
||||||
|
prompt, style, title, instrumental,
|
||||||
|
vocal_gender: vocalGender || undefined,
|
||||||
|
negative_tags: negativeTags || undefined,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'add-vocals':
|
||||||
|
apiCall = addVocals;
|
||||||
|
payload = {
|
||||||
|
upload_url: uploadUrl, prompt, title, style,
|
||||||
|
negative_tags: negativeTags,
|
||||||
|
vocal_gender: vocalGender || undefined,
|
||||||
|
model: 'V4_5PLUS',
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'add-instrumental':
|
||||||
|
apiCall = addInstrumental;
|
||||||
|
payload = {
|
||||||
|
upload_url: uploadUrl, title, tags,
|
||||||
|
negative_tags: negativeTags,
|
||||||
|
vocal_gender: vocalGender || undefined,
|
||||||
|
model: 'V4_5PLUS',
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiCall(payload);
|
||||||
|
if (res?.task_id) {
|
||||||
|
onTaskStarted(res.task_id, `Remix: ${REMIX_ACTIONS.find(a => a.id === activeAction)?.label}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 에러는 부모 컴포넌트에서 처리
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ms-remix-tab">
|
||||||
|
<div className="ms-remix-tab__header">
|
||||||
|
<h2 className="ms-remix-tab__title">Remix Studio</h2>
|
||||||
|
<p className="ms-remix-tab__desc">외부 음원을 AI로 리메이크, 확장, 보컬/반주 추가</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ms-param-group">
|
||||||
|
<label className="ms-param-label">Audio URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
className="ms-negative-tags__input"
|
||||||
|
placeholder="리믹스할 오디오 파일 URL (예: /media/music/track.mp3)"
|
||||||
|
value={uploadUrl}
|
||||||
|
onChange={(e) => setUploadUrl(e.target.value)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ms-remix-actions">
|
||||||
|
{REMIX_ACTIONS.map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.id}
|
||||||
|
type="button"
|
||||||
|
className={`ms-remix-card ${activeAction === action.id ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setActiveAction(activeAction === action.id ? null : action.id)}
|
||||||
|
>
|
||||||
|
<span className="ms-remix-card__icon">{action.icon}</span>
|
||||||
|
<span className="ms-remix-card__label">{action.label}</span>
|
||||||
|
<span className="ms-remix-card__desc">{action.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeAction && (
|
||||||
|
<div className="ms-remix-params">
|
||||||
|
{/* 공통 파라미터 */}
|
||||||
|
<div className="ms-param-group">
|
||||||
|
<label className="ms-param-label">Title</label>
|
||||||
|
<input type="text" className="ms-negative-tags__input" value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)} placeholder="곡 제목" style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && (
|
||||||
|
<div className="ms-param-group">
|
||||||
|
<label className="ms-param-label">Prompt / Lyrics</label>
|
||||||
|
<textarea className="ms-prompt" value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)} rows={3}
|
||||||
|
placeholder="가사 또는 스타일 설명" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && (
|
||||||
|
<div className="ms-param-group">
|
||||||
|
<label className="ms-param-label">Style</label>
|
||||||
|
<input type="text" className="ms-negative-tags__input" value={style}
|
||||||
|
onChange={(e) => setStyle(e.target.value)} placeholder="예: Pop, Energetic, Piano" style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeAction === 'add-instrumental' && (
|
||||||
|
<div className="ms-param-group">
|
||||||
|
<label className="ms-param-label">Tags (스타일/특성)</label>
|
||||||
|
<input type="text" className="ms-negative-tags__input" value={tags}
|
||||||
|
onChange={(e) => setTags(e.target.value)} placeholder="예: acoustic, warm, dreamy" style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeAction === 'extend' && (
|
||||||
|
<div className="ms-param-group">
|
||||||
|
<label className="ms-param-label">Continue At (초)</label>
|
||||||
|
<input type="number" className="ms-negative-tags__input" value={continueAt}
|
||||||
|
onChange={(e) => setContinueAt(Number(e.target.value))} min={0} style={{ width: '120px' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ms-param-group">
|
||||||
|
<label className="ms-param-label">Exclude Styles</label>
|
||||||
|
<input type="text" className="ms-negative-tags__input" value={negativeTags}
|
||||||
|
onChange={(e) => setNegativeTags(e.target.value)} placeholder="제외할 스타일" style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ms-param-group">
|
||||||
|
<label className="ms-param-label">Vocal Gender</label>
|
||||||
|
<div className="ms-gender-toggle">
|
||||||
|
{[{ value: null, label: 'Auto' }, { value: 'm', label: 'Male' }, { value: 'f', label: 'Female' }].map((opt) => (
|
||||||
|
<button key={opt.label} type="button"
|
||||||
|
className={`ms-gender-btn ${vocalGender === opt.value ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setVocalGender(opt.value)}>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ms-btn ms-btn--accent ms-remix-submit"
|
||||||
|
disabled={!uploadUrl || isGenerating}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{isGenerating ? 'Processing...' : `Start ${REMIX_ACTIONS.find(a => a.id === activeAction)?.label}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RemixTab;
|
||||||
55
src/pages/music/components/StemModal.jsx
Normal file
55
src/pages/music/components/StemModal.jsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const STEM_ICONS = {
|
||||||
|
vocal: '🎤', backing_vocals: '🎶', drums: '🥁', bass: '🎸',
|
||||||
|
guitar: '🎸', keyboard: '🎹', strings: '🎻', brass: '🎺',
|
||||||
|
woodwinds: '🪈', percussion: '🪘', synth: '🎛', fx: '✨',
|
||||||
|
};
|
||||||
|
|
||||||
|
const StemModal = ({ stems, onClose }) => {
|
||||||
|
const [playingStem, setPlayingStem] = useState(null);
|
||||||
|
|
||||||
|
if (!stems || Object.keys(stems).length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ms-modal-overlay" onClick={onClose}>
|
||||||
|
<div className="ms-modal ms-modal--wide" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="ms-modal__header">
|
||||||
|
<h3 className="ms-modal__title">12 Stems</h3>
|
||||||
|
<span className="ms-modal__subtitle">각 스템을 개별 재생 및 다운로드할 수 있습니다</span>
|
||||||
|
<button type="button" className="ms-modal__close" onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="ms-stem-grid">
|
||||||
|
{Object.entries(stems).map(([name, url]) => {
|
||||||
|
if (!url) return null;
|
||||||
|
const isPlaying = playingStem === name;
|
||||||
|
return (
|
||||||
|
<div key={name} className={`ms-stem-card ${isPlaying ? 'is-playing' : ''}`}>
|
||||||
|
<span className="ms-stem-card__icon">{STEM_ICONS[name] || '🎵'}</span>
|
||||||
|
<span className="ms-stem-card__name">{name.replace(/_/g, ' ')}</span>
|
||||||
|
<div className="ms-stem-card__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ms-btn--icon"
|
||||||
|
onClick={() => setPlayingStem(isPlaying ? null : name)}
|
||||||
|
>
|
||||||
|
{isPlaying ? '■' : '▶'}
|
||||||
|
</button>
|
||||||
|
<a href={url} download className="ms-btn--icon" aria-label="다운로드">↓</a>
|
||||||
|
</div>
|
||||||
|
{isPlaying && (
|
||||||
|
<audio src={url} autoPlay onEnded={() => setPlayingStem(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="ms-modal__actions">
|
||||||
|
<button type="button" className="ms-btn ms-btn--ghost" onClick={onClose}>닫기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StemModal;
|
||||||
51
src/pages/music/components/SyncedLyricsPlayer.jsx
Normal file
51
src/pages/music/components/SyncedLyricsPlayer.jsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const SyncedLyricsPlayer = ({ audioUrl, alignedWords, onClose, accentColor }) => {
|
||||||
|
const audioRef = useRef(null);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const handler = () => setCurrentTime(el.currentTime);
|
||||||
|
el.addEventListener('timeupdate', handler);
|
||||||
|
return () => el.removeEventListener('timeupdate', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!alignedWords || alignedWords.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ms-synced-player" style={{ '--synced-accent': accentColor }}>
|
||||||
|
<div className="ms-synced-player__header">
|
||||||
|
<h4 className="ms-synced-player__title">Synced Lyrics</h4>
|
||||||
|
<button type="button" className="ms-modal__close" onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={audioUrl}
|
||||||
|
onPlay={() => setPlaying(true)}
|
||||||
|
onPause={() => setPlaying(false)}
|
||||||
|
onEnded={() => setPlaying(false)}
|
||||||
|
controls
|
||||||
|
className="ms-synced-player__audio"
|
||||||
|
/>
|
||||||
|
<div className="ms-synced-player__lyrics">
|
||||||
|
{alignedWords.map((word, idx) => {
|
||||||
|
const isActive = currentTime >= word.startS && currentTime < word.endS;
|
||||||
|
const isPast = currentTime >= word.endS;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className={`ms-synced-word ${isActive ? 'is-active' : ''} ${isPast ? 'is-past' : ''}`}
|
||||||
|
>
|
||||||
|
{word.word}{' '}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SyncedLyricsPlayer;
|
||||||
1026
src/pages/realestate/RealEstate.css
Normal file
1026
src/pages/realestate/RealEstate.css
Normal file
File diff suppressed because it is too large
Load Diff
909
src/pages/realestate/RealEstate.jsx
Normal file
909
src/pages/realestate/RealEstate.jsx
Normal file
@@ -0,0 +1,909 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
|
ResponsiveContainer, Cell,
|
||||||
|
} from 'recharts';
|
||||||
|
import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import './RealEstate.css';
|
||||||
|
|
||||||
|
// ── 샘플 데이터 ────────────────────────────────────────────────────────────────
|
||||||
|
const SAMPLE_COMPLEXES = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '래미안 원베일리',
|
||||||
|
address: '서울 서초구 반포동',
|
||||||
|
lat: 37.5065,
|
||||||
|
lng: 126.9942,
|
||||||
|
units: 2990,
|
||||||
|
types: ['59㎡', '84㎡', '114㎡'],
|
||||||
|
avgPricePerPyeong: 9500,
|
||||||
|
subscriptionStart: '2024-01-08',
|
||||||
|
subscriptionEnd: '2024-01-10',
|
||||||
|
resultDate: '2024-01-15',
|
||||||
|
status: '완료',
|
||||||
|
priority: 'high',
|
||||||
|
tags: ['강남권', '한강뷰', '역세권', '브랜드'],
|
||||||
|
naverUrl: '',
|
||||||
|
floorPlanUrl: '',
|
||||||
|
memo: '반포동 재건축 단지. 경쟁률 수백대 1 예상.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '올림픽파크 포레온',
|
||||||
|
address: '서울 강동구 둔촌동',
|
||||||
|
lat: 37.5284,
|
||||||
|
lng: 127.1340,
|
||||||
|
units: 12032,
|
||||||
|
types: ['39㎡', '49㎡', '59㎡', '84㎡'],
|
||||||
|
avgPricePerPyeong: 3800,
|
||||||
|
subscriptionStart: '2022-12-05',
|
||||||
|
subscriptionEnd: '2022-12-07',
|
||||||
|
resultDate: '2022-12-12',
|
||||||
|
status: '완료',
|
||||||
|
priority: 'high',
|
||||||
|
tags: ['대단지', '역세권', '재건축'],
|
||||||
|
naverUrl: '',
|
||||||
|
floorPlanUrl: '',
|
||||||
|
memo: '역대 최대 규모 재건축 단지.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: '힐스테이트 동탄',
|
||||||
|
address: '경기 화성시 동탄2신도시',
|
||||||
|
lat: 37.2001,
|
||||||
|
lng: 127.0724,
|
||||||
|
units: 1534,
|
||||||
|
types: ['59㎡', '74㎡', '84㎡'],
|
||||||
|
avgPricePerPyeong: 1850,
|
||||||
|
subscriptionStart: '2026-04-10',
|
||||||
|
subscriptionEnd: '2026-04-12',
|
||||||
|
resultDate: '2026-04-17',
|
||||||
|
status: '청약예정',
|
||||||
|
priority: 'normal',
|
||||||
|
tags: ['동탄2', '신도시', 'SRT'],
|
||||||
|
naverUrl: '',
|
||||||
|
floorPlanUrl: '',
|
||||||
|
memo: '동탄 핵심 입지. 교통 개선 기대.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: '롯데캐슬 마곡',
|
||||||
|
address: '서울 강서구 마곡동',
|
||||||
|
lat: 37.5626,
|
||||||
|
lng: 126.8295,
|
||||||
|
units: 868,
|
||||||
|
types: ['59㎡', '84㎡'],
|
||||||
|
avgPricePerPyeong: 4200,
|
||||||
|
subscriptionStart: '2026-03-20',
|
||||||
|
subscriptionEnd: '2026-03-22',
|
||||||
|
resultDate: '2026-03-27',
|
||||||
|
status: '청약중',
|
||||||
|
priority: 'high',
|
||||||
|
tags: ['마곡', '9호선', '공항철도'],
|
||||||
|
naverUrl: '',
|
||||||
|
floorPlanUrl: '',
|
||||||
|
memo: '마곡 업무지구 직주근접. 강서 핵심 입지.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
'청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.12)' },
|
||||||
|
'청약중': { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
|
||||||
|
'결과발표': { color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' },
|
||||||
|
'완료': { color: '#6b7280', bg: 'rgba(107,114,128,0.10)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_LABELS = { high: '★ 최우선', normal: '보통', low: '낮음' };
|
||||||
|
|
||||||
|
const EMPTY_FORM = {
|
||||||
|
name: '', address: '', lat: '', lng: '',
|
||||||
|
units: '', types: '', avgPricePerPyeong: '',
|
||||||
|
subscriptionStart: '', subscriptionEnd: '', resultDate: '',
|
||||||
|
status: '청약예정', priority: 'normal',
|
||||||
|
tags: '', naverUrl: '', floorPlanUrl: '', memo: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TABS = ['목록', '일정', '분석'];
|
||||||
|
|
||||||
|
// ── 유틸 함수 ──────────────────────────────────────────────────────────────────
|
||||||
|
const formatDate = (d) => {
|
||||||
|
if (!d) return '-';
|
||||||
|
return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPrice = (v) => {
|
||||||
|
if (!v) return '-';
|
||||||
|
return `${v.toLocaleString()}만원`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDDays = (dateStr) => {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
const target = new Date(dateStr);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
target.setHours(0, 0, 0, 0);
|
||||||
|
const diff = Math.ceil((target - today) / (1000 * 60 * 60 * 24));
|
||||||
|
if (diff === 0) return 'D-Day';
|
||||||
|
if (diff > 0) return `D-${diff}`;
|
||||||
|
return `D+${Math.abs(diff)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMarkerIcon = (status, isSelected = false) => {
|
||||||
|
const cfg = STATUS_CONFIG[status] || STATUS_CONFIG['완료'];
|
||||||
|
const size = isSelected ? 18 : 12;
|
||||||
|
return L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="
|
||||||
|
width:${size}px;height:${size}px;border-radius:50%;
|
||||||
|
background:${cfg.color};
|
||||||
|
box-shadow:0 0 ${isSelected ? 16 : 8}px ${cfg.color};
|
||||||
|
border:2px solid rgba(255,255,255,${isSelected ? 0.6 : 0.25});
|
||||||
|
transition:all 0.2s;
|
||||||
|
"></div>`,
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
popupAnchor: [0, -(size / 2 + 4)],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 지도 중심 이동 (react-leaflet 내부 훅) ────────────────────────────────────
|
||||||
|
const MapFlyTo = ({ position, zoom }) => {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
if (position) {
|
||||||
|
map.flyTo(position, zoom ?? 14, { duration: 1.0 });
|
||||||
|
}
|
||||||
|
}, [position, zoom, map]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 단지 카드 ──────────────────────────────────────────────────────────────────
|
||||||
|
const ComplexCard = ({ complex, isSelected, onClick }) => {
|
||||||
|
const cfg = STATUS_CONFIG[complex.status] || STATUS_CONFIG['완료'];
|
||||||
|
const dday = getDDays(complex.subscriptionStart);
|
||||||
|
const isUpcoming = complex.status === '청약예정' || complex.status === '청약중';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`re-card ${isSelected ? 'is-selected' : ''}`} onClick={onClick}>
|
||||||
|
<div className="re-card__top">
|
||||||
|
<span className="re-badge" style={{ color: cfg.color, background: cfg.bg }}>
|
||||||
|
{complex.status}
|
||||||
|
</span>
|
||||||
|
{complex.priority === 'high' && <span className="re-priority-star">★</span>}
|
||||||
|
</div>
|
||||||
|
<h3 className="re-card__name">{complex.name}</h3>
|
||||||
|
<p className="re-card__address">{complex.address}</p>
|
||||||
|
<div className="re-card__stats">
|
||||||
|
<span>{complex.units.toLocaleString()}세대</span>
|
||||||
|
<span className="re-card__dot">·</span>
|
||||||
|
<span style={{ color: '#f59e0b' }}>{formatPrice(complex.avgPricePerPyeong)}/평</span>
|
||||||
|
</div>
|
||||||
|
<div className="re-chip-group">
|
||||||
|
{complex.types.map((t) => (
|
||||||
|
<span key={t} className="re-chip">{t}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{isUpcoming && dday && (
|
||||||
|
<div className="re-card__dday" style={{ color: cfg.color }}>
|
||||||
|
청약 {dday}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 인라인 지도 + 단지 상세 패널 ──────────────────────────────────────────────
|
||||||
|
const RightPanel = ({ complexes, selectedComplex, onSelectComplex, onEdit, onDelete }) => {
|
||||||
|
const cfg = selectedComplex
|
||||||
|
? STATUS_CONFIG[selectedComplex.status] || STATUS_CONFIG['완료']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const mapCenter = useMemo(() => {
|
||||||
|
if (selectedComplex) return [selectedComplex.lat, selectedComplex.lng];
|
||||||
|
if (complexes.length === 0) return [37.5665, 126.9780];
|
||||||
|
return [
|
||||||
|
complexes.reduce((s, c) => s + c.lat, 0) / complexes.length,
|
||||||
|
complexes.reduce((s, c) => s + c.lng, 0) / complexes.length,
|
||||||
|
];
|
||||||
|
}, []); // 초기 중심값만 계산 (flyTo로 이후 이동)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="re-right-panel">
|
||||||
|
{/* ── 지도 ── */}
|
||||||
|
<div className="re-panel re-panel--map">
|
||||||
|
<div className="re-mini-map-wrap">
|
||||||
|
<MapContainer
|
||||||
|
key="inline-map"
|
||||||
|
center={mapCenter}
|
||||||
|
zoom={10}
|
||||||
|
className="re-map"
|
||||||
|
scrollWheelZoom
|
||||||
|
zoomControl={false}
|
||||||
|
>
|
||||||
|
<MapFlyTo
|
||||||
|
position={selectedComplex ? [selectedComplex.lat, selectedComplex.lng] : null}
|
||||||
|
zoom={14}
|
||||||
|
/>
|
||||||
|
<TileLayer
|
||||||
|
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||||
|
attribution='© <a href="https://carto.com">CartoDB</a>'
|
||||||
|
/>
|
||||||
|
{complexes.map((c) => (
|
||||||
|
<Marker
|
||||||
|
key={c.id}
|
||||||
|
position={[c.lat, c.lng]}
|
||||||
|
icon={createMarkerIcon(c.status, selectedComplex?.id === c.id)}
|
||||||
|
eventHandlers={{ click: () => onSelectComplex(c) }}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<div className="re-popup">
|
||||||
|
<strong>{c.name}</strong>
|
||||||
|
<span>{c.address}</span>
|
||||||
|
<span>{c.status} · {c.units.toLocaleString()}세대</span>
|
||||||
|
<span>{formatPrice(c.avgPricePerPyeong)}/평</span>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MapContainer>
|
||||||
|
{selectedComplex && (
|
||||||
|
<div className="re-map-label">
|
||||||
|
<span style={{ color: cfg.color }}>●</span> {selectedComplex.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 상세 패널 ── */}
|
||||||
|
{selectedComplex ? (
|
||||||
|
<div className="re-detail" key={selectedComplex.id}>
|
||||||
|
<div className="re-detail__header">
|
||||||
|
<div>
|
||||||
|
<span className="re-badge re-badge--lg" style={{ color: cfg.color, background: cfg.bg }}>
|
||||||
|
{selectedComplex.status}
|
||||||
|
</span>
|
||||||
|
<h2 className="re-detail__name">{selectedComplex.name}</h2>
|
||||||
|
<p className="re-detail__address">{selectedComplex.address}</p>
|
||||||
|
</div>
|
||||||
|
<div className="re-detail__header-actions">
|
||||||
|
<button className="button ghost small" onClick={onEdit}>편집</button>
|
||||||
|
<button className="button danger small" onClick={onDelete}>삭제</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="re-detail__section">
|
||||||
|
<div className="re-detail__stats-grid">
|
||||||
|
<div className="re-stat">
|
||||||
|
<p className="re-stat__label">세대수</p>
|
||||||
|
<p className="re-stat__value">{selectedComplex.units.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="re-stat">
|
||||||
|
<p className="re-stat__label">평당가</p>
|
||||||
|
<p className="re-stat__value" style={{ color: '#f59e0b' }}>
|
||||||
|
{formatPrice(selectedComplex.avgPricePerPyeong)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="re-stat">
|
||||||
|
<p className="re-stat__label">우선순위</p>
|
||||||
|
<p className="re-stat__value" style={{ fontSize: 13 }}>
|
||||||
|
{PRIORITY_LABELS[selectedComplex.priority]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="re-detail__section">
|
||||||
|
<p className="re-detail__section-title">평형대</p>
|
||||||
|
<div className="re-chip-group">
|
||||||
|
{selectedComplex.types.map((t) => (
|
||||||
|
<span key={t} className="re-chip re-chip--lg">{t}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="re-detail__section">
|
||||||
|
<p className="re-detail__section-title">청약 일정</p>
|
||||||
|
<div className="re-timeline">
|
||||||
|
<div className="re-timeline__item">
|
||||||
|
<div className="re-timeline__dot re-timeline__dot--start" />
|
||||||
|
<div>
|
||||||
|
<p className="re-timeline__label">청약 시작</p>
|
||||||
|
<p className="re-timeline__date">{formatDate(selectedComplex.subscriptionStart)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="re-timeline__item">
|
||||||
|
<div className="re-timeline__dot" />
|
||||||
|
<div>
|
||||||
|
<p className="re-timeline__label">청약 마감</p>
|
||||||
|
<p className="re-timeline__date">{formatDate(selectedComplex.subscriptionEnd)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="re-timeline__item">
|
||||||
|
<div className="re-timeline__dot re-timeline__dot--result" />
|
||||||
|
<div>
|
||||||
|
<p className="re-timeline__label">당첨 발표</p>
|
||||||
|
<p className="re-timeline__date">{formatDate(selectedComplex.resultDate)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedComplex.tags.length > 0 && (
|
||||||
|
<div className="re-detail__section">
|
||||||
|
<p className="re-detail__section-title">특징</p>
|
||||||
|
<div className="re-chip-group">
|
||||||
|
{selectedComplex.tags.map((tag) => (
|
||||||
|
<span key={tag} className="re-chip re-chip--tag">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedComplex.memo && (
|
||||||
|
<div className="re-detail__section">
|
||||||
|
<p className="re-detail__section-title">메모</p>
|
||||||
|
<p className="re-detail__memo">{selectedComplex.memo}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="re-detail__actions">
|
||||||
|
{selectedComplex.naverUrl ? (
|
||||||
|
<a href={selectedComplex.naverUrl} target="_blank" rel="noreferrer" className="button primary small">
|
||||||
|
네이버 부동산 →
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={`https://new.land.naver.com/search?query=${encodeURIComponent(selectedComplex.name)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="button ghost small"
|
||||||
|
>
|
||||||
|
네이버 검색 →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{selectedComplex.floorPlanUrl && (
|
||||||
|
<a href={selectedComplex.floorPlanUrl} target="_blank" rel="noreferrer" className="button ghost small">
|
||||||
|
평면도 보기
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="re-detail re-detail--empty">
|
||||||
|
<div className="re-detail__empty-icon">🏢</div>
|
||||||
|
<p>카드 또는 지도 마커를 클릭하면<br />단지 상세 정보가 표시됩니다</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 청약 일정 타임라인 ─────────────────────────────────────────────────────────
|
||||||
|
const ScheduleView = ({ complexes }) => {
|
||||||
|
const events = complexes
|
||||||
|
.filter((c) => c.subscriptionStart)
|
||||||
|
.flatMap((c) => [
|
||||||
|
{ date: c.subscriptionStart, label: '청약 시작', complex: c, type: 'start' },
|
||||||
|
{ date: c.subscriptionEnd, label: '청약 마감', complex: c, type: 'end' },
|
||||||
|
{ date: c.resultDate, label: '당첨 발표', complex: c, type: 'result' },
|
||||||
|
])
|
||||||
|
.filter((e) => e.date)
|
||||||
|
.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const upcoming = events.filter((e) => new Date(e.date) >= today);
|
||||||
|
const past = events.filter((e) => new Date(e.date) < today).reverse();
|
||||||
|
|
||||||
|
const EventItem = ({ event }) => {
|
||||||
|
const cfg = STATUS_CONFIG[event.complex.status] || STATUS_CONFIG['완료'];
|
||||||
|
const dday = getDDays(event.date);
|
||||||
|
return (
|
||||||
|
<div className={`re-schedule-item re-schedule-item--${event.type}`}>
|
||||||
|
<div className="re-schedule-item__date">
|
||||||
|
<span className="re-schedule-item__dday" style={{ color: cfg.color }}>{dday}</span>
|
||||||
|
<span className="re-schedule-item__datestr">{formatDate(event.date)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="re-schedule-item__dot" style={{ background: cfg.color, boxShadow: `0 0 6px ${cfg.color}` }} />
|
||||||
|
<div className="re-schedule-item__content">
|
||||||
|
<p className="re-schedule-item__complex">{event.complex.name}</p>
|
||||||
|
<p className="re-schedule-item__label">{event.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="re-schedule">
|
||||||
|
{upcoming.length > 0 && (
|
||||||
|
<div className="re-schedule-section">
|
||||||
|
<h4 className="re-schedule-section__title">예정 일정</h4>
|
||||||
|
{upcoming.map((e, i) => <EventItem key={i} event={e} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{past.length > 0 && (
|
||||||
|
<div className="re-schedule-section">
|
||||||
|
<h4 className="re-schedule-section__title re-schedule-section__title--past">지난 일정</h4>
|
||||||
|
{past.map((e, i) => <EventItem key={i} event={e} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{events.length === 0 && <p className="re-empty">등록된 청약 일정이 없습니다.</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 가격 분석 ──────────────────────────────────────────────────────────────────
|
||||||
|
const PriceAnalysis = ({ complexes }) => {
|
||||||
|
const chartData = [...complexes]
|
||||||
|
.filter((c) => c.avgPricePerPyeong > 0)
|
||||||
|
.sort((a, b) => b.avgPricePerPyeong - a.avgPricePerPyeong)
|
||||||
|
.map((c) => ({
|
||||||
|
name: c.name.length > 9 ? c.name.slice(0, 9) + '…' : c.name,
|
||||||
|
price: c.avgPricePerPyeong,
|
||||||
|
status: c.status,
|
||||||
|
fullName: c.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload }) => {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="re-chart-tooltip">
|
||||||
|
<p>{payload[0].payload.fullName}</p>
|
||||||
|
<p className="re-chart-tooltip__value">{payload[0].value.toLocaleString()}만원/평</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const prices = complexes.map((c) => c.avgPricePerPyeong).filter((v) => v > 0);
|
||||||
|
const avg = prices.length ? Math.round(prices.reduce((s, v) => s + v, 0) / prices.length) : 0;
|
||||||
|
const max = prices.length ? Math.max(...prices) : 0;
|
||||||
|
const min = prices.length ? Math.min(...prices) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="re-analysis">
|
||||||
|
<div className="re-analysis__stats">
|
||||||
|
<div className="re-stat-card">
|
||||||
|
<p className="re-stat-card__label">평균 평당가</p>
|
||||||
|
<p className="re-stat-card__value">{formatPrice(avg)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="re-stat-card">
|
||||||
|
<p className="re-stat-card__label">최고 평당가</p>
|
||||||
|
<p className="re-stat-card__value" style={{ color: '#f59e0b' }}>{formatPrice(max)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="re-stat-card">
|
||||||
|
<p className="re-stat-card__label">최저 평당가</p>
|
||||||
|
<p className="re-stat-card__value" style={{ color: '#34d399' }}>{formatPrice(min)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="re-panel">
|
||||||
|
<div className="re-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="re-panel__eyebrow">가격 비교</p>
|
||||||
|
<h3>단지별 평당가</h3>
|
||||||
|
<p className="re-panel__sub">관심 단지의 평당 분양가를 비교합니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="re-chart-wrapper">
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<BarChart data={chartData} margin={{ top: 10, right: 20, left: 10, bottom: 50 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
stroke="var(--text-dim)"
|
||||||
|
tick={{ fill: 'var(--text-dim)', fontSize: 11 }}
|
||||||
|
angle={-20}
|
||||||
|
textAnchor="end"
|
||||||
|
height={60}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="var(--text-dim)"
|
||||||
|
tick={{ fill: 'var(--text-dim)', fontSize: 11 }}
|
||||||
|
tickFormatter={(v) => `${(v / 1000).toFixed(1)}k`}
|
||||||
|
width={44}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.03)' }} />
|
||||||
|
<Bar dataKey="price" radius={[4, 4, 0, 0]}>
|
||||||
|
{chartData.map((entry, index) => {
|
||||||
|
const cfg = STATUS_CONFIG[entry.status] || STATUS_CONFIG['완료'];
|
||||||
|
return <Cell key={index} fill={cfg.color} fillOpacity={0.75} />;
|
||||||
|
})}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="re-panel">
|
||||||
|
<div className="re-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="re-panel__eyebrow">비교표</p>
|
||||||
|
<h3>단지 상세 비교</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="re-table-wrapper">
|
||||||
|
<table className="re-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>단지명</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>세대수</th>
|
||||||
|
<th>평형대</th>
|
||||||
|
<th>평당가</th>
|
||||||
|
<th>청약 시작</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{complexes.map((c) => {
|
||||||
|
const cfg = STATUS_CONFIG[c.status] || STATUS_CONFIG['완료'];
|
||||||
|
return (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td className="re-table__name">{c.name}</td>
|
||||||
|
<td>
|
||||||
|
<span className="re-badge" style={{ color: cfg.color, background: cfg.bg }}>
|
||||||
|
{c.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{c.units.toLocaleString()}</td>
|
||||||
|
<td>{c.types.join(', ')}</td>
|
||||||
|
<td style={{ color: '#f59e0b', fontWeight: 600 }}>
|
||||||
|
{formatPrice(c.avgPricePerPyeong)}
|
||||||
|
</td>
|
||||||
|
<td>{formatDate(c.subscriptionStart)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 단지 추가/편집 모달 ────────────────────────────────────────────────────────
|
||||||
|
const ComplexModal = ({ complex, onClose, onSave }) => {
|
||||||
|
const [form, setForm] = useState(
|
||||||
|
complex
|
||||||
|
? {
|
||||||
|
...complex,
|
||||||
|
types: complex.types.join(', '),
|
||||||
|
tags: complex.tags.join(', '),
|
||||||
|
lat: String(complex.lat),
|
||||||
|
lng: String(complex.lng),
|
||||||
|
units: String(complex.units),
|
||||||
|
avgPricePerPyeong: String(complex.avgPricePerPyeong),
|
||||||
|
}
|
||||||
|
: { ...EMPTY_FORM }
|
||||||
|
);
|
||||||
|
|
||||||
|
const set = (field) => (e) => setForm((prev) => ({ ...prev, [field]: e.target.value }));
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave({
|
||||||
|
...form,
|
||||||
|
lat: parseFloat(form.lat) || 37.5665,
|
||||||
|
lng: parseFloat(form.lng) || 126.9780,
|
||||||
|
units: parseInt(form.units) || 0,
|
||||||
|
avgPricePerPyeong: parseInt(form.avgPricePerPyeong) || 0,
|
||||||
|
types: form.types.split(',').map((t) => t.trim()).filter(Boolean),
|
||||||
|
tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="re-modal-overlay" onClick={onClose}>
|
||||||
|
<div className="re-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="re-modal__header">
|
||||||
|
<h3>{complex ? '단지 편집' : '새 단지 추가'}</h3>
|
||||||
|
<button className="re-modal__close" onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
<form className="re-modal__form" onSubmit={handleSubmit}>
|
||||||
|
<div className="re-form-section">
|
||||||
|
<p className="re-form-section__title">기본 정보</p>
|
||||||
|
<div className="re-form-row">
|
||||||
|
<label className="re-form-label">
|
||||||
|
단지명 *
|
||||||
|
<input className="re-form-input" value={form.name} onChange={set('name')} placeholder="단지명 입력" required />
|
||||||
|
</label>
|
||||||
|
<label className="re-form-label">
|
||||||
|
상태
|
||||||
|
<select className="re-form-input" value={form.status} onChange={set('status')}>
|
||||||
|
{Object.keys(STATUS_CONFIG).map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="re-form-label">
|
||||||
|
주소
|
||||||
|
<input className="re-form-input" value={form.address} onChange={set('address')} placeholder="서울 서초구 반포동" />
|
||||||
|
</label>
|
||||||
|
<div className="re-form-row">
|
||||||
|
<label className="re-form-label">
|
||||||
|
위도 (lat)
|
||||||
|
<input className="re-form-input" value={form.lat} onChange={set('lat')} placeholder="37.5665" type="number" step="0.0001" />
|
||||||
|
</label>
|
||||||
|
<label className="re-form-label">
|
||||||
|
경도 (lng)
|
||||||
|
<input className="re-form-input" value={form.lng} onChange={set('lng')} placeholder="126.9780" type="number" step="0.0001" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="re-form-section">
|
||||||
|
<p className="re-form-section__title">단지 정보</p>
|
||||||
|
<div className="re-form-row">
|
||||||
|
<label className="re-form-label">
|
||||||
|
세대수
|
||||||
|
<input className="re-form-input" value={form.units} onChange={set('units')} placeholder="2990" type="number" />
|
||||||
|
</label>
|
||||||
|
<label className="re-form-label">
|
||||||
|
평당가 (만원)
|
||||||
|
<input className="re-form-input" value={form.avgPricePerPyeong} onChange={set('avgPricePerPyeong')} placeholder="4500" type="number" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="re-form-row">
|
||||||
|
<label className="re-form-label">
|
||||||
|
평형대 (쉼표 구분)
|
||||||
|
<input className="re-form-input" value={form.types} onChange={set('types')} placeholder="59㎡, 84㎡, 114㎡" />
|
||||||
|
</label>
|
||||||
|
<label className="re-form-label">
|
||||||
|
우선순위
|
||||||
|
<select className="re-form-input" value={form.priority} onChange={set('priority')}>
|
||||||
|
<option value="high">★ 최우선</option>
|
||||||
|
<option value="normal">보통</option>
|
||||||
|
<option value="low">낮음</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="re-form-label">
|
||||||
|
특징 태그 (쉼표 구분)
|
||||||
|
<input className="re-form-input" value={form.tags} onChange={set('tags')} placeholder="강남권, 역세권, 브랜드" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="re-form-section">
|
||||||
|
<p className="re-form-section__title">청약 일정</p>
|
||||||
|
<div className="re-form-row re-form-row--three">
|
||||||
|
<label className="re-form-label">
|
||||||
|
청약 시작
|
||||||
|
<input className="re-form-input" type="date" value={form.subscriptionStart} onChange={set('subscriptionStart')} />
|
||||||
|
</label>
|
||||||
|
<label className="re-form-label">
|
||||||
|
청약 마감
|
||||||
|
<input className="re-form-input" type="date" value={form.subscriptionEnd} onChange={set('subscriptionEnd')} />
|
||||||
|
</label>
|
||||||
|
<label className="re-form-label">
|
||||||
|
당첨 발표
|
||||||
|
<input className="re-form-input" type="date" value={form.resultDate} onChange={set('resultDate')} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="re-form-section">
|
||||||
|
<p className="re-form-section__title">링크 & 메모</p>
|
||||||
|
<label className="re-form-label">
|
||||||
|
네이버 부동산 URL
|
||||||
|
<input className="re-form-input" value={form.naverUrl} onChange={set('naverUrl')} placeholder="https://new.land.naver.com/complexes/..." />
|
||||||
|
</label>
|
||||||
|
<label className="re-form-label">
|
||||||
|
평면도 URL
|
||||||
|
<input className="re-form-input" value={form.floorPlanUrl} onChange={set('floorPlanUrl')} placeholder="https://..." />
|
||||||
|
</label>
|
||||||
|
<label className="re-form-label">
|
||||||
|
메모
|
||||||
|
<textarea
|
||||||
|
className="re-form-input re-form-textarea"
|
||||||
|
value={form.memo}
|
||||||
|
onChange={set('memo')}
|
||||||
|
placeholder="관심 포인트, 분석 내용 등"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="re-modal__footer">
|
||||||
|
<button type="button" className="button ghost" onClick={onClose}>취소</button>
|
||||||
|
<button type="submit" className="button primary">{complex ? '저장' : '추가'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 메인 컴포넌트 ──────────────────────────────────────────────────────────────
|
||||||
|
const RealEstate = () => {
|
||||||
|
const [complexes, setComplexes] = useState(SAMPLE_COMPLEXES);
|
||||||
|
const [selectedComplex, setSelectedComplex] = useState(null);
|
||||||
|
const [activeTab, setActiveTab] = useState('목록');
|
||||||
|
const [filterStatus, setFilterStatus] = useState('전체');
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingComplex, setEditingComplex] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiGet('/api/realestate/complexes')
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data) && data.length > 0) setComplexes(data);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAdd = async (data) => {
|
||||||
|
const newComplex = { ...data, id: Date.now() };
|
||||||
|
setComplexes((prev) => [...prev, newComplex]);
|
||||||
|
setShowModal(false);
|
||||||
|
try { await apiPost('/api/realestate/complexes', data); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async (data) => {
|
||||||
|
setComplexes((prev) => prev.map((c) => (c.id === data.id ? data : c)));
|
||||||
|
if (selectedComplex?.id === data.id) setSelectedComplex(data);
|
||||||
|
setEditingComplex(null);
|
||||||
|
setShowModal(false);
|
||||||
|
try { await apiPut(`/api/realestate/complexes/${data.id}`, data); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (!confirm('삭제하시겠습니까?')) return;
|
||||||
|
setComplexes((prev) => prev.filter((c) => c.id !== id));
|
||||||
|
if (selectedComplex?.id === id) setSelectedComplex(null);
|
||||||
|
try { await apiDelete(`/api/realestate/complexes/${id}`); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalSave = (data) => {
|
||||||
|
if (editingComplex) {
|
||||||
|
handleUpdate({ ...editingComplex, ...data });
|
||||||
|
} else {
|
||||||
|
handleAdd(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredComplexes = useMemo(() => {
|
||||||
|
if (filterStatus === '전체') return complexes;
|
||||||
|
return complexes.filter((c) => c.status === filterStatus);
|
||||||
|
}, [complexes, filterStatus]);
|
||||||
|
|
||||||
|
const stats = useMemo(() => ({
|
||||||
|
total: complexes.length,
|
||||||
|
upcoming: complexes.filter((c) => c.status === '청약예정').length,
|
||||||
|
active: complexes.filter((c) => c.status === '청약중').length,
|
||||||
|
avgPrice: complexes.length
|
||||||
|
? Math.round(complexes.reduce((s, c) => s + c.avgPricePerPyeong, 0) / complexes.length)
|
||||||
|
: 0,
|
||||||
|
}), [complexes]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="re">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<header className="re-header">
|
||||||
|
<div>
|
||||||
|
<p className="re-kicker">부동산 정보</p>
|
||||||
|
<h1>관심 단지 관리</h1>
|
||||||
|
<p className="re-sub">관심 있는 아파트 단지 정보를 수집하고 분석합니다.</p>
|
||||||
|
<div className="re-header-actions">
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
onClick={() => { setEditingComplex(null); setShowModal(true); }}
|
||||||
|
>
|
||||||
|
+ 단지 추가
|
||||||
|
</button>
|
||||||
|
<Link to="/realestate" className="button ghost">← 청약 대시보드</Link>
|
||||||
|
<a href="https://www.applyhome.co.kr" target="_blank" rel="noreferrer" className="button ghost">
|
||||||
|
청약홈 →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="re-stats-bar">
|
||||||
|
<div className="re-stat-item">
|
||||||
|
<p className="re-stat-item__value">{stats.total}</p>
|
||||||
|
<p className="re-stat-item__label">관심 단지</p>
|
||||||
|
</div>
|
||||||
|
<div className="re-stat-item">
|
||||||
|
<p className="re-stat-item__value" style={{ color: '#00d4ff' }}>{stats.upcoming}</p>
|
||||||
|
<p className="re-stat-item__label">청약 예정</p>
|
||||||
|
</div>
|
||||||
|
<div className="re-stat-item">
|
||||||
|
<p className="re-stat-item__value" style={{ color: '#34d399' }}>{stats.active}</p>
|
||||||
|
<p className="re-stat-item__label">청약 중</p>
|
||||||
|
</div>
|
||||||
|
<div className="re-stat-item">
|
||||||
|
<p className="re-stat-item__value" style={{ color: '#f59e0b' }}>
|
||||||
|
{stats.avgPrice.toLocaleString()}만
|
||||||
|
</p>
|
||||||
|
<p className="re-stat-item__label">평균 평당가</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 탭 바 */}
|
||||||
|
<div className="re-tabs-bar">
|
||||||
|
<div className="re-tabs">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
className={`re-tab ${activeTab === tab ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{activeTab === '목록' && (
|
||||||
|
<div className="re-filter">
|
||||||
|
{['전체', ...Object.keys(STATUS_CONFIG)].map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
className={`re-filter-btn ${filterStatus === s ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setFilterStatus(s)}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 목록 탭 — 카드 + 지도/상세 */}
|
||||||
|
{activeTab === '목록' && (
|
||||||
|
<div className="re-list-layout">
|
||||||
|
<div className="re-card-grid">
|
||||||
|
{filteredComplexes.length === 0 ? (
|
||||||
|
<p className="re-empty">등록된 단지가 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
filteredComplexes.map((c) => (
|
||||||
|
<ComplexCard
|
||||||
|
key={c.id}
|
||||||
|
complex={c}
|
||||||
|
isSelected={selectedComplex?.id === c.id}
|
||||||
|
onClick={() => setSelectedComplex(c)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<RightPanel
|
||||||
|
complexes={complexes}
|
||||||
|
selectedComplex={selectedComplex}
|
||||||
|
onSelectComplex={setSelectedComplex}
|
||||||
|
onEdit={() => { setEditingComplex(selectedComplex); setShowModal(true); }}
|
||||||
|
onDelete={() => handleDelete(selectedComplex.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === '일정' && (
|
||||||
|
<div className="re-panel">
|
||||||
|
<div className="re-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="re-panel__eyebrow">캘린더</p>
|
||||||
|
<h3>청약 일정</h3>
|
||||||
|
<p className="re-panel__sub">청약 시작·마감·당첨 발표일을 타임라인으로 확인합니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ScheduleView complexes={complexes} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === '분석' && (
|
||||||
|
<PriceAnalysis complexes={complexes} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showModal && (
|
||||||
|
<ComplexModal
|
||||||
|
complex={editingComplex}
|
||||||
|
onClose={() => { setShowModal(false); setEditingComplex(null); }}
|
||||||
|
onSave={handleModalSave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RealEstate;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { getStockIndices, getStockNews } from '../../api';
|
import { getStockIndices, getStockNews, getFearAndGreed, getVix, getTreasury10Y, getWTI, getBrent } from '../../api';
|
||||||
import Loading from '../../components/Loading';
|
import Loading from '../../components/Loading';
|
||||||
|
import FearGreedGauge from '../../components/FearGreedGauge';
|
||||||
import './Stock.css';
|
import './Stock.css';
|
||||||
|
|
||||||
const formatDate = (value) => {
|
const formatDate = (value) => {
|
||||||
@@ -11,21 +12,6 @@ const formatDate = (value) => {
|
|||||||
return date.toLocaleString('sv-SE');
|
return date.toLocaleString('sv-SE');
|
||||||
};
|
};
|
||||||
|
|
||||||
const toDateValue = (value) => {
|
|
||||||
if (!value) return null;
|
|
||||||
const date = new Date(value);
|
|
||||||
return Number.isNaN(date.getTime()) ? null : date;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLatestBy = (items, key) => {
|
|
||||||
const filtered = items
|
|
||||||
.map((item) => ({ ...item, __date: toDateValue(item?.[key]) }))
|
|
||||||
.filter((item) => item.__date);
|
|
||||||
if (!filtered.length) return null;
|
|
||||||
filtered.sort((a, b) => b.__date - a.__date);
|
|
||||||
return filtered[0]?.[key] ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeIndices = (data) => {
|
const normalizeIndices = (data) => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
|
|
||||||
@@ -65,23 +51,63 @@ const normalizeIndices = (data) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getDirection = (change, percent, direction) => {
|
const getDirection = (change, percent, direction) => {
|
||||||
if (direction === 'red') return 'up';
|
// 숫자 부호로 방향 추출 (percent → change 순서로 시도)
|
||||||
if (direction === 'blue') return 'down';
|
const fromStr = (s) => {
|
||||||
const pick = (value) =>
|
if (s === undefined || s === null || s === '') return null;
|
||||||
value === undefined || value === null || value === '' ? null : value;
|
const str = String(s).trim();
|
||||||
const raw = pick(change) ?? pick(percent);
|
if (str.startsWith('-')) return 'down';
|
||||||
if (!raw) return '';
|
if (str.startsWith('+')) return 'up';
|
||||||
const str = String(raw).trim();
|
const numeric = Number(str.replace(/[^0-9.-]/g, ''));
|
||||||
if (str.startsWith('-')) return 'down';
|
if (Number.isFinite(numeric) && numeric !== 0) {
|
||||||
if (str.startsWith('+')) return 'up';
|
return numeric > 0 ? 'up' : 'down';
|
||||||
const numeric = Number(str.replace(/[^0-9.-]/g, ''));
|
}
|
||||||
if (Number.isFinite(numeric)) {
|
return null;
|
||||||
if (numeric > 0) return 'up';
|
};
|
||||||
if (numeric < 0) return 'down';
|
// percent 필드가 부호를 가장 신뢰성 있게 포함하는 경우가 많음
|
||||||
|
const byPercent = fromStr(percent);
|
||||||
|
if (byPercent) return byPercent;
|
||||||
|
const byChange = fromStr(change);
|
||||||
|
if (byChange) return byChange;
|
||||||
|
// 숫자로 판별 불가 시 direction 필드 fallback
|
||||||
|
if (direction) {
|
||||||
|
const d = String(direction).toLowerCase();
|
||||||
|
if (d === 'red' || d === 'up' || d === 'rise' || d === 'positive') return 'up';
|
||||||
|
if (d === 'blue' || d === 'down' || d === 'fall' || d === 'negative') return 'down';
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const VIX_LEVELS = [
|
||||||
|
{
|
||||||
|
range: '0 – 12', label: '극히 낮음', color: '#22c55e',
|
||||||
|
desc: '시장이 극도로 안정적. 오히려 투자자 안일함의 신호일 수 있어, 갑작스러운 조정에 대비가 필요합니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
range: '12 – 20', label: '정상', color: '#84cc16',
|
||||||
|
desc: '시장이 안정적인 상태. 보통 상승장에서 나타나며, 건강한 변동성 수준입니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
range: '20 – 30', label: '주의', color: '#eab308',
|
||||||
|
desc: '불확실성이 높아지는 구간. 주가와 반대로 움직이며, 단기 바닥 신호로 해석되기도 합니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
range: '30 – 40', label: '높음', color: '#f97316',
|
||||||
|
desc: '극도의 공포가 퍼진 상태. 급격한 매도세가 나타나지만, 역사적으로 역발상 매수 기회가 되기도 합니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
range: '40+', label: '극단', color: '#ef4444',
|
||||||
|
desc: '패닉 수준의 공포. 2008 금융위기·2020 코로나 때 발생. VIX가 꺾이기 시작하면 심리적 진정의 시작입니다.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const getVixLevel = (score) => {
|
||||||
|
if (score < 12) return VIX_LEVELS[0];
|
||||||
|
if (score < 20) return VIX_LEVELS[1];
|
||||||
|
if (score < 30) return VIX_LEVELS[2];
|
||||||
|
if (score < 40) return VIX_LEVELS[3];
|
||||||
|
return VIX_LEVELS[4];
|
||||||
|
};
|
||||||
|
|
||||||
const Stock = () => {
|
const Stock = () => {
|
||||||
const [newsDomestic, setNewsDomestic] = useState([]);
|
const [newsDomestic, setNewsDomestic] = useState([]);
|
||||||
const [newsOverseas, setNewsOverseas] = useState([]);
|
const [newsOverseas, setNewsOverseas] = useState([]);
|
||||||
@@ -94,14 +120,14 @@ const Stock = () => {
|
|||||||
const [indicesLoading, setIndicesLoading] = useState(false);
|
const [indicesLoading, setIndicesLoading] = useState(false);
|
||||||
const [autoRefreshMs] = useState(180000);
|
const [autoRefreshMs] = useState(180000);
|
||||||
|
|
||||||
|
const [fgData, setFgData] = useState(null);
|
||||||
|
const [vixData, setVixData] = useState(null);
|
||||||
|
const [macroData, setMacroData] = useState({ treasury: null, wti: null, brent: null });
|
||||||
|
|
||||||
const combinedNews = useMemo(
|
const combinedNews = useMemo(
|
||||||
() => [...newsDomestic, ...newsOverseas],
|
() => [...newsDomestic, ...newsOverseas],
|
||||||
[newsDomestic, newsOverseas]
|
[newsDomestic, newsOverseas]
|
||||||
);
|
);
|
||||||
const latestPublished = useMemo(
|
|
||||||
() => getLatestBy(combinedNews, 'published_at'),
|
|
||||||
[combinedNews]
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadNews = async () => {
|
const loadNews = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -143,6 +169,32 @@ const Stock = () => {
|
|||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [autoRefreshMs]);
|
}, [autoRefreshMs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSentiment = () => {
|
||||||
|
getFearAndGreed()
|
||||||
|
.then((data) => {
|
||||||
|
const fg = data?.fear_and_greed ?? data;
|
||||||
|
const score = typeof fg?.score === 'number' ? fg.score : parseFloat(fg?.score);
|
||||||
|
if (!isNaN(score)) {
|
||||||
|
setFgData({ score, timestamp: fg?.timestamp ?? null });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { });
|
||||||
|
getVix().then(setVixData).catch(() => { });
|
||||||
|
Promise.allSettled([getTreasury10Y(), getWTI(), getBrent()])
|
||||||
|
.then(([t, w, b]) => {
|
||||||
|
setMacroData({
|
||||||
|
treasury: t.status === 'fulfilled' ? t.value : null,
|
||||||
|
wti: w.status === 'fulfilled' ? w.value : null,
|
||||||
|
brent: b.status === 'fulfilled' ? b.value : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
loadSentiment();
|
||||||
|
const timer = window.setInterval(loadSentiment, 600000); // 10분마다 갱신
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const indexOrder = [
|
const indexOrder = [
|
||||||
'KOSPI',
|
'KOSPI',
|
||||||
'KOSDAQ',
|
'KOSDAQ',
|
||||||
@@ -262,61 +314,147 @@ const Stock = () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* 시장 심리 지표 행 */}
|
||||||
<section className="stock-filter-row">
|
<section className="stock-filter-row">
|
||||||
<div className="stock-panel stock-panel--compact">
|
<div className="stock-panel stock-panel--compact">
|
||||||
<div className="stock-panel__head">
|
<div className="stock-panel__head">
|
||||||
<div>
|
<div>
|
||||||
<p className="stock-panel__eyebrow">필터</p>
|
<p className="stock-panel__eyebrow">심리 지표</p>
|
||||||
<h3>뉴스 필터</h3>
|
<h3>Fear & Greed</h3>
|
||||||
<p className="stock-panel__sub">
|
<p className="stock-panel__sub">시장 탐욕·공포 지수 (0–100)</p>
|
||||||
표시할 뉴스 개수를 조정합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stock-filter">
|
{fgData ? (
|
||||||
<label>
|
<FearGreedGauge
|
||||||
표시 개수
|
score={Math.round(fgData.score)}
|
||||||
<select
|
date={fgData.timestamp ? new Date(fgData.timestamp).toLocaleDateString('ko-KR') : undefined}
|
||||||
value={limit}
|
showLevels
|
||||||
onChange={(event) =>
|
/>
|
||||||
setLimit(Number(event.target.value))
|
) : (
|
||||||
}
|
<p className="stock-empty" style={{ fontSize: 13 }}>데이터 없음</p>
|
||||||
>
|
)}
|
||||||
{[10, 20, 30, 40].map((value) => (
|
|
||||||
<option key={value} value={value}>
|
|
||||||
{value}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<p className="stock-filter__note">
|
|
||||||
최신 뉴스가 먼저 표시됩니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="stock-panel stock-panel--compact">
|
<div className="stock-panel stock-panel--compact">
|
||||||
<div className="stock-panel__head">
|
<div className="stock-panel__head">
|
||||||
<div>
|
<div>
|
||||||
<p className="stock-panel__eyebrow">요약</p>
|
<p className="stock-panel__eyebrow">변동성 지수</p>
|
||||||
<h3>뉴스 요약</h3>
|
<h3>VIX</h3>
|
||||||
<p className="stock-panel__sub">
|
<p className="stock-panel__sub">CBOE 공포 지수</p>
|
||||||
최신 발행 시각과 기사 수를 확인합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stock-status">
|
{vixData ? (
|
||||||
<div>
|
<div className="stock-vix">
|
||||||
<span>최신 발행</span>
|
<div className="stock-vix__top">
|
||||||
<strong>{formatDate(latestPublished)}</strong>
|
<div className="stock-vix__score" style={{ color: getVixLevel(vixData.value ?? 0).color }}>
|
||||||
|
{vixData.value ?? '--'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="stock-vix__label" style={{ color: getVixLevel(vixData.value ?? 0).color }}>
|
||||||
|
{getVixLevel(vixData.value ?? 0).label}
|
||||||
|
</p>
|
||||||
|
{vixData.change != null && (
|
||||||
|
<p className={`stock-vix__change ${vixData.change >= 0 ? 'is-up' : 'is-down'}`}>
|
||||||
|
{vixData.change >= 0 ? '+' : ''}{vixData.change}
|
||||||
|
{vixData.changePercent != null && ` (${vixData.changePercent >= 0 ? '+' : ''}${vixData.changePercent}%)`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-vix__levels">
|
||||||
|
{VIX_LEVELS.map((level) => (
|
||||||
|
<div
|
||||||
|
key={level.range}
|
||||||
|
className={`stock-vix__level ${level.label === getVixLevel(vixData.value ?? 0).label ? 'is-current' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="stock-vix__level-head">
|
||||||
|
<span className="stock-vix__level-dot" style={{ background: level.color }} />
|
||||||
|
<span className="stock-vix__level-label" style={{ color: level.color }}>{level.label}</span>
|
||||||
|
<span className="stock-vix__level-range">{level.range}</span>
|
||||||
|
</div>
|
||||||
|
<p className="stock-vix__level-desc">{level.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
) : (
|
||||||
<span>국내</span>
|
<p className="stock-empty" style={{ fontSize: 13 }}>데이터 없음</p>
|
||||||
<strong>{newsDomestic.length}</strong>
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 매크로 지표 섹션 */}
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">글로벌 매크로</p>
|
||||||
|
<h3>매크로 지표</h3>
|
||||||
|
<p className="stock-panel__sub">금리·원자재 등 주요 거시경제 지표를 확인합니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-macro-grid">
|
||||||
|
<div className="stock-macro-card">
|
||||||
|
<p className="stock-macro-card__title">미국 10년물 국채 금리</p>
|
||||||
|
<div className="stock-macro-card__value">
|
||||||
|
{macroData.treasury ? `${macroData.treasury.value}%` : '--'}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{macroData.treasury?.change != null && (
|
||||||
<span>해외</span>
|
<p className={`stock-macro-card__change ${macroData.treasury.change >= 0 ? 'is-up' : 'is-down'}`}>
|
||||||
<strong>{newsOverseas.length}</strong>
|
{macroData.treasury.change >= 0 ? '+' : ''}{macroData.treasury.change}
|
||||||
|
{macroData.treasury.changePercent != null && ` (${macroData.treasury.changePercent >= 0 ? '+' : ''}${macroData.treasury.changePercent}%)`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="stock-macro-card__desc">금리 상승 시 주식 밸류에이션 압박. 4% 이상 지속은 주식 하락 압력 신호. 단기 급등은 인플레이션 우려를 반영합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="stock-macro-card">
|
||||||
|
<p className="stock-macro-card__title">WTI 유가</p>
|
||||||
|
<div className="stock-macro-card__value">
|
||||||
|
{macroData.wti ? `$${macroData.wti.value}` : '--'}
|
||||||
</div>
|
</div>
|
||||||
|
{macroData.wti?.change != null && (
|
||||||
|
<p className={`stock-macro-card__change ${macroData.wti.change >= 0 ? 'is-up' : 'is-down'}`}>
|
||||||
|
{macroData.wti.change >= 0 ? '+' : ''}{macroData.wti.change}
|
||||||
|
{macroData.wti.changePercent != null && ` (${macroData.wti.changePercent >= 0 ? '+' : ''}${macroData.wti.changePercent}%)`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="stock-macro-card__desc">에너지 인플레이션 지표. $80 이상 지속 시 물가 상승 우려 확대. 급락은 경기침체 가능성을 반영하기도 합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="stock-macro-card">
|
||||||
|
<p className="stock-macro-card__title">Brent 유가</p>
|
||||||
|
<div className="stock-macro-card__value">
|
||||||
|
{macroData.brent ? `$${macroData.brent.value}` : '--'}
|
||||||
|
</div>
|
||||||
|
{macroData.brent?.change != null && (
|
||||||
|
<p className={`stock-macro-card__change ${macroData.brent.change >= 0 ? 'is-up' : 'is-down'}`}>
|
||||||
|
{macroData.brent.change >= 0 ? '+' : ''}{macroData.brent.change}
|
||||||
|
{macroData.brent.changePercent != null && ` (${macroData.brent.changePercent >= 0 ? '+' : ''}${macroData.brent.changePercent}%)`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="stock-macro-card__desc">국제 기준 유가. WTI와 함께 에너지 시장 방향을 파악하는 데 활용. 지정학 리스크 시 WTI 대비 프리미엄 형성.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 시장 건강 지표 (Placeholder) */}
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">시장 건강</p>
|
||||||
|
<h3>시장 건강 지표</h3>
|
||||||
|
<p className="stock-panel__sub">백엔드 API 연동 후 실시간 데이터를 표시합니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-health-grid">
|
||||||
|
<div className="stock-placeholder-card">
|
||||||
|
<p className="stock-placeholder-card__title">ADR (등락주선 비율)</p>
|
||||||
|
<div className="stock-placeholder-card__status">🔧 데이터 준비 중</div>
|
||||||
|
<p className="stock-placeholder-card__desc">일정 기간 상승종목 ÷ (상승+하락) 종목 비율. 0.5 이상 = 폭넓은 상승장. 0.3 이하 = 일부 대형주만 오르는 약세 신호.</p>
|
||||||
|
<code className="stock-placeholder-card__api">GET /api/stock/adr</code>
|
||||||
|
</div>
|
||||||
|
<div className="stock-placeholder-card">
|
||||||
|
<p className="stock-placeholder-card__title">고객예탁금 / 신용융자</p>
|
||||||
|
<div className="stock-placeholder-card__status">🔧 데이터 준비 중</div>
|
||||||
|
<p className="stock-placeholder-card__desc">고객예탁금 증가 = 투자 대기자금 유입 = 강세. 신용융자 급증 = 과열 경고. 예탁금 감소 + 신용 급증 = 위험 구간.</p>
|
||||||
|
<code className="stock-placeholder-card__api">GET /api/stock/deposit</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -347,46 +485,46 @@ const Stock = () => {
|
|||||||
<p className="stock-empty">뉴스가 없습니다.</p>
|
<p className="stock-empty">뉴스가 없습니다.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="stock-tabs">
|
<div className="stock-news-toolbar">
|
||||||
<button
|
<div className="stock-tabs">
|
||||||
type="button"
|
<button
|
||||||
className={`stock-tab ${newsCategory === 'domestic'
|
type="button"
|
||||||
? 'is-active'
|
className={`stock-tab ${newsCategory === 'domestic' ? 'is-active' : ''}`}
|
||||||
: ''
|
onClick={() => setNewsCategory('domestic')}
|
||||||
}`}
|
>
|
||||||
onClick={() => setNewsCategory('domestic')}
|
국내 <span className="stock-tab-count">{newsDomestic.length}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`stock-tab ${newsCategory === 'overseas' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setNewsCategory('overseas')}
|
||||||
|
>
|
||||||
|
해외 <span className="stock-tab-count">{newsOverseas.length}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="stock-news-limit"
|
||||||
|
value={limit}
|
||||||
|
onChange={(e) => setLimit(Number(e.target.value))}
|
||||||
>
|
>
|
||||||
국내
|
{[10, 20, 30, 40].map((v) => (
|
||||||
</button>
|
<option key={v} value={v}>{v}개</option>
|
||||||
<button
|
))}
|
||||||
type="button"
|
</select>
|
||||||
className={`stock-tab ${newsCategory === 'overseas'
|
|
||||||
? 'is-active'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
onClick={() => setNewsCategory('overseas')}
|
|
||||||
>
|
|
||||||
해외
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{activeNews.length === 0 ? (
|
{activeNews.length === 0 ? (
|
||||||
<p className="stock-empty">
|
<p className="stock-empty">
|
||||||
해당 카테고리 뉴스가 없습니다.
|
해당 카테고리 뉴스가 없습니다.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="stock-news">
|
<div className="stock-news-grid">
|
||||||
{activeNews.map((item) => (
|
{activeNews.map((item) => (
|
||||||
<article
|
<article
|
||||||
key={item.id ?? item.link}
|
key={item.id ?? item.link}
|
||||||
className="stock-news__item"
|
className="stock-news-card"
|
||||||
>
|
>
|
||||||
<div>
|
<div className="stock-news-card__head">
|
||||||
<p className="stock-news__title">
|
<span className="stock-news-card__date">
|
||||||
{item.title}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="stock-news__meta">
|
|
||||||
<span>
|
|
||||||
{formatDate(item.published_at)}
|
{formatDate(item.published_at)}
|
||||||
</span>
|
</span>
|
||||||
{item.sentiment ? (
|
{item.sentiment ? (
|
||||||
@@ -394,14 +532,25 @@ const Stock = () => {
|
|||||||
{item.sentiment}
|
{item.sentiment}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="stock-news-card__title">
|
||||||
|
{item.title}
|
||||||
|
</p>
|
||||||
|
{item.summary && (
|
||||||
|
<p className="stock-news-card__summary">
|
||||||
|
{item.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{item.link && (
|
||||||
<a
|
<a
|
||||||
href={item.link}
|
href={item.link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
|
className="stock-news-card__link"
|
||||||
>
|
>
|
||||||
원문 보기
|
원문 보기 →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
)}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,422 +1,208 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { createTradeOrder, getTradeBalance } from '../../api';
|
|
||||||
import './Stock.css';
|
import './Stock.css';
|
||||||
|
import {
|
||||||
|
formatNumber, formatPercent,
|
||||||
|
toNumeric, profitColorClass,
|
||||||
|
TAB_PORTFOLIO, TAB_AI, TAB_REPORT, TAB_ADVISOR,
|
||||||
|
} from './stockUtils';
|
||||||
|
|
||||||
const formatNumber = (value) => {
|
/* ── hooks ──────────────────────────────────────────────────────── */
|
||||||
if (value === null || value === undefined || value === '') return '-';
|
import usePortfolio from './hooks/usePortfolio';
|
||||||
const numeric = Number(value);
|
import useSellHistory from './hooks/useSellHistory';
|
||||||
if (Number.isNaN(numeric)) return value;
|
import useAiCoach from './hooks/useAiCoach';
|
||||||
return new Intl.NumberFormat('ko-KR').format(numeric);
|
import useAssetHistory from './hooks/useAssetHistory';
|
||||||
};
|
import useMarketContext from './hooks/useMarketContext';
|
||||||
|
import useAiBalance from './hooks/useAiBalance';
|
||||||
|
import useReportData from './hooks/useReportData';
|
||||||
|
import useAdvisor from './hooks/useAdvisor';
|
||||||
|
|
||||||
const formatPercent = (value) => {
|
/* ── tab components ─────────────────────────────────────────────── */
|
||||||
if (value === null || value === undefined || value === '') return '-';
|
import PortfolioTab from './components/PortfolioTab';
|
||||||
if (typeof value === 'string' && value.includes('%')) return value;
|
import AiTradeTab from './components/AiTradeTab';
|
||||||
const numeric = Number(value);
|
import ReportTab from './components/ReportTab';
|
||||||
if (Number.isNaN(numeric)) return value;
|
import AdvisorTab from './components/AdvisorTab';
|
||||||
return `${numeric.toFixed(2)}%`;
|
import SellHistoryDrawer from './components/SellHistoryDrawer';
|
||||||
};
|
|
||||||
|
|
||||||
const pickFirst = (...values) =>
|
/* ── component ───────────────────────────────────────────────────── */
|
||||||
values.find((value) => value !== undefined && value !== null && value !== '');
|
|
||||||
|
|
||||||
const getQty = (item) =>
|
|
||||||
pickFirst(item?.qty, item?.quantity, item?.holding, item?.hold_qty);
|
|
||||||
|
|
||||||
const getBuyPrice = (item) =>
|
|
||||||
pickFirst(
|
|
||||||
item?.buy_price,
|
|
||||||
item?.avg_price,
|
|
||||||
item?.avg,
|
|
||||||
item?.purchase_price,
|
|
||||||
item?.buyPrice,
|
|
||||||
item?.price
|
|
||||||
);
|
|
||||||
|
|
||||||
const getCurrentPrice = (item) =>
|
|
||||||
pickFirst(
|
|
||||||
item?.current_price,
|
|
||||||
item?.current,
|
|
||||||
item?.cur_price,
|
|
||||||
item?.now_price,
|
|
||||||
item?.market_price
|
|
||||||
);
|
|
||||||
|
|
||||||
const getProfitRate = (item) =>
|
|
||||||
pickFirst(
|
|
||||||
item?.profit_rate,
|
|
||||||
item?.profitRate,
|
|
||||||
item?.profit_pct,
|
|
||||||
item?.profitPercent,
|
|
||||||
item?.pnl_rate,
|
|
||||||
item?.return_rate,
|
|
||||||
item?.yield
|
|
||||||
);
|
|
||||||
|
|
||||||
const getProfitLoss = (item) =>
|
|
||||||
pickFirst(item?.profit_loss, item?.pnl, item?.profitLoss);
|
|
||||||
|
|
||||||
const toNumeric = (value) => {
|
|
||||||
if (value === null || value === undefined || value === '') return null;
|
|
||||||
const numeric = Number(String(value).replace(/[^0-9.-]/g, ''));
|
|
||||||
return Number.isNaN(numeric) ? null : numeric;
|
|
||||||
};
|
|
||||||
|
|
||||||
const StockTrade = () => {
|
const StockTrade = () => {
|
||||||
const [balance, setBalance] = useState(null);
|
const [activeTab, setActiveTab] = React.useState(TAB_REPORT);
|
||||||
const [balanceLoading, setBalanceLoading] = useState(false);
|
|
||||||
const [balanceError, setBalanceError] = useState('');
|
/* ── hooks ────────────────────────────────────────────────────── */
|
||||||
const [manualForm, setManualForm] = useState({
|
const pf = usePortfolio();
|
||||||
code: '',
|
const sell = useSellHistory();
|
||||||
qty: 1,
|
const asset = useAssetHistory();
|
||||||
price: 0,
|
const marketCtx = useMarketContext(activeTab === TAB_REPORT || activeTab === TAB_ADVISOR);
|
||||||
type: 'buy',
|
const ai = useAiCoach({
|
||||||
|
portfolioHoldings: pf.portfolioHoldings,
|
||||||
|
portfolioSummary: pf.portfolioSummary,
|
||||||
|
totalCash: pf.totalCash,
|
||||||
|
totalAssets: pf.totalAssets,
|
||||||
|
marketCtx,
|
||||||
|
});
|
||||||
|
const aib = useAiBalance();
|
||||||
|
const report = useReportData({
|
||||||
|
portfolioHoldings: pf.portfolioHoldings,
|
||||||
|
portfolioSummary: pf.portfolioSummary,
|
||||||
|
brokerGroups: pf.brokerGroups,
|
||||||
|
getBrokerSummary: pf.getBrokerSummary,
|
||||||
|
});
|
||||||
|
const advisor = useAdvisor({
|
||||||
|
portfolioHoldings: pf.portfolioHoldings,
|
||||||
|
portfolioSummary: pf.portfolioSummary,
|
||||||
|
cashList: pf.cashList,
|
||||||
|
totalCash: pf.totalCash,
|
||||||
|
totalAssets: pf.totalAssets,
|
||||||
|
marketCtx,
|
||||||
});
|
});
|
||||||
const [manualLoading, setManualLoading] = useState(false);
|
|
||||||
const [manualError, setManualError] = useState('');
|
|
||||||
const [manualResult, setManualResult] = useState(null);
|
|
||||||
const [kisModal, setKisModal] = useState('');
|
|
||||||
|
|
||||||
const loadBalance = async () => {
|
/* ── sell history filter derived ─────────────────────────────── */
|
||||||
setBalanceLoading(true);
|
const sellHistoryBrokers = useMemo(() => {
|
||||||
setBalanceError('');
|
const set = new Set(sell.sellHistory.map((r) => r.broker).filter(Boolean));
|
||||||
try {
|
return ['ALL', ...Array.from(set).sort()];
|
||||||
const data = await getTradeBalance();
|
}, [sell.sellHistory]);
|
||||||
setBalance(data);
|
|
||||||
} catch (err) {
|
|
||||||
setBalanceError(err?.message ?? String(err));
|
|
||||||
} finally {
|
|
||||||
setBalanceLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitManualOrder = async (event) => {
|
const filteredSellHistory = useMemo(() => {
|
||||||
event.preventDefault();
|
const now = new Date();
|
||||||
setManualLoading(true);
|
const periodMs = {
|
||||||
setManualError('');
|
'1M': 30 * 86400000, '3M': 90 * 86400000,
|
||||||
setManualResult(null);
|
'6M': 180 * 86400000, '1Y': 365 * 86400000, 'ALL': Infinity,
|
||||||
try {
|
}[sell.sellHistoryPeriod] ?? Infinity;
|
||||||
const payload = {
|
return sell.sellHistory.filter((r) => {
|
||||||
ticker: manualForm.code.trim(),
|
if (sell.sellHistoryBroker !== 'ALL' && r.broker !== sell.sellHistoryBroker) return false;
|
||||||
action: manualForm.type === 'sell' ? 'SELL' : 'BUY',
|
return (now - new Date(r.sold_at)) <= periodMs;
|
||||||
quantity: Number(manualForm.qty),
|
});
|
||||||
price: Number(manualForm.price),
|
}, [sell.sellHistory, sell.sellHistoryBroker, sell.sellHistoryPeriod]);
|
||||||
};
|
|
||||||
const result = await createTradeOrder(payload);
|
const sellHistorySummary = useMemo(() => {
|
||||||
setManualResult(result ?? { ok: true });
|
const totalProfit = filteredSellHistory.reduce((s, r) => s + (r.realized_profit ?? 0), 0);
|
||||||
if (result?.kis_result !== undefined) {
|
const totalSell = filteredSellHistory.reduce((s, r) => s + (r.sell_amount ?? 0), 0);
|
||||||
const message =
|
const totalBuy = filteredSellHistory.reduce((s, r) => s + (r.buy_amount ?? 0), 0);
|
||||||
typeof result.kis_result === 'string'
|
const totalCommission = filteredSellHistory.reduce((s, r) => s + (r.commission ?? 0), 0);
|
||||||
? result.kis_result
|
const rate = totalBuy > 0 ? (totalProfit / totalBuy) * 100 : 0;
|
||||||
: JSON.stringify(result.kis_result, null, 2);
|
return { totalProfit, totalSell, totalBuy, totalCommission, rate, count: filteredSellHistory.length };
|
||||||
setKisModal(message);
|
}, [filteredSellHistory]);
|
||||||
}
|
|
||||||
await loadBalance();
|
/* ── lazy load ───────────────────────────────────────────────── */
|
||||||
} catch (err) {
|
useEffect(() => {
|
||||||
setManualError(err?.message ?? String(err));
|
if (activeTab === TAB_PORTFOLIO && !pf.portfolioLoaded) {
|
||||||
} finally {
|
pf.loadPortfolio();
|
||||||
setManualLoading(false);
|
sell.loadSellHistory();
|
||||||
|
} else if (activeTab === TAB_AI && !aib.balanceLoaded) {
|
||||||
|
aib.loadBalance();
|
||||||
|
} else if ((activeTab === TAB_REPORT || activeTab === TAB_ADVISOR) && !pf.portfolioLoaded) {
|
||||||
|
pf.loadPortfolio();
|
||||||
}
|
}
|
||||||
};
|
}, [activeTab, pf.portfolioLoaded, aib.balanceLoaded]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadBalance();
|
if (activeTab === TAB_PORTFOLIO) asset.loadAssetHistory(asset.assetHistoryDays);
|
||||||
}, []);
|
}, [activeTab, asset.assetHistoryDays]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const holdings = useMemo(() => {
|
useEffect(() => {
|
||||||
if (!balance) return [];
|
if (activeTab !== TAB_PORTFOLIO) return;
|
||||||
if (Array.isArray(balance.holdings)) return balance.holdings;
|
const timer = window.setInterval(pf.loadPortfolio, 180000);
|
||||||
if (Array.isArray(balance.positions)) return balance.positions;
|
return () => window.clearInterval(timer);
|
||||||
if (Array.isArray(balance.items)) return balance.items;
|
}, [activeTab, pf.loadPortfolio]);
|
||||||
return [];
|
|
||||||
}, [balance]);
|
|
||||||
const summary = balance?.summary ?? {};
|
|
||||||
const totalEval =
|
|
||||||
summary.total_eval ?? balance?.total_eval ?? balance?.total_value;
|
|
||||||
const deposit =
|
|
||||||
summary.deposit ?? balance?.deposit ?? balance?.available_cash;
|
|
||||||
|
|
||||||
|
/* ── cross-hook wrappers ─────────────────────────────────────── */
|
||||||
|
const handleSell = (item) =>
|
||||||
|
pf.handleSell(item, { cashList: pf.cashList, loadSellHistoryAfter: sell.addSellRecord });
|
||||||
|
|
||||||
|
const handleSaveSnapshot = () =>
|
||||||
|
asset.handleSaveSnapshot(pf.totalAssets, asset.assetHistoryDays);
|
||||||
|
|
||||||
|
/* ── render ───────────────────────────────────────────────────── */
|
||||||
return (
|
return (
|
||||||
<div className="stock">
|
<div className="stock">
|
||||||
|
{/* Header */}
|
||||||
<header className="stock-header">
|
<header className="stock-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="stock-kicker">거래 데스크</p>
|
<p className="stock-kicker">거래 데스크</p>
|
||||||
<h1>주식 거래</h1>
|
<h1>거래 데스크</h1>
|
||||||
<p className="stock-sub">
|
<p className="stock-sub">실제 계좌와 AI 모의투자를 한 곳에서 관리하세요.</p>
|
||||||
연결된 계좌 잔고를 확인하고 필요한 주문을 요청하세요.
|
|
||||||
</p>
|
|
||||||
<div className="stock-actions">
|
<div className="stock-actions">
|
||||||
<Link className="button ghost" to="/stock">
|
<Link className="button ghost" to="/stock">주식 랩으로 돌아가기</Link>
|
||||||
주식 랩으로 돌아가기
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stock-card">
|
<div className="stock-card">
|
||||||
<p className="stock-card__title">계좌 요약</p>
|
<p className="stock-card__title">
|
||||||
<div className="stock-status">
|
{activeTab === TAB_AI ? 'AI 투자 요약' : '쟁승토리 계좌 요약'}
|
||||||
<div>
|
</p>
|
||||||
<span>총 평가금액</span>
|
{activeTab === TAB_PORTFOLIO || activeTab === TAB_REPORT ? (
|
||||||
<strong>{formatNumber(totalEval)}</strong>
|
<div className="stock-status">
|
||||||
|
<div><span>총 매입</span><strong>{formatNumber(pf.portfolioSummary.total_buy)}</strong></div>
|
||||||
|
<div><span>총 평가</span><strong>{formatNumber(pf.portfolioSummary.total_eval)}</strong></div>
|
||||||
|
<div>
|
||||||
|
<span>총 손익</span>
|
||||||
|
<strong className={`stock-profit ${profitColorClass(toNumeric(pf.portfolioSummary.total_profit))}`}>
|
||||||
|
{formatNumber(pf.portfolioSummary.total_profit)}
|
||||||
|
{pf.portfolioSummary.total_profit_rate != null && (
|
||||||
|
<small style={{ marginLeft: 4, fontSize: 11 }}>
|
||||||
|
({formatPercent(pf.portfolioSummary.total_profit_rate)})
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div><span>보유 종목</span><strong>{pf.portfolioHoldings.length}</strong></div>
|
||||||
|
{pf.totalCash != null && (
|
||||||
|
<div><span>예수금 합계</span><strong style={{ color: '#93c5fd' }}>{formatNumber(pf.totalCash)}원</strong></div>
|
||||||
|
)}
|
||||||
|
{pf.totalAssets != null && (
|
||||||
|
<div><span>총 자산</span><strong style={{ fontWeight: 700 }}>{formatNumber(pf.totalAssets)}원</strong></div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
) : (
|
||||||
<span>예수금</span>
|
<div className="stock-status">
|
||||||
<strong>{formatNumber(deposit)}</strong>
|
<div><span>총 평가금액</span><strong>{formatNumber(aib.totalEval)}</strong></div>
|
||||||
|
<div><span>예수금</span><strong>{formatNumber(aib.deposit)}</strong></div>
|
||||||
|
<div><span>보유 종목</span><strong>{aib.holdings.length}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<span>보유 종목</span>
|
{activeTab === TAB_AI && aib.summary.note ? (
|
||||||
<strong>{holdings.length}</strong>
|
<p className="stock-status__note">{aib.summary.note}</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{summary.note ? (
|
|
||||||
<p className="stock-status__note">{summary.note}</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{balanceError ? <p className="stock-error">{balanceError}</p> : null}
|
{/* Tab bar */}
|
||||||
|
<div className="stock-main-tabs">
|
||||||
<section className="stock-panel stock-panel--wide">
|
{[
|
||||||
<div className="stock-panel__head">
|
{ id: TAB_PORTFOLIO, icon: '💼', label: '쟁승토리 계좌', badge: pf.portfolioHoldings.length || null },
|
||||||
<div>
|
{ id: TAB_AI, icon: '🤖', label: 'AI 투자', sub: '모의투자' },
|
||||||
<p className="stock-panel__eyebrow">잔고</p>
|
{ id: TAB_REPORT, icon: '📊', label: '리포트', sub: '분석·AI코치' },
|
||||||
<h3>보유 현황</h3>
|
{ id: TAB_ADVISOR, icon: '🧠', label: 'AI 어드바이저', sub: 'Gemini Pro', className: 'stock-main-tab--advisor' },
|
||||||
<p className="stock-panel__sub">
|
].map(({ id, icon, label, sub, badge, className: cls }) => (
|
||||||
연결 계좌의 실시간 잔고와 보유 종목을 확인합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="stock-panel__actions">
|
|
||||||
{balanceLoading ? (
|
|
||||||
<span className="stock-chip">조회 중</span>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
className="button ghost small"
|
|
||||||
onClick={loadBalance}
|
|
||||||
disabled={balanceLoading}
|
|
||||||
>
|
|
||||||
새로고침
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="stock-balance">
|
|
||||||
<div className="stock-balance__summary">
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
label: '총 평가',
|
|
||||||
value: totalEval,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '예수금',
|
|
||||||
value: deposit,
|
|
||||||
},
|
|
||||||
].map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.label}
|
|
||||||
className="stock-balance__card"
|
|
||||||
>
|
|
||||||
<span>{item.label}</span>
|
|
||||||
<strong>{formatNumber(item.value)}</strong>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{holdings.length ? (
|
|
||||||
<div className="stock-holdings">
|
|
||||||
{holdings.map((item, idx) => {
|
|
||||||
const profitLoss = getProfitLoss(item);
|
|
||||||
const profitLossNumeric = toNumeric(profitLoss);
|
|
||||||
const profitClass =
|
|
||||||
profitLossNumeric > 0
|
|
||||||
? 'is-up'
|
|
||||||
: profitLossNumeric < 0
|
|
||||||
? 'is-down'
|
|
||||||
: profitLossNumeric === 0
|
|
||||||
? 'is-flat'
|
|
||||||
: '';
|
|
||||||
const profitRate = getProfitRate(item);
|
|
||||||
const profitRateNumeric = toNumeric(profitRate);
|
|
||||||
const profitRateClass =
|
|
||||||
profitRateNumeric > 0
|
|
||||||
? 'is-up'
|
|
||||||
: profitRateNumeric < 0
|
|
||||||
? 'is-down'
|
|
||||||
: profitRateNumeric === 0
|
|
||||||
? 'is-flat'
|
|
||||||
: '';
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.code ?? `${item.name}-${idx}`}
|
|
||||||
className="stock-holdings__item"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="stock-holdings__name">
|
|
||||||
{item.name ?? item.code ?? 'N/A'}
|
|
||||||
</p>
|
|
||||||
<span className="stock-holdings__code">
|
|
||||||
{item.code ?? ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="stock-holdings__metric">
|
|
||||||
<span>수량</span>
|
|
||||||
<strong>
|
|
||||||
{formatNumber(getQty(item))}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
<div className="stock-holdings__metric">
|
|
||||||
<span>매입가</span>
|
|
||||||
<strong>
|
|
||||||
{formatNumber(getBuyPrice(item))}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
<div className="stock-holdings__metric">
|
|
||||||
<span>현재가</span>
|
|
||||||
<strong>
|
|
||||||
{formatNumber(
|
|
||||||
getCurrentPrice(item)
|
|
||||||
)}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
<div className="stock-holdings__metric">
|
|
||||||
<span>수익률</span>
|
|
||||||
<strong
|
|
||||||
className={`stock-profit ${profitRateClass}`}
|
|
||||||
>
|
|
||||||
{formatPercent(profitRate)}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
<div className="stock-holdings__metric">
|
|
||||||
<span>평가손익</span>
|
|
||||||
<strong
|
|
||||||
className={`stock-profit ${profitClass}`}
|
|
||||||
>
|
|
||||||
{formatNumber(profitLoss)}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="stock-empty">보유 종목이 없습니다.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="stock-panel stock-panel--wide">
|
|
||||||
<div className="stock-panel__head">
|
|
||||||
<div>
|
|
||||||
<p className="stock-panel__eyebrow">수동 주문</p>
|
|
||||||
<h3>직접 매수/매도</h3>
|
|
||||||
<p className="stock-panel__sub">
|
|
||||||
종목명 또는 종목코드를 입력하고 매수/매도 주문을
|
|
||||||
요청합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form className="stock-order" onSubmit={submitManualOrder}>
|
|
||||||
<label>
|
|
||||||
종목명/코드
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={manualForm.code}
|
|
||||||
onChange={(event) =>
|
|
||||||
setManualForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
code: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="005930 또는 삼성전자"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
매수/매도
|
|
||||||
<select
|
|
||||||
value={manualForm.type}
|
|
||||||
onChange={(event) =>
|
|
||||||
setManualForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
type: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="buy">매수</option>
|
|
||||||
<option value="sell">매도</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
수량
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
step={1}
|
|
||||||
value={manualForm.qty}
|
|
||||||
onChange={(event) =>
|
|
||||||
setManualForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
qty: Number(event.target.value),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
금액(원)
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step={1}
|
|
||||||
value={manualForm.price}
|
|
||||||
onChange={(event) =>
|
|
||||||
setManualForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
price: Number(event.target.value),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button
|
<button
|
||||||
className="button primary"
|
key={id}
|
||||||
type="submit"
|
type="button"
|
||||||
disabled={manualLoading}
|
className={`stock-main-tab ${cls ?? ''} ${activeTab === id ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setActiveTab(id)}
|
||||||
>
|
>
|
||||||
{manualLoading ? '요청 중...' : '주문 요청'}
|
<span className="stock-main-tab__icon">{icon}</span>
|
||||||
|
<span className="stock-main-tab__label">{label}</span>
|
||||||
|
{sub && <span className="stock-main-tab__sub">{sub}</span>}
|
||||||
|
{badge > 0 && <span className="stock-main-tab__badge">{badge}</span>}
|
||||||
</button>
|
</button>
|
||||||
{manualError ? (
|
))}
|
||||||
<p className="stock-error">{manualError}</p>
|
</div>
|
||||||
) : null}
|
|
||||||
{manualResult ? (
|
{/* Tab content */}
|
||||||
<div className="stock-result">
|
{activeTab === TAB_PORTFOLIO && (
|
||||||
<p className="stock-result__title">요청 결과</p>
|
<PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} />
|
||||||
<pre>
|
)}
|
||||||
{typeof manualResult === 'string'
|
{activeTab === TAB_AI && <AiTradeTab aib={aib} />}
|
||||||
? manualResult
|
{activeTab === TAB_REPORT && <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />}
|
||||||
: JSON.stringify(manualResult, null, 2)}
|
{activeTab === TAB_ADVISOR && <AdvisorTab pf={pf} advisor={advisor} />}
|
||||||
</pre>
|
|
||||||
</div>
|
{/* Sell history drawer (always mounted) */}
|
||||||
) : null}
|
<SellHistoryDrawer
|
||||||
</form>
|
sell={sell}
|
||||||
</section>
|
sellHistoryBrokers={sellHistoryBrokers}
|
||||||
{kisModal ? (
|
filteredSellHistory={filteredSellHistory}
|
||||||
<div className="stock-modal" role="dialog" aria-modal="true">
|
sellHistorySummary={sellHistorySummary}
|
||||||
<div
|
/>
|
||||||
className="stock-modal__backdrop"
|
|
||||||
onClick={() => setKisModal('')}
|
|
||||||
/>
|
|
||||||
<div className="stock-modal__card">
|
|
||||||
<div className="stock-modal__head">
|
|
||||||
<h4>주문 결과</h4>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button ghost small"
|
|
||||||
onClick={() => setKisModal('')}
|
|
||||||
>
|
|
||||||
닫기
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<pre>{kisModal}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
72
src/pages/stock/components/AdvisorTab.jsx
Normal file
72
src/pages/stock/components/AdvisorTab.jsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Loading from '../../../components/Loading';
|
||||||
|
import { formatNumber } from '../stockUtils';
|
||||||
|
|
||||||
|
const AdvisorTab = ({ pf, advisor }) => (
|
||||||
|
<section className="stock-panel stock-panel--wide advisor-panel">
|
||||||
|
<div className="advisor-panel__head">
|
||||||
|
<div className="advisor-panel__title-block">
|
||||||
|
<span className="advisor-panel__badge">AI 어드바이저</span>
|
||||||
|
<h3 className="advisor-panel__title">포트폴리오 분석 프롬프트</h3>
|
||||||
|
<p className="advisor-panel__sub">
|
||||||
|
보유 종목 정보를 담은 전문가용 프롬프트를 생성합니다.
|
||||||
|
복사 후 Gemini, ChatGPT 등에 붙여넣어 분석을 받아보세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="advisor-panel__actions">
|
||||||
|
<a
|
||||||
|
href="https://gemini.google.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="button ghost small"
|
||||||
|
>
|
||||||
|
Gemini 열기 ↗
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://chatgpt.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="button ghost small"
|
||||||
|
>
|
||||||
|
ChatGPT 열기 ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pf.portfolioLoading && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
|
||||||
|
<Loading type="spinner" message="포트폴리오 로딩 중..." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!pf.portfolioLoading && pf.portfolioHoldings.length === 0 && (
|
||||||
|
<div className="advisor-panel__empty">
|
||||||
|
<span className="advisor-panel__empty-icon">📋</span>
|
||||||
|
<p>포트폴리오 탭에서 보유 종목을 먼저 등록해주세요.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!pf.portfolioLoading && pf.portfolioHoldings.length > 0 && (
|
||||||
|
<div className="advisor-panel__body">
|
||||||
|
<div className="advisor-prompt__toolbar">
|
||||||
|
<span className="advisor-prompt__info">
|
||||||
|
종목 {pf.portfolioHoldings.length}개 · 총 자산 {pf.totalAssets != null ? formatNumber(pf.totalAssets) + '원' : '미집계'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className={`button primary small ${advisor.advisorCopied ? 'is-copied' : ''}`}
|
||||||
|
onClick={advisor.handleCopyPrompt}
|
||||||
|
>
|
||||||
|
{advisor.advisorCopied ? '✅ 복사됨' : '📋 프롬프트 복사'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className="advisor-prompt__preview">{advisor.buildAdvisorPrompt()}</pre>
|
||||||
|
<p className="advisor-panel__disclaimer">
|
||||||
|
※ 이 프롬프트를 AI에 붙여넣으면 전문가 관점의 매매 조언을 받을 수 있습니다.
|
||||||
|
투자 결정은 최종적으로 본인의 판단과 책임 하에 이루어져야 합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AdvisorTab;
|
||||||
220
src/pages/stock/components/AiTradeTab.jsx
Normal file
220
src/pages/stock/components/AiTradeTab.jsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
formatNumber, formatPercent,
|
||||||
|
getQty, getBuyPrice, getCurrentPrice, getProfitRate, getProfitLoss,
|
||||||
|
toNumeric, profitColorClass,
|
||||||
|
} from '../stockUtils';
|
||||||
|
|
||||||
|
const AiTradeTab = ({ aib }) => (
|
||||||
|
<>
|
||||||
|
{aib.balanceError ? <p className="stock-error">{aib.balanceError}</p> : null}
|
||||||
|
|
||||||
|
{/* AI Balance section */}
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">AI 모의투자</p>
|
||||||
|
<h3>보유 현황</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
AI가 운용 중인 모의투자 계좌의 잔고와 보유 종목을 확인합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="stock-panel__actions">
|
||||||
|
{aib.balanceLoading ? (
|
||||||
|
<span className="stock-chip">조회 중</span>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={aib.loadBalance}
|
||||||
|
disabled={aib.balanceLoading}
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-balance">
|
||||||
|
<div className="stock-balance__summary">
|
||||||
|
{[
|
||||||
|
{ label: '총 평가', value: aib.totalEval },
|
||||||
|
{ label: '예수금', value: aib.deposit },
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.label} className="stock-balance__card">
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<strong>{formatNumber(item.value)}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{aib.holdings.length ? (
|
||||||
|
<div className="stock-holdings">
|
||||||
|
{aib.holdings.map((item, idx) => {
|
||||||
|
const profitLoss = getProfitLoss(item);
|
||||||
|
const profitLossNumeric = toNumeric(profitLoss);
|
||||||
|
const profitClass = profitColorClass(profitLossNumeric);
|
||||||
|
const profitRate = getProfitRate(item);
|
||||||
|
const profitRateNumeric = toNumeric(profitRate);
|
||||||
|
const profitRateClass = profitColorClass(profitRateNumeric);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.code ?? `${item.name}-${idx}`}
|
||||||
|
className="stock-holdings__item"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="stock-holdings__name">
|
||||||
|
{item.name ?? item.code ?? 'N/A'}
|
||||||
|
</p>
|
||||||
|
<span className="stock-holdings__code">
|
||||||
|
{item.code ?? ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>수량</span>
|
||||||
|
<strong>{formatNumber(getQty(item))}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>매입가</span>
|
||||||
|
<strong>{formatNumber(getBuyPrice(item))}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>현재가</span>
|
||||||
|
<strong>{formatNumber(getCurrentPrice(item))}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>평가금액</span>
|
||||||
|
<strong>
|
||||||
|
{getCurrentPrice(item) != null && getQty(item) != null
|
||||||
|
? formatNumber(toNumeric(getCurrentPrice(item)) * toNumeric(getQty(item)))
|
||||||
|
: '-'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>수익률</span>
|
||||||
|
<strong className={`stock-profit ${profitRateClass}`}>
|
||||||
|
{formatPercent(profitRate)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>평가손익</span>
|
||||||
|
<strong className={`stock-profit ${profitClass}`}>
|
||||||
|
{formatNumber(profitLoss)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="stock-empty">보유 종목이 없습니다.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Manual order section */}
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">수동 주문</p>
|
||||||
|
<h3>직접 매수/매도</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
종목명 또는 종목코드를 입력하고 매수/매도 주문을 요청합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form className="stock-order" onSubmit={aib.submitManualOrder}>
|
||||||
|
<label>
|
||||||
|
종목명/코드
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={aib.manualForm.code}
|
||||||
|
onChange={(e) =>
|
||||||
|
aib.setManualForm((prev) => ({ ...prev, code: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="005930 또는 삼성전자"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
매수/매도
|
||||||
|
<select
|
||||||
|
value={aib.manualForm.type}
|
||||||
|
onChange={(e) =>
|
||||||
|
aib.setManualForm((prev) => ({ ...prev, type: e.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="buy">매수</option>
|
||||||
|
<option value="sell">매도</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
수량
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={aib.manualForm.qty}
|
||||||
|
onChange={(e) =>
|
||||||
|
aib.setManualForm((prev) => ({ ...prev, qty: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
금액(원)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={aib.manualForm.price}
|
||||||
|
onChange={(e) =>
|
||||||
|
aib.setManualForm((prev) => ({ ...prev, price: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={aib.manualLoading}
|
||||||
|
>
|
||||||
|
{aib.manualLoading ? '요청 중...' : '주문 요청'}
|
||||||
|
</button>
|
||||||
|
{aib.manualError ? (
|
||||||
|
<p className="stock-error">{aib.manualError}</p>
|
||||||
|
) : null}
|
||||||
|
{aib.manualResult ? (
|
||||||
|
<div className="stock-result">
|
||||||
|
<p className="stock-result__title">요청 결과</p>
|
||||||
|
<pre>
|
||||||
|
{typeof aib.manualResult === 'string'
|
||||||
|
? aib.manualResult
|
||||||
|
: JSON.stringify(aib.manualResult, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* KIS modal */}
|
||||||
|
{aib.kisModal ? (
|
||||||
|
<div className="stock-modal" role="dialog" aria-modal="true">
|
||||||
|
<div
|
||||||
|
className="stock-modal__backdrop"
|
||||||
|
onClick={() => aib.setKisModal('')}
|
||||||
|
/>
|
||||||
|
<div className="stock-modal__card">
|
||||||
|
<div className="stock-modal__head">
|
||||||
|
<h4>주문 결과</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => aib.setKisModal('')}
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre>{aib.kisModal}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AiTradeTab;
|
||||||
609
src/pages/stock/components/PortfolioTab.jsx
Normal file
609
src/pages/stock/components/PortfolioTab.jsx
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Loading from '../../../components/Loading';
|
||||||
|
import {
|
||||||
|
ResponsiveContainer, AreaChart, Area, XAxis, YAxis,
|
||||||
|
Tooltip as ChartTooltip,
|
||||||
|
} from 'recharts';
|
||||||
|
import { formatNumber, formatPercent, toNumeric, profitColorClass } from '../stockUtils';
|
||||||
|
|
||||||
|
const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
|
||||||
|
<>
|
||||||
|
{pf.portfolioError ? (
|
||||||
|
<p className="stock-error">{pf.portfolioError}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 포트폴리오 관리 헤더 + 추가 폼 */}
|
||||||
|
<section className="stock-panel stock-panel--wide pf-section">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">포트폴리오</p>
|
||||||
|
<h3>수동 입력 종목 관리</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
증권사별 보유 종목을 수동 등록하면 현재가를 자동 조회합니다. (3분 캐시)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="stock-panel__actions">
|
||||||
|
{pf.portfolioLoading ? (
|
||||||
|
<Loading type="spinner" message="" />
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={pf.loadPortfolio}
|
||||||
|
disabled={pf.portfolioLoading}
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button primary small"
|
||||||
|
onClick={() => pf.setAddFormOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{pf.addFormOpen ? '취소' : '+ 종목 추가'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add form */}
|
||||||
|
{pf.addFormOpen && (
|
||||||
|
<form className="pf-add-form" onSubmit={pf.handleAddSubmit}>
|
||||||
|
<label>
|
||||||
|
증권사
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pf.addForm.broker}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setAddForm((p) => ({ ...p, broker: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="KB증권"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
종목코드
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pf.addForm.ticker}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setAddForm((p) => ({ ...p, ticker: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="005930"
|
||||||
|
required
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
종목명
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pf.addForm.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setAddForm((p) => ({ ...p, name: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="삼성전자"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
수량
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={pf.addForm.quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setAddForm((p) => ({ ...p, quantity: e.target.value }))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
평균 매입가 (원)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={pf.addForm.avg_price}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setAddForm((p) => ({ ...p, avg_price: e.target.value }))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={pf.addLoading}
|
||||||
|
>
|
||||||
|
{pf.addLoading ? '등록 중...' : '종목 등록'}
|
||||||
|
</button>
|
||||||
|
{pf.addError && <p className="stock-error">{pf.addError}</p>}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Portfolio total summary */}
|
||||||
|
{pf.portfolioHoldings.length > 0 && (
|
||||||
|
<div className="pf-total-summary">
|
||||||
|
{[
|
||||||
|
{ label: '총 매입', value: pf.portfolioSummary.total_buy },
|
||||||
|
{ label: '총 평가', value: pf.portfolioSummary.total_eval },
|
||||||
|
{ label: '총 손익', value: pf.portfolioSummary.total_profit, isProfit: true },
|
||||||
|
{ label: '수익률', value: pf.portfolioSummary.total_profit_rate, isRate: true },
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.label} className="pf-total-summary__card">
|
||||||
|
<span>{s.label}</span>
|
||||||
|
<strong
|
||||||
|
className={
|
||||||
|
s.isProfit || s.isRate
|
||||||
|
? `stock-profit ${profitColorClass(toNumeric(s.value))}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{s.isRate ? formatPercent(s.value) : formatNumber(s.value)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{pf.totalCash != null && (
|
||||||
|
<div className="pf-total-summary__card is-cash">
|
||||||
|
<span>예수금 합계</span>
|
||||||
|
<strong>{formatNumber(pf.totalCash)}원</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pf.totalAssets != null && (
|
||||||
|
<div className="pf-total-summary__card is-assets">
|
||||||
|
<span>총 자산</span>
|
||||||
|
<strong>{formatNumber(pf.totalAssets)}원</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 자산 추이 차트 */}
|
||||||
|
<div className="pf-asset-history">
|
||||||
|
<div className="pf-asset-history__head">
|
||||||
|
<p className="pf-asset-history__title">총 자산 추이</p>
|
||||||
|
<div className="pf-asset-history__controls">
|
||||||
|
{[
|
||||||
|
{ label: '7일', value: 7 },
|
||||||
|
{ label: '30일', value: 30 },
|
||||||
|
{ label: '90일', value: 90 },
|
||||||
|
{ label: '전체', value: 0 },
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className={`pf-asset-period-btn ${asset.assetHistoryDays === value ? 'is-active' : ''}`}
|
||||||
|
onClick={() => asset.setAssetHistoryDays(value)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={handleSaveSnapshot}
|
||||||
|
disabled={asset.snapshotSaving || pf.totalAssets == null}
|
||||||
|
title="현재 총 자산을 오늘 날짜로 저장"
|
||||||
|
>
|
||||||
|
{asset.snapshotSaving ? '저장 중...' : '📸 스냅샷'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{asset.assetHistoryLoading ? (
|
||||||
|
<div className="pf-asset-history__empty">
|
||||||
|
<Loading type="spinner" message="" />
|
||||||
|
</div>
|
||||||
|
) : Array.isArray(asset.assetHistory) && asset.assetHistory.length >= 1 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<AreaChart
|
||||||
|
data={asset.assetHistory}
|
||||||
|
margin={{ top: 8, right: 12, left: 0, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="assetGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#38bdf8" stopOpacity={0.25} />
|
||||||
|
<stop offset="95%" stopColor="#38bdf8" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fill: 'var(--text-muted)', fontSize: 10 }}
|
||||||
|
tickFormatter={(v) => v?.slice(5)}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis hide domain={['auto', 'auto']} />
|
||||||
|
<ChartTooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: 'var(--text-dim)', marginBottom: 4 }}
|
||||||
|
formatter={(v) => [`${new Intl.NumberFormat('ko-KR').format(v)}원`, '총 자산']}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="total_assets"
|
||||||
|
stroke="#38bdf8"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#assetGrad)"
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4, fill: '#38bdf8' }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="pf-asset-history__empty">
|
||||||
|
저장된 자산 추이 데이터가 없습니다. 📸 스냅샷 버튼으로 오늘 자산을 기록하세요.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 예수금 패널 */}
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">예수금 관리</p>
|
||||||
|
<h3>증권사별 예수금</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
증권사별 예수금을 입력하면 총 자산에 자동 반영됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pf.cashList.length > 0 && (
|
||||||
|
<div className="pf-cash-table">
|
||||||
|
{pf.cashList.map((item) => {
|
||||||
|
const isEditing = pf.cashEditingBroker === item.broker;
|
||||||
|
return (
|
||||||
|
<div key={item.id ?? item.broker} className="pf-cash-row">
|
||||||
|
<span className="pf-cash-broker">{item.broker}</span>
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
className="pf-cash-edit-input"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={pf.cashEditingValue}
|
||||||
|
onChange={(e) => pf.setCashEditingValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') pf.handleCashInlineSave(item.broker);
|
||||||
|
if (e.key === 'Escape') pf.handleCashInlineCancel();
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<strong className="pf-cash-amount">
|
||||||
|
{formatNumber(item.cash)}원
|
||||||
|
</strong>
|
||||||
|
)}
|
||||||
|
<span className="pf-cash-date">
|
||||||
|
{item.updated_at
|
||||||
|
? new Date(item.updated_at).toLocaleDateString('ko-KR')
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="button primary small"
|
||||||
|
onClick={() => pf.handleCashInlineSave(item.broker)}
|
||||||
|
disabled={pf.cashEditSaving}
|
||||||
|
>
|
||||||
|
{pf.cashEditSaving ? '저장 중' : '저장'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={pf.handleCashInlineCancel}
|
||||||
|
disabled={pf.cashEditSaving}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => pf.handleCashInlineEdit(item)}
|
||||||
|
title="수정"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button ghost small pf-btn-danger"
|
||||||
|
onClick={() => pf.handleCashDelete(item.broker)}
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pf.cashList.length === 0 && (
|
||||||
|
<p className="stock-empty" style={{ fontSize: 13 }}>
|
||||||
|
등록된 예수금이 없습니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className="pf-cash-form" onSubmit={pf.handleCashSave}>
|
||||||
|
<label>
|
||||||
|
증권사명
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pf.cashForm.broker}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setCashForm((p) => ({ ...p, broker: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="KB증권"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
예수금 (원)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={pf.cashForm.cash}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setCashForm((p) => ({ ...p, cash: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="1500000"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={pf.cashSaving}
|
||||||
|
>
|
||||||
|
{pf.cashSaving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
{pf.cashError && <p className="stock-error">{pf.cashError}</p>}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Broker cards stacked */}
|
||||||
|
{pf.brokerGroups.map(([broker, items]) => {
|
||||||
|
const bSummary = pf.getBrokerSummary(items);
|
||||||
|
const color = pf.brokerColors[broker];
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={broker}
|
||||||
|
className="stock-panel stock-panel--wide pf-broker-section"
|
||||||
|
style={{ borderColor: color?.border, background: color?.bg }}
|
||||||
|
>
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow" style={{ color: color?.border }}>
|
||||||
|
{broker}
|
||||||
|
</p>
|
||||||
|
<h3>{broker} 보유 현황</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
{items.length}종목 · 평가{' '}
|
||||||
|
{formatNumber(bSummary.totalEval)} · 손익{' '}
|
||||||
|
<span className={`stock-profit ${profitColorClass(bSummary.totalProfit)}`}>
|
||||||
|
{formatNumber(bSummary.totalProfit)} (
|
||||||
|
{formatPercent(bSummary.totalProfitRate)})
|
||||||
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const bc = pf.cashList.find((c) => c.broker === broker);
|
||||||
|
return bc ? (
|
||||||
|
<span className="pf-cash-badge">
|
||||||
|
예수금 {formatNumber(bc.cash)}원
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings">
|
||||||
|
{items.map((item) => {
|
||||||
|
const profitAmt = item.profit_amount;
|
||||||
|
const profitRate = item.profit_rate;
|
||||||
|
const profitAmtN = toNumeric(profitAmt);
|
||||||
|
const profitRateN = toNumeric(profitRate);
|
||||||
|
const isEditing = pf.editingId === item.id;
|
||||||
|
const isDeleting = pf.deleteConfirmId === item.id;
|
||||||
|
const isSelling = pf.sellConfirmId === item.id;
|
||||||
|
const sellPrice = item.current_price ?? item.avg_price;
|
||||||
|
const saleAmount = sellPrice != null ? sellPrice * (item.quantity ?? 0) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="stock-holdings__item pf-item">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="pf-edit-row">
|
||||||
|
<div className="pf-edit-fields">
|
||||||
|
<label>
|
||||||
|
수량
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={pf.editForm.quantity ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setEditForm((p) => ({
|
||||||
|
...p,
|
||||||
|
quantity: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
평균매입가
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={pf.editForm.avg_price ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setEditForm((p) => ({
|
||||||
|
...p,
|
||||||
|
avg_price: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="pf-edit-actions">
|
||||||
|
<button
|
||||||
|
className="button primary small"
|
||||||
|
onClick={() => pf.handleEditSave(item.id)}
|
||||||
|
disabled={pf.editLoading}
|
||||||
|
>
|
||||||
|
{pf.editLoading ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => pf.setEditingId(null)}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<p className="stock-holdings__name">
|
||||||
|
{item.name ?? item.ticker ?? 'N/A'}
|
||||||
|
</p>
|
||||||
|
<span className="stock-holdings__code">
|
||||||
|
{item.ticker ?? ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>수량</span>
|
||||||
|
<strong>{formatNumber(item.quantity)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>매입가</span>
|
||||||
|
<strong>{formatNumber(item.avg_price)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>현재가</span>
|
||||||
|
<strong className={item.current_price == null ? 'pf-null-price' : ''}>
|
||||||
|
{item.current_price != null
|
||||||
|
? formatNumber(item.current_price)
|
||||||
|
: '조회 실패'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>평가금액</span>
|
||||||
|
<strong>
|
||||||
|
{item.current_price != null && item.quantity != null
|
||||||
|
? formatNumber(item.current_price * item.quantity)
|
||||||
|
: '-'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>수익률</span>
|
||||||
|
<strong className={`stock-profit ${profitColorClass(profitRateN)}`}>
|
||||||
|
{profitRate != null ? formatPercent(profitRate) : '-'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>평가손익</span>
|
||||||
|
<strong className={`stock-profit ${profitColorClass(profitAmtN)}`}>
|
||||||
|
{profitAmt != null ? formatNumber(profitAmt) : '-'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="pf-item-actions">
|
||||||
|
{!isSelling && !isDeleting && (
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => pf.handleEditStart(item)}
|
||||||
|
title="수정"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isSelling ? (
|
||||||
|
<div className="pf-sell-confirm">
|
||||||
|
<span className="pf-sell-confirm__msg">
|
||||||
|
{item.current_price == null && (
|
||||||
|
<small className="pf-sell-confirm__warn">현재가 미조회 — 매입가 기준</small>
|
||||||
|
)}
|
||||||
|
{saleAmount != null
|
||||||
|
? `${formatNumber(saleAmount)}원 매도 후 예수금 반영`
|
||||||
|
: '매도 처리'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="button small pf-btn-sell"
|
||||||
|
onClick={() => handleSell(item)}
|
||||||
|
disabled={pf.sellLoading}
|
||||||
|
>
|
||||||
|
{pf.sellLoading ? '처리 중...' : '매도 확인'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => pf.setSellConfirmId(null)}
|
||||||
|
disabled={pf.sellLoading}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : isDeleting ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="button ghost small pf-btn-danger"
|
||||||
|
onClick={() => pf.handleDelete(item.id)}
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => pf.setDeleteConfirmId(null)}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="button ghost small pf-btn-sell"
|
||||||
|
onClick={() => {
|
||||||
|
pf.setSellConfirmId(item.id);
|
||||||
|
pf.setDeleteConfirmId(null);
|
||||||
|
}}
|
||||||
|
title="매도"
|
||||||
|
>
|
||||||
|
매도
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => {
|
||||||
|
pf.setDeleteConfirmId(item.id);
|
||||||
|
pf.setSellConfirmId(null);
|
||||||
|
}}
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{pf.portfolioLoaded && pf.portfolioHoldings.length === 0 && !pf.portfolioError && (
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<p className="stock-empty" style={{ textAlign: 'center', padding: 24 }}>
|
||||||
|
등록된 종목이 없습니다. 상단의 <strong>+ 종목 추가</strong> 버튼으로 보유 종목을 등록하세요.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default PortfolioTab;
|
||||||
384
src/pages/stock/components/ReportTab.jsx
Normal file
384
src/pages/stock/components/ReportTab.jsx
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Loading from '../../../components/Loading';
|
||||||
|
import {
|
||||||
|
PieChart, Pie, Cell,
|
||||||
|
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||||
|
Tooltip as ChartTooltip, Legend, ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
formatNumber, formatPercent, toNumeric,
|
||||||
|
CHART_COLORS, profitColorClass, getVixLabel, getFgLabel,
|
||||||
|
} from '../stockUtils';
|
||||||
|
|
||||||
|
const ReportTab = ({ pf, report, ai, marketCtx }) => (
|
||||||
|
<>
|
||||||
|
{pf.portfolioLoading && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
|
||||||
|
<Loading type="spinner" message="포트폴리오 로딩 중..." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pf.portfolioError && <p className="stock-error">{pf.portfolioError}</p>}
|
||||||
|
|
||||||
|
{/* 자산 배분 + 수익률 차트 */}
|
||||||
|
{pf.portfolioHoldings.length > 0 && (
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">포트폴리오 분석</p>
|
||||||
|
<h3>자산 배분 현황</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="report-charts-row">
|
||||||
|
<div className="report-chart-box">
|
||||||
|
<p className="report-chart-title">증권사별 자산 배분</p>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={report.brokerPieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={52}
|
||||||
|
outerRadius={84}
|
||||||
|
dataKey="value"
|
||||||
|
paddingAngle={2}
|
||||||
|
>
|
||||||
|
{report.brokerPieData.map((_, i) => (
|
||||||
|
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<ChartTooltip
|
||||||
|
formatter={(v) => [formatNumber(v) + '원', '평가금액']}
|
||||||
|
contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
iconType="circle"
|
||||||
|
iconSize={8}
|
||||||
|
formatter={(v) => <span style={{ color: '#9ca3af', fontSize: 12 }}>{v}</span>}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="report-chart-box">
|
||||||
|
<p className="report-chart-title">종목별 수익률 (%)</p>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<BarChart data={report.profitBarData} margin={{ top: 0, right: 8, left: -16, bottom: 48 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fill: '#9ca3af', fontSize: 10 }}
|
||||||
|
angle={-40}
|
||||||
|
textAnchor="end"
|
||||||
|
interval={0}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: '#9ca3af', fontSize: 10 }}
|
||||||
|
tickFormatter={(v) => `${v}%`}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
formatter={(v, _n, props) => [`${v.toFixed(2)}%`, props.payload.fullName]}
|
||||||
|
contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="rate" radius={[4, 4, 0, 0]}>
|
||||||
|
{report.profitBarData.map((entry, i) => (
|
||||||
|
<Cell key={i} fill={entry.rate >= 0 ? '#34d399' : '#f87171'} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 리스크 분산 분석 */}
|
||||||
|
{pf.portfolioHoldings.length > 0 && pf.portfolioSummary.total_eval != null && (
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">리스크 관리</p>
|
||||||
|
<h3>분산 분석</h3>
|
||||||
|
<p className="stock-panel__sub">증권사·종목 집중도를 확인합니다. 단일 비중 40% 초과 시 주의.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="risk-grid">
|
||||||
|
<div className="risk-card">
|
||||||
|
<p className="risk-card__title">증권사별 집중도</p>
|
||||||
|
{report.brokerConcentration.length === 0 ? (
|
||||||
|
<p className="stock-empty" style={{ fontSize: 13 }}>평가금액 데이터 없음</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{report.brokerConcentration.some((b) => b.ratio > 40) && (
|
||||||
|
<div className="risk-warning">
|
||||||
|
⚠️ 단일 증권사 집중도가 40%를 초과합니다
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{report.brokerConcentration.map(({ broker, eval: evalAmt, ratio }) => {
|
||||||
|
const level = ratio >= 60 ? 'is-danger' : ratio >= 40 ? 'is-warn' : 'is-ok';
|
||||||
|
return (
|
||||||
|
<div key={broker} className="risk-item">
|
||||||
|
<div className="risk-item__head">
|
||||||
|
<span className="risk-item__name">{broker}</span>
|
||||||
|
<span className={`risk-item__ratio ${level}`}>{ratio.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="risk-bar">
|
||||||
|
<div className={`risk-bar__fill ${level}`} style={{ width: `${Math.min(ratio, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{formatNumber(evalAmt)}원</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="risk-card">
|
||||||
|
<p className="risk-card__title">상위 5 종목 집중도</p>
|
||||||
|
{report.stockConcentration.length === 0 ? (
|
||||||
|
<p className="stock-empty" style={{ fontSize: 13 }}>현재가 데이터 없음</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{report.stockConcentration.some((s) => s.ratio > 40) && (
|
||||||
|
<div className="risk-warning">
|
||||||
|
⚠️ 단일 종목 집중도가 40%를 초과합니다
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{report.stockConcentration.map(({ name, ticker, eval: evalAmt, ratio }) => {
|
||||||
|
const level = ratio >= 60 ? 'is-danger' : ratio >= 40 ? 'is-warn' : 'is-ok';
|
||||||
|
return (
|
||||||
|
<div key={ticker || name} className="risk-item">
|
||||||
|
<div className="risk-item__head">
|
||||||
|
<span className="risk-item__name">{name}</span>
|
||||||
|
<span className={`risk-item__ratio ${level}`}>{ratio.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="risk-bar">
|
||||||
|
<div className={`risk-bar__fill ${level}`} style={{ width: `${Math.min(ratio, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--muted)' }}>
|
||||||
|
{ticker && <span style={{ marginRight: 6 }}>{ticker}</span>}
|
||||||
|
{formatNumber(evalAmt)}원
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 수익률 랭킹 테이블 */}
|
||||||
|
{pf.portfolioHoldings.length > 0 && (
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">수익률 랭킹</p>
|
||||||
|
<h3>종목별 상세 현황</h3>
|
||||||
|
<p className="stock-panel__sub">헤더 클릭으로 정렬 · 비중은 총 평가금액 대비</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="report-table-wrapper">
|
||||||
|
<table className="report-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{[
|
||||||
|
{ key: 'name', label: '종목명' },
|
||||||
|
{ key: 'broker', label: '증권사' },
|
||||||
|
{ key: 'profit_rate', label: '수익률' },
|
||||||
|
{ key: 'profit_amount', label: '평가손익' },
|
||||||
|
{ key: 'eval_amount', label: '평가금액' },
|
||||||
|
].map(({ key, label }) => (
|
||||||
|
<th key={key} onClick={() => report.handleReportSort(key)}>
|
||||||
|
{label}{' '}
|
||||||
|
<span className="report-sort-icon">
|
||||||
|
{report.reportSortField === key
|
||||||
|
? report.reportSortDir === 'asc' ? '↑' : '↓'
|
||||||
|
: '↕'}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th style={{ cursor: 'default' }}>비중</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{report.sortedHoldings.map((item) => {
|
||||||
|
const rateN = toNumeric(item.profit_rate);
|
||||||
|
const pnlN = toNumeric(item.profit_amount);
|
||||||
|
const evalAmt = item.eval_amount != null
|
||||||
|
? item.eval_amount
|
||||||
|
: item.current_price != null
|
||||||
|
? item.current_price * item.quantity
|
||||||
|
: null;
|
||||||
|
const totalEvalVal = toNumeric(pf.portfolioSummary.total_eval);
|
||||||
|
const weight = evalAmt != null && totalEvalVal
|
||||||
|
? Math.round((evalAmt / totalEvalVal) * 1000) / 10
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>
|
||||||
|
<p className="report-table-name">{item.name ?? item.ticker ?? 'N/A'}</p>
|
||||||
|
<span className="report-table-code">{item.ticker ?? ''}</span>
|
||||||
|
</td>
|
||||||
|
<td className="report-td-muted">{item.broker ?? '-'}</td>
|
||||||
|
<td className={`stock-profit ${profitColorClass(rateN)}`}>
|
||||||
|
<div className="report-rate-cell">
|
||||||
|
<span>{item.profit_rate != null ? formatPercent(item.profit_rate) : '-'}</span>
|
||||||
|
{rateN != null && (
|
||||||
|
<div className="report-rate-bar">
|
||||||
|
<div
|
||||||
|
className={`report-rate-bar__fill ${rateN >= 0 ? 'is-up' : 'is-down'}`}
|
||||||
|
style={{ width: `${report.maxAbsRate > 0 ? Math.abs(rateN) / report.maxAbsRate * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className={`stock-profit ${profitColorClass(pnlN)}`}>
|
||||||
|
{item.profit_amount != null ? formatNumber(item.profit_amount) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="report-td-muted">
|
||||||
|
{evalAmt != null ? formatNumber(evalAmt) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="report-td-muted">
|
||||||
|
{weight != null ? `${weight.toFixed(1)}%` : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pf.portfolioLoaded && pf.portfolioHoldings.length === 0 && !pf.portfolioError && (
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<p className="stock-empty" style={{ textAlign: 'center', padding: 24 }}>
|
||||||
|
등록된 종목이 없습니다. <strong>쟁승토리 계좌</strong> 탭에서 종목을 먼저 등록하세요.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI 투자 코치 */}
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">AI 투자 코치</p>
|
||||||
|
<h3>오늘의 투자 평가</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
포트폴리오를 AI가 분석하여 성취도 등급과 내일을 위한 투자 조언을 드립니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 시장 컨텍스트 미니 패널 */}
|
||||||
|
{marketCtx && (
|
||||||
|
<div className="ai-market-ctx">
|
||||||
|
<span className="ai-market-ctx__label">시장 환경</span>
|
||||||
|
<div className="ai-market-ctx__chips">
|
||||||
|
{marketCtx.vix != null && (
|
||||||
|
<span className="ai-market-chip">
|
||||||
|
VIX <strong>{marketCtx.vix}</strong>
|
||||||
|
<em>{getVixLabel(marketCtx.vix)}</em>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{marketCtx.fg != null && (
|
||||||
|
<span className="ai-market-chip">
|
||||||
|
F&G <strong>{marketCtx.fg}</strong>
|
||||||
|
<em>{getFgLabel(marketCtx.fg)}</em>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{marketCtx.treasury != null && (
|
||||||
|
<span className="ai-market-chip">
|
||||||
|
10년물 <strong>{marketCtx.treasury}%</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{marketCtx.wti != null && (
|
||||||
|
<span className="ai-market-chip">
|
||||||
|
WTI <strong>${marketCtx.wti}</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 모델 선택 */}
|
||||||
|
<div className="ai-coach-settings">
|
||||||
|
<label>
|
||||||
|
AI 모델
|
||||||
|
<select
|
||||||
|
value={ai.aiModel}
|
||||||
|
onChange={(e) => {
|
||||||
|
ai.setAiModel(e.target.value);
|
||||||
|
localStorage.setItem('ai_coach_model', e.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="claude-haiku-4-5-20251001">Claude Haiku (빠름·저렴)</option>
|
||||||
|
<option value="claude-sonnet-4-6">Claude Sonnet (고성능)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ai-coach-actions">
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
type="button"
|
||||||
|
onClick={ai.handleAiCoach}
|
||||||
|
disabled={ai.aiLoading || pf.portfolioHoldings.length === 0}
|
||||||
|
>
|
||||||
|
{ai.aiLoading ? 'AI 분석 중...' : '오늘 투자 평가 받기'}
|
||||||
|
</button>
|
||||||
|
{pf.portfolioHoldings.length === 0 && (
|
||||||
|
<span className="ai-coach-note">종목 등록 후 이용 가능합니다.</span>
|
||||||
|
)}
|
||||||
|
{ai.aiResult?.generated_at && (
|
||||||
|
<span className="ai-coach-note">
|
||||||
|
{ai.aiResult.cached ? '오늘 캐시 결과 · ' : ''}
|
||||||
|
{new Date(ai.aiResult.generated_at).toLocaleTimeString('ko-KR')} 생성
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ai.aiError && <p className="stock-error" style={{ marginTop: 8 }}>{ai.aiError}</p>}
|
||||||
|
|
||||||
|
{ai.aiResult && !ai.aiLoading && (
|
||||||
|
<div className="ai-coach-result">
|
||||||
|
<div className="ai-coach-header">
|
||||||
|
<div className={`ai-grade-badge grade-${(ai.aiResult.grade ?? 'c').toLowerCase()}`}>
|
||||||
|
{ai.aiResult.grade ?? '?'}
|
||||||
|
</div>
|
||||||
|
<div className="ai-score-wrap">
|
||||||
|
<span className="ai-score-num">{ai.aiResult.score ?? 0}</span>
|
||||||
|
<span className="ai-score-unit">/ 100</span>
|
||||||
|
</div>
|
||||||
|
<p className="ai-summary-text">{ai.aiResult.summary}</p>
|
||||||
|
</div>
|
||||||
|
<p className="ai-evaluation-text">{ai.aiResult.evaluation}</p>
|
||||||
|
{ai.aiResult.advice?.length > 0 && (
|
||||||
|
<div className="ai-advice-list">
|
||||||
|
{ai.aiResult.advice.map((a, i) => (
|
||||||
|
<div key={i} className="ai-advice-card">
|
||||||
|
<p className="ai-advice-title">{a.title}</p>
|
||||||
|
<p className="ai-advice-body">{a.body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
type="button"
|
||||||
|
style={{ marginTop: 16, fontSize: 11 }}
|
||||||
|
onClick={() => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
localStorage.removeItem(`ai_coach_${today}`);
|
||||||
|
ai.setAiResult(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
다시 평가받기 (캐시 삭제)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ReportTab;
|
||||||
354
src/pages/stock/components/SellHistoryDrawer.jsx
Normal file
354
src/pages/stock/components/SellHistoryDrawer.jsx
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Loading from '../../../components/Loading';
|
||||||
|
import { formatNumber, formatPercent, profitColorClass } from '../stockUtils';
|
||||||
|
|
||||||
|
const SellHistoryDrawer = ({
|
||||||
|
sell, sellHistoryBrokers, filteredSellHistory, sellHistorySummary,
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
{/* Floating 토글 버튼 */}
|
||||||
|
{!sell.sellDrawerOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sh-floating-toggle"
|
||||||
|
onClick={() => {
|
||||||
|
sell.setSellDrawerOpen(true);
|
||||||
|
sell.loadSellHistory();
|
||||||
|
}}
|
||||||
|
title="실현손익 내역"
|
||||||
|
>
|
||||||
|
<span className="sh-floating-toggle__icon">💹</span>
|
||||||
|
<span className="sh-floating-toggle__label">실현손익</span>
|
||||||
|
{sell.sellHistory.length > 0 && (
|
||||||
|
<span className="sh-floating-toggle__badge">{sell.sellHistory.length}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Backdrop */}
|
||||||
|
{sell.sellDrawerOpen && (
|
||||||
|
<div
|
||||||
|
className="sh-backdrop"
|
||||||
|
onClick={() => { sell.setSellDrawerOpen(false); sell.handleSellFormClose(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Drawer */}
|
||||||
|
<aside className={`sh-drawer ${sell.sellDrawerOpen ? 'is-open' : ''}`}>
|
||||||
|
<div className="sh-drawer__header">
|
||||||
|
<div>
|
||||||
|
<p className="sh-drawer__eyebrow">실현손익</p>
|
||||||
|
<h3 className="sh-drawer__title">매도 거래 내역</h3>
|
||||||
|
</div>
|
||||||
|
<div className="sh-drawer__header-actions">
|
||||||
|
{sell.sellHistoryLoading && <Loading type="spinner" message="" />}
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={sell.loadSellHistory}
|
||||||
|
disabled={sell.sellHistoryLoading}
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button primary small"
|
||||||
|
onClick={sell.sellFormOpen && sell.sellEditId == null ? sell.handleSellFormClose : sell.handleSellFormOpen}
|
||||||
|
>
|
||||||
|
{sell.sellFormOpen && sell.sellEditId == null ? '취소' : '+ 추가'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="sh-drawer__close"
|
||||||
|
type="button"
|
||||||
|
onClick={() => { sell.setSellDrawerOpen(false); sell.handleSellFormClose(); }}
|
||||||
|
aria-label="닫기"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수동 추가 / 수정 폼 */}
|
||||||
|
{sell.sellFormOpen && (
|
||||||
|
<form className="sh-form" onSubmit={sell.handleSellFormSubmit}>
|
||||||
|
<div className="sh-form__title">
|
||||||
|
{sell.sellEditId != null ? '거래 내역 수정' : '매도 내역 수동 추가'}
|
||||||
|
</div>
|
||||||
|
<div className="sh-form__grid">
|
||||||
|
<label>
|
||||||
|
증권사
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={sell.sellForm.broker}
|
||||||
|
onChange={(e) => sell.setSellForm((p) => ({ ...p, broker: e.target.value }))}
|
||||||
|
placeholder="KB증권"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
종목코드
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={sell.sellForm.ticker}
|
||||||
|
onChange={(e) => sell.setSellForm((p) => ({ ...p, ticker: e.target.value }))}
|
||||||
|
placeholder="005930"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
종목명
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={sell.sellForm.name}
|
||||||
|
onChange={(e) => sell.setSellForm((p) => ({ ...p, name: e.target.value }))}
|
||||||
|
placeholder="삼성전자"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
수량
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={sell.sellForm.quantity}
|
||||||
|
onChange={(e) => sell.setSellForm((p) => ({ ...p, quantity: e.target.value }))}
|
||||||
|
placeholder="10"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
평균 매입가 (원)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={sell.sellForm.avg_price}
|
||||||
|
onChange={(e) => sell.setSellForm((p) => ({ ...p, avg_price: e.target.value }))}
|
||||||
|
placeholder="58000"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
매도가 (원)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={sell.sellForm.sell_price}
|
||||||
|
onChange={(e) => sell.setSellForm((p) => ({ ...p, sell_price: e.target.value }))}
|
||||||
|
placeholder="62000"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
수수료 & 세금 (원)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={sell.sellForm.commission}
|
||||||
|
onChange={(e) => sell.setSellForm((p) => ({ ...p, commission: e.target.value }))}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="sh-form__datetime">
|
||||||
|
매도 일시
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={sell.sellForm.sold_at}
|
||||||
|
onChange={(e) => sell.setSellForm((p) => ({ ...p, sold_at: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{sell.sellForm.quantity && sell.sellForm.avg_price && sell.sellForm.sell_price && (() => {
|
||||||
|
const qty = Number(sell.sellForm.quantity);
|
||||||
|
const buy = Number(sell.sellForm.avg_price) * qty;
|
||||||
|
const sellAmt = Number(sell.sellForm.sell_price) * qty;
|
||||||
|
const commission = Number(sell.sellForm.commission) || 0;
|
||||||
|
const profit = sellAmt - buy - commission;
|
||||||
|
const rate = buy > 0 ? (profit / buy) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div className="sh-form__preview">
|
||||||
|
<span>매도금액 <strong>{formatNumber(Math.round(sellAmt))}원</strong></span>
|
||||||
|
{commission > 0 && (
|
||||||
|
<span>수수료 & 세금 <strong className="stock-profit is-negative">-{formatNumber(Math.round(commission))}원</strong></span>
|
||||||
|
)}
|
||||||
|
<span>실현손익 <strong className={`stock-profit ${profitColorClass(profit)}`}>{formatNumber(Math.round(profit))}원</strong></span>
|
||||||
|
<span>수익률 <strong className={`stock-profit ${profitColorClass(rate)}`}>{formatPercent(rate)}</strong></span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<div className="sh-form__actions">
|
||||||
|
<button className="button primary" type="submit" disabled={sell.sellFormSaving}>
|
||||||
|
{sell.sellFormSaving ? '저장 중...' : (sell.sellEditId != null ? '수정 저장' : '추가')}
|
||||||
|
</button>
|
||||||
|
<button className="button ghost" type="button" onClick={sell.handleSellFormClose} disabled={sell.sellFormSaving}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
{sell.sellFormError && <p className="stock-error">{sell.sellFormError}</p>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 필터 바 */}
|
||||||
|
<div className="sell-history__filters">
|
||||||
|
<div className="sell-history__filter-group">
|
||||||
|
<span className="sell-history__filter-label">계좌</span>
|
||||||
|
{sellHistoryBrokers.map((b) => (
|
||||||
|
<button
|
||||||
|
key={b}
|
||||||
|
type="button"
|
||||||
|
className={`sell-history__filter-btn ${sell.sellHistoryBroker === b ? 'is-active' : ''}`}
|
||||||
|
onClick={() => sell.setSellHistoryBroker(b)}
|
||||||
|
>
|
||||||
|
{b === 'ALL' ? '전체' : b}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="sell-history__filter-group">
|
||||||
|
<span className="sell-history__filter-label">기간</span>
|
||||||
|
{[
|
||||||
|
{ label: '1개월', value: '1M' },
|
||||||
|
{ label: '3개월', value: '3M' },
|
||||||
|
{ label: '6개월', value: '6M' },
|
||||||
|
{ label: '1년', value: '1Y' },
|
||||||
|
{ label: '전체', value: 'ALL' },
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className={`sell-history__filter-btn ${sell.sellHistoryPeriod === value ? 'is-active' : ''}`}
|
||||||
|
onClick={() => sell.setSellHistoryPeriod(value)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 요약 카드 */}
|
||||||
|
{filteredSellHistory.length > 0 && (
|
||||||
|
<div className="sell-history__summary">
|
||||||
|
<div className="sell-history__summary-card">
|
||||||
|
<span>거래 횟수</span>
|
||||||
|
<strong>{sellHistorySummary.count}건</strong>
|
||||||
|
</div>
|
||||||
|
<div className="sell-history__summary-card">
|
||||||
|
<span>총 매도금액</span>
|
||||||
|
<strong>{formatNumber(sellHistorySummary.totalSell)}원</strong>
|
||||||
|
</div>
|
||||||
|
<div className="sell-history__summary-card">
|
||||||
|
<span>총 수수료 & 세금</span>
|
||||||
|
<strong className="stock-profit is-negative">
|
||||||
|
-{formatNumber(Math.round(sellHistorySummary.totalCommission))}원
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="sell-history__summary-card">
|
||||||
|
<span>실현손익 합계</span>
|
||||||
|
<strong className={`stock-profit ${profitColorClass(sellHistorySummary.totalProfit)}`}>
|
||||||
|
{formatNumber(Math.round(sellHistorySummary.totalProfit))}원
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="sell-history__summary-card">
|
||||||
|
<span>평균 수익률</span>
|
||||||
|
<strong className={`stock-profit ${profitColorClass(sellHistorySummary.rate)}`}>
|
||||||
|
{formatPercent(sellHistorySummary.rate)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 거래 내역 목록 */}
|
||||||
|
{filteredSellHistory.length > 0 ? (
|
||||||
|
<div className="sh-drawer__list">
|
||||||
|
{filteredSellHistory.map((r) => {
|
||||||
|
const profitN = r.realized_profit ?? 0;
|
||||||
|
const rateN = r.realized_rate ?? 0;
|
||||||
|
return (
|
||||||
|
<div key={r.id} className="sh-drawer__item">
|
||||||
|
<div className="sh-drawer__item-top">
|
||||||
|
<div className="sh-drawer__item-name">
|
||||||
|
<span>{r.name}</span>
|
||||||
|
{r.ticker && <code>{r.ticker}</code>}
|
||||||
|
</div>
|
||||||
|
<div className="sh-drawer__item-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => sell.handleSellEditStart(r)}
|
||||||
|
title="수정"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button ghost small pf-btn-danger"
|
||||||
|
onClick={() => sell.handleDeleteSellRecord(r.id)}
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sh-drawer__item-meta">
|
||||||
|
<span className="sell-history__broker">{r.broker}</span>
|
||||||
|
<span className="sell-history__date">
|
||||||
|
{new Date(r.sold_at).toLocaleString('ko-KR', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="sh-drawer__item-metrics">
|
||||||
|
<div>
|
||||||
|
<span>수량</span>
|
||||||
|
<strong>{formatNumber(r.quantity)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>매입가</span>
|
||||||
|
<strong>{formatNumber(r.avg_price)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>매도가</span>
|
||||||
|
<strong>{formatNumber(r.sell_price)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>매도금액</span>
|
||||||
|
<strong>{formatNumber(Math.round(r.sell_amount))}</strong>
|
||||||
|
</div>
|
||||||
|
{(r.commission > 0) && (
|
||||||
|
<div>
|
||||||
|
<span>수수료 & 세금</span>
|
||||||
|
<strong className="stock-profit is-negative">
|
||||||
|
-{formatNumber(Math.round(r.commission))}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span>실현손익</span>
|
||||||
|
<strong className={`stock-profit ${profitColorClass(profitN)}`}>
|
||||||
|
{formatNumber(Math.round(profitN))}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>수익률</span>
|
||||||
|
<strong className={`stock-profit ${profitColorClass(rateN)}`}>
|
||||||
|
{formatPercent(rateN)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="stock-empty sh-drawer__empty">
|
||||||
|
{sell.sellHistory.length === 0
|
||||||
|
? '아직 매도 기록이 없습니다.'
|
||||||
|
: '필터 조건에 맞는 기록이 없습니다.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SellHistoryDrawer;
|
||||||
108
src/pages/stock/hooks/useAdvisor.js
Normal file
108
src/pages/stock/hooks/useAdvisor.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { formatNumber, formatPercent, getVixLabel, getFgLabel } from '../stockUtils';
|
||||||
|
|
||||||
|
export default function useAdvisor({ portfolioHoldings, portfolioSummary, cashList, totalCash, totalAssets, marketCtx }) {
|
||||||
|
const [advisorCopied, setAdvisorCopied] = useState(false);
|
||||||
|
|
||||||
|
const buildAdvisorPrompt = useCallback(() => {
|
||||||
|
const today = new Date().toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
|
||||||
|
const holdingsLines = portfolioHoldings.map((h) => {
|
||||||
|
const cp = h.current_price != null ? `${formatNumber(h.current_price)}원` : '시세 미조회';
|
||||||
|
const rate = h.profit_rate != null ? formatPercent(h.profit_rate) : '미조회';
|
||||||
|
const profit = h.profit_amount != null ? `(${h.profit_amount >= 0 ? '+' : ''}${formatNumber(h.profit_amount)}원)` : '';
|
||||||
|
return `- **${h.name ?? h.ticker}** (${h.ticker ?? ''}) | 계좌: ${h.broker ?? '-'}
|
||||||
|
수량 ${h.quantity}주 | 평균매입가 ${formatNumber(h.avg_price)}원 | 현재가 ${cp} | 손익 ${rate} ${profit}`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const cashLines = cashList.map((c) => `- ${c.broker}: ${formatNumber(c.cash)}원`).join('\n') || '- 없음';
|
||||||
|
|
||||||
|
const marketLines = marketCtx
|
||||||
|
? [
|
||||||
|
`VIX: ${marketCtx.vix != null ? `${marketCtx.vix} (${getVixLabel(marketCtx.vix)})` : '데이터 없음'}`,
|
||||||
|
`공포탐욕지수: ${marketCtx.fg != null ? `${marketCtx.fg}점 (${getFgLabel(marketCtx.fg)})` : '데이터 없음'}`,
|
||||||
|
`미 10년물 국채: ${marketCtx.treasury != null ? `${marketCtx.treasury}%` : '데이터 없음'}`,
|
||||||
|
`WTI 유가: ${marketCtx.wti != null ? `$${marketCtx.wti}` : '데이터 없음'}`,
|
||||||
|
].join('\n')
|
||||||
|
: '시장 데이터 미로드';
|
||||||
|
|
||||||
|
return `당신은 15년 이상 경력의 한국 주식시장 전문 애널리스트입니다.
|
||||||
|
오늘은 ${today}입니다. 아래 포트폴리오 정보와 시장 환경을 바탕으로 전문가 분석을 제공해주세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 현재 시장 환경
|
||||||
|
|
||||||
|
${marketLines}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💼 보유 포트폴리오
|
||||||
|
|
||||||
|
### 보유 종목 (${portfolioHoldings.length}개)
|
||||||
|
|
||||||
|
${holdingsLines || '보유 종목 없음'}
|
||||||
|
|
||||||
|
### 포트폴리오 요약
|
||||||
|
|
||||||
|
- 총 매입금액: ${formatNumber(portfolioSummary.total_buy)}원
|
||||||
|
- 총 평가금액: ${formatNumber(portfolioSummary.total_eval)}원
|
||||||
|
- 총 손익: ${formatNumber(portfolioSummary.total_profit)}원 (수익률: ${formatPercent(portfolioSummary.total_profit_rate)})
|
||||||
|
- 예수금 합계: ${totalCash != null ? formatNumber(totalCash) + '원' : '미입력'}
|
||||||
|
- 총 자산: ${totalAssets != null ? formatNumber(totalAssets) + '원' : '미집계'}
|
||||||
|
|
||||||
|
### 예수금 현황
|
||||||
|
|
||||||
|
${cashLines}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 분석 요청
|
||||||
|
|
||||||
|
다음 형식으로 명확하게 작성해주세요:
|
||||||
|
|
||||||
|
### 📈 오늘의 시장 환경
|
||||||
|
시장 환경 데이터를 바탕으로 오늘 한국 주식시장의 전반적인 분위기와 주요 이슈를 2-3문장으로 요약하세요.
|
||||||
|
|
||||||
|
### 🔍 종목별 분석 및 행동 지침
|
||||||
|
각 보유 종목에 대해 아래 형식으로 작성하세요:
|
||||||
|
|
||||||
|
**[종목명 (티커)]**
|
||||||
|
- 현황: 현재 손익 상태와 포지션 평가
|
||||||
|
- 분석: 업황·섹터 동향, 주요 리스크/기회
|
||||||
|
- 🎯 행동 지침: **[매도 / 보유 / 추가매수 / 분할매도]** — 구체적 이유와 목표 참고 가격대
|
||||||
|
|
||||||
|
### 💼 포트폴리오 종합 의견
|
||||||
|
전체 포트폴리오의 섹터 편중, 리밸런싱 필요 여부, 현금 비중 조언을 작성하세요.
|
||||||
|
|
||||||
|
### ⚠️ 오늘 주의해야 할 리스크
|
||||||
|
매크로·섹터·개별 종목 측면에서 오늘 특히 주의할 리스크를 2-3가지 나열하세요.
|
||||||
|
|
||||||
|
### 🚀 추가 매수 유망 섹터 추천
|
||||||
|
현재 시장 환경과 포트폴리오 구성을 고려하여 추가 매수를 검토할 만한 유망 섹터를 추천해주세요.
|
||||||
|
아래 형식으로 작성하세요:
|
||||||
|
|
||||||
|
**[섹터명]**
|
||||||
|
- 추천 이유: 현재 시장 환경에서 이 섹터가 유망한 근거 (매크로 환경, 정책, 업황 사이클 등)
|
||||||
|
- 대표 종목 예시: 국내 대표 종목 2-3개 (현재 포트폴리오와 중복 여부 언급)
|
||||||
|
- 주의사항: 이 섹터 투자 시 고려해야 할 리스크
|
||||||
|
|
||||||
|
(현재 포트폴리오에 없거나 비중이 낮은 섹터를 우선 추천하고, 2-3개 섹터를 제시해주세요.)
|
||||||
|
|
||||||
|
---
|
||||||
|
분석은 반드시 한국어로, 구체적인 수치와 근거를 들어 전문적으로 작성해주세요.
|
||||||
|
투자 결정은 최종적으로 투자자 본인이 판단함을 명시하세요.`;
|
||||||
|
}, [portfolioHoldings, portfolioSummary, cashList, totalCash, totalAssets, marketCtx]);
|
||||||
|
|
||||||
|
const handleCopyPrompt = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(buildAdvisorPrompt());
|
||||||
|
setAdvisorCopied(true);
|
||||||
|
setTimeout(() => setAdvisorCopied(false), 2500);
|
||||||
|
} catch {
|
||||||
|
alert('클립보드 복사에 실패했습니다. 텍스트를 직접 선택해 복사하세요.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { advisorCopied, buildAdvisorPrompt, handleCopyPrompt };
|
||||||
|
}
|
||||||
84
src/pages/stock/hooks/useAiBalance.js
Normal file
84
src/pages/stock/hooks/useAiBalance.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { getTradeBalance, createTradeOrder } from '../../../api';
|
||||||
|
import { getQty, getBuyPrice, getCurrentPrice, getProfitRate, getProfitLoss } from '../stockUtils';
|
||||||
|
|
||||||
|
export default function useAiBalance() {
|
||||||
|
const [balance, setBalance] = useState(null);
|
||||||
|
const [balanceLoading, setBalanceLoading] = useState(false);
|
||||||
|
const [balanceError, setBalanceError] = useState('');
|
||||||
|
const [balanceLoaded, setBalanceLoaded] = useState(false);
|
||||||
|
|
||||||
|
const [manualForm, setManualForm] = useState({
|
||||||
|
code: '',
|
||||||
|
qty: 1,
|
||||||
|
price: 0,
|
||||||
|
type: 'buy',
|
||||||
|
});
|
||||||
|
const [manualLoading, setManualLoading] = useState(false);
|
||||||
|
const [manualError, setManualError] = useState('');
|
||||||
|
const [manualResult, setManualResult] = useState(null);
|
||||||
|
const [kisModal, setKisModal] = useState('');
|
||||||
|
|
||||||
|
const loadBalance = useCallback(async () => {
|
||||||
|
setBalanceLoading(true);
|
||||||
|
setBalanceError('');
|
||||||
|
try {
|
||||||
|
const data = await getTradeBalance();
|
||||||
|
setBalance(data);
|
||||||
|
setBalanceLoaded(true);
|
||||||
|
} catch (err) {
|
||||||
|
setBalanceError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setBalanceLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submitManualOrder = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setManualLoading(true);
|
||||||
|
setManualError('');
|
||||||
|
setManualResult(null);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
ticker: manualForm.code.trim(),
|
||||||
|
action: manualForm.type === 'sell' ? 'SELL' : 'BUY',
|
||||||
|
quantity: Number(manualForm.qty),
|
||||||
|
price: Number(manualForm.price),
|
||||||
|
};
|
||||||
|
const result = await createTradeOrder(payload);
|
||||||
|
setManualResult(result ?? { ok: true });
|
||||||
|
if (result?.kis_result !== undefined) {
|
||||||
|
const message =
|
||||||
|
typeof result.kis_result === 'string'
|
||||||
|
? result.kis_result
|
||||||
|
: JSON.stringify(result.kis_result, null, 2);
|
||||||
|
setKisModal(message);
|
||||||
|
}
|
||||||
|
await loadBalance();
|
||||||
|
} catch (err) {
|
||||||
|
setManualError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setManualLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* derived */
|
||||||
|
const holdings = useMemo(() => {
|
||||||
|
if (!balance) return [];
|
||||||
|
if (Array.isArray(balance.holdings)) return balance.holdings;
|
||||||
|
if (Array.isArray(balance.positions)) return balance.positions;
|
||||||
|
if (Array.isArray(balance.items)) return balance.items;
|
||||||
|
return [];
|
||||||
|
}, [balance]);
|
||||||
|
|
||||||
|
const summary = balance?.summary ?? {};
|
||||||
|
const totalEval = summary.total_eval ?? balance?.total_eval ?? balance?.total_value;
|
||||||
|
const deposit = summary.deposit ?? balance?.deposit ?? balance?.available_cash;
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance, balanceLoading, balanceError, balanceLoaded, loadBalance,
|
||||||
|
holdings, summary, totalEval, deposit,
|
||||||
|
manualForm, setManualForm, manualLoading, manualError, manualResult,
|
||||||
|
kisModal, setKisModal, submitManualOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
92
src/pages/stock/hooks/useAiCoach.js
Normal file
92
src/pages/stock/hooks/useAiCoach.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { formatNumber, formatPercent, getVixLabel, getFgLabel } from '../stockUtils';
|
||||||
|
|
||||||
|
export default function useAiCoach({ portfolioHoldings, portfolioSummary, totalCash, totalAssets, marketCtx }) {
|
||||||
|
const [aiModel, setAiModel] = useState(() => localStorage.getItem('ai_coach_model') ?? 'claude-haiku-4-5-20251001');
|
||||||
|
const [aiResult, setAiResult] = useState(null);
|
||||||
|
const [aiLoading, setAiLoading] = useState(false);
|
||||||
|
const [aiError, setAiError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const cached = localStorage.getItem(`ai_coach_${today}`);
|
||||||
|
if (cached) {
|
||||||
|
try { setAiResult({ ...JSON.parse(cached), cached: true }); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAiCoach = async () => {
|
||||||
|
if (portfolioHoldings.length === 0) return;
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const cacheKey = `ai_coach_${today}`;
|
||||||
|
const cached = localStorage.getItem(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
try { setAiResult({ ...JSON.parse(cached), cached: true }); return; } catch { /* invalid */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
setAiLoading(true);
|
||||||
|
setAiError('');
|
||||||
|
|
||||||
|
const holdingsText = portfolioHoldings
|
||||||
|
.map((item) =>
|
||||||
|
`- ${item.name ?? item.ticker}(${item.ticker ?? ''}): ${item.quantity}주, 매입가 ${formatNumber(item.avg_price)}원, 현재가 ${item.current_price != null ? formatNumber(item.current_price) + '원' : '미조회'}, 수익률 ${item.profit_rate != null ? formatPercent(item.profit_rate) : '미조회'}`
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const marketText = marketCtx
|
||||||
|
? `\n[현재 시장 환경]\nVIX: ${marketCtx.vix != null ? `${marketCtx.vix} (${getVixLabel(marketCtx.vix)})` : '데이터 없음'}\nFear & Greed: ${marketCtx.fg != null ? `${marketCtx.fg}점 (${getFgLabel(marketCtx.fg)})` : '데이터 없음'}\n미국 10년물 국채: ${marketCtx.treasury != null ? `${marketCtx.treasury}%` : '데이터 없음'}\nWTI 유가: ${marketCtx.wti != null ? `$${marketCtx.wti}` : '데이터 없음'}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const prompt = `당신은 한국 주식 전문 투자 코치입니다. 아래 포트폴리오와 시장 환경을 종합 분석하여 JSON으로만 답하세요.
|
||||||
|
|
||||||
|
분석 일자: ${today}
|
||||||
|
총 매입금액: ${formatNumber(portfolioSummary.total_buy)}원
|
||||||
|
총 평가금액: ${formatNumber(portfolioSummary.total_eval)}원
|
||||||
|
총 손익: ${formatNumber(portfolioSummary.total_profit)}원 (수익률: ${formatPercent(portfolioSummary.total_profit_rate)})
|
||||||
|
예수금 합계: ${totalCash != null ? formatNumber(totalCash) + '원' : '미입력'}
|
||||||
|
총 자산: ${totalAssets != null ? formatNumber(totalAssets) + '원' : '미집계'}
|
||||||
|
보유 종목 수: ${portfolioHoldings.length}개
|
||||||
|
보유 종목:
|
||||||
|
${holdingsText}${marketText}
|
||||||
|
|
||||||
|
반드시 아래 JSON 형식으로만 응답하세요 (코드블록 없이, 모든 텍스트는 한국어로):
|
||||||
|
{
|
||||||
|
"score": 85,
|
||||||
|
"grade": "A",
|
||||||
|
"summary": "30자 이내 한줄 평가",
|
||||||
|
"evaluation": "200자 이내 상세 평가",
|
||||||
|
"advice": [
|
||||||
|
{ "title": "조언 제목", "body": "50자 이내 조언 내용" },
|
||||||
|
{ "title": "조언 제목", "body": "50자 이내 조언 내용" },
|
||||||
|
{ "title": "조언 제목", "body": "50자 이내 조언 내용" }
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/stock/ai-coach', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model: aiModel, prompt, max_tokens: 1024 }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const errData = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(errData.error || `AI Coach 오류 (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const text = data.content?.[0]?.text ?? '';
|
||||||
|
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||||
|
if (!jsonMatch) throw new Error('AI 응답에서 JSON을 파싱할 수 없습니다.');
|
||||||
|
const result = JSON.parse(jsonMatch[0]);
|
||||||
|
const final = { ...result, generated_at: new Date().toISOString(), cached: false };
|
||||||
|
localStorage.setItem(cacheKey, JSON.stringify(final));
|
||||||
|
setAiResult(final);
|
||||||
|
} catch (err) {
|
||||||
|
setAiError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setAiLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { aiModel, setAiModel, aiResult, setAiResult, aiLoading, aiError, handleAiCoach };
|
||||||
|
}
|
||||||
66
src/pages/stock/hooks/useAssetHistory.js
Normal file
66
src/pages/stock/hooks/useAssetHistory.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { getAssetHistory, saveAssetSnapshot } from '../../../api';
|
||||||
|
|
||||||
|
export default function useAssetHistory() {
|
||||||
|
const [assetHistory, setAssetHistory] = useState(null);
|
||||||
|
const [assetHistoryLoading, setAssetHistoryLoading] = useState(false);
|
||||||
|
const [assetHistoryDays, setAssetHistoryDays] = useState(30);
|
||||||
|
const [snapshotSaving, setSnapshotSaving] = useState(false);
|
||||||
|
|
||||||
|
const loadAssetHistory = useCallback(async (days) => {
|
||||||
|
setAssetHistoryLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getAssetHistory(days);
|
||||||
|
const raw = data?.snapshots ?? data?.history ?? (Array.isArray(data) ? data : []);
|
||||||
|
const byDate = {};
|
||||||
|
for (const item of raw) byDate[item.date] = item.total_assets ?? 0;
|
||||||
|
|
||||||
|
const toLocalDate = (d) => {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
let filled;
|
||||||
|
if (days > 0) {
|
||||||
|
const today = new Date();
|
||||||
|
filled = Array.from({ length: days }, (_, i) => {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(today.getDate() - (days - 1 - i));
|
||||||
|
const dateStr = toLocalDate(d);
|
||||||
|
const val = byDate[dateStr];
|
||||||
|
return val > 0 ? { date: dateStr, total_assets: val } : null;
|
||||||
|
}).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
filled = Object.entries(byDate)
|
||||||
|
.filter(([, v]) => v > 0)
|
||||||
|
.map(([date, total_assets]) => ({ date, total_assets }))
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}
|
||||||
|
setAssetHistory(filled);
|
||||||
|
} catch {
|
||||||
|
setAssetHistory([]);
|
||||||
|
} finally {
|
||||||
|
setAssetHistoryLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveSnapshot = async (totalAssets, days) => {
|
||||||
|
setSnapshotSaving(true);
|
||||||
|
try {
|
||||||
|
await saveAssetSnapshot(totalAssets != null ? Number(totalAssets) : undefined);
|
||||||
|
await loadAssetHistory(days);
|
||||||
|
} catch (err) {
|
||||||
|
alert('스냅샷 저장 실패: ' + (err?.message ?? String(err)));
|
||||||
|
} finally {
|
||||||
|
setSnapshotSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
assetHistory, assetHistoryLoading,
|
||||||
|
assetHistoryDays, setAssetHistoryDays,
|
||||||
|
snapshotSaving, loadAssetHistory, handleSaveSnapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
23
src/pages/stock/hooks/useMarketContext.js
Normal file
23
src/pages/stock/hooks/useMarketContext.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getFearAndGreed, getVix, getTreasury10Y, getWTI } from '../../../api';
|
||||||
|
|
||||||
|
export default function useMarketContext(shouldLoad) {
|
||||||
|
const [marketCtx, setMarketCtx] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldLoad || marketCtx !== null) return;
|
||||||
|
Promise.allSettled([getFearAndGreed(), getVix(), getTreasury10Y(), getWTI()])
|
||||||
|
.then(([fg, vix, t, w]) => {
|
||||||
|
const fgRaw = fg.status === 'fulfilled' ? fg.value : null;
|
||||||
|
const fgScore = fgRaw?.fear_and_greed?.score ?? fgRaw?.score;
|
||||||
|
setMarketCtx({
|
||||||
|
fg: fgScore != null ? Math.round(Number(fgScore)) : null,
|
||||||
|
vix: vix.status === 'fulfilled' ? (vix.value?.value ?? null) : null,
|
||||||
|
treasury: t.status === 'fulfilled' ? (t.value?.value ?? null) : null,
|
||||||
|
wti: w.status === 'fulfilled' ? (w.value?.value ?? null) : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [shouldLoad, marketCtx]);
|
||||||
|
|
||||||
|
return marketCtx;
|
||||||
|
}
|
||||||
269
src/pages/stock/hooks/usePortfolio.js
Normal file
269
src/pages/stock/hooks/usePortfolio.js
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import { useState, useCallback, useRef, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
getPortfolio, addPortfolio, updatePortfolio, deletePortfolio,
|
||||||
|
upsertCash, deleteCash,
|
||||||
|
} from '../../../api';
|
||||||
|
import { emptyPortfolioForm } from '../stockUtils';
|
||||||
|
|
||||||
|
export default function usePortfolio() {
|
||||||
|
const [portfolio, setPortfolio] = useState(null);
|
||||||
|
const [portfolioLoading, setPortfolioLoading] = useState(false);
|
||||||
|
const [portfolioError, setPortfolioError] = useState('');
|
||||||
|
const [portfolioLoaded, setPortfolioLoaded] = useState(false);
|
||||||
|
|
||||||
|
/* add form */
|
||||||
|
const [addForm, setAddForm] = useState({ ...emptyPortfolioForm });
|
||||||
|
const [addFormOpen, setAddFormOpen] = useState(false);
|
||||||
|
const [addLoading, setAddLoading] = useState(false);
|
||||||
|
const [addError, setAddError] = useState('');
|
||||||
|
|
||||||
|
/* edit */
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [editForm, setEditForm] = useState({});
|
||||||
|
const [editLoading, setEditLoading] = useState(false);
|
||||||
|
const editOrigRef = useRef({});
|
||||||
|
|
||||||
|
/* delete / sell confirm */
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
|
||||||
|
const [sellConfirmId, setSellConfirmId] = useState(null);
|
||||||
|
const [sellLoading, setSellLoading] = useState(false);
|
||||||
|
|
||||||
|
/* cash */
|
||||||
|
const [cashForm, setCashForm] = useState({ broker: '', cash: '' });
|
||||||
|
const [cashSaving, setCashSaving] = useState(false);
|
||||||
|
const [cashError, setCashError] = useState('');
|
||||||
|
const [cashEditingBroker, setCashEditingBroker] = useState(null);
|
||||||
|
const [cashEditingValue, setCashEditingValue] = useState('');
|
||||||
|
const [cashEditSaving, setCashEditSaving] = useState(false);
|
||||||
|
|
||||||
|
/* derived */
|
||||||
|
const portfolioHoldings = portfolio?.holdings ?? [];
|
||||||
|
const portfolioSummary = portfolio?.summary ?? {};
|
||||||
|
const cashList = portfolio?.cash ?? [];
|
||||||
|
const totalCash = portfolioSummary.total_cash ?? null;
|
||||||
|
const totalAssets = portfolioSummary.total_assets ?? null;
|
||||||
|
|
||||||
|
const brokerGroups = useMemo(() => {
|
||||||
|
const map = {};
|
||||||
|
for (const item of portfolioHoldings) {
|
||||||
|
const broker = item.broker || '기타';
|
||||||
|
if (!map[broker]) map[broker] = [];
|
||||||
|
map[broker].push(item);
|
||||||
|
}
|
||||||
|
return Object.entries(map).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
}, [portfolioHoldings]);
|
||||||
|
|
||||||
|
const brokerColors = useMemo(() => {
|
||||||
|
const palette = [
|
||||||
|
{ border: 'rgba(129,140,248,0.5)', bg: 'rgba(129,140,248,0.06)' },
|
||||||
|
{ border: 'rgba(251,191,36,0.5)', bg: 'rgba(251,191,36,0.06)' },
|
||||||
|
{ border: 'rgba(52,211,153,0.5)', bg: 'rgba(52,211,153,0.06)' },
|
||||||
|
{ border: 'rgba(244,114,182,0.5)', bg: 'rgba(244,114,182,0.06)' },
|
||||||
|
{ border: 'rgba(251,146,60,0.5)', bg: 'rgba(251,146,60,0.06)' },
|
||||||
|
{ border: 'rgba(139,92,246,0.5)', bg: 'rgba(139,92,246,0.06)' },
|
||||||
|
];
|
||||||
|
const map = {};
|
||||||
|
brokerGroups.forEach(([broker], i) => {
|
||||||
|
map[broker] = palette[i % palette.length];
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [brokerGroups]);
|
||||||
|
|
||||||
|
const getBrokerSummary = (items) => {
|
||||||
|
let totalBuy = 0, totalEvalAmt = 0, hasNullPrice = false;
|
||||||
|
for (const item of items) {
|
||||||
|
totalBuy += (item.avg_price ?? 0) * (item.quantity ?? 0);
|
||||||
|
if (item.eval_amount != null) totalEvalAmt += item.eval_amount;
|
||||||
|
else hasNullPrice = true;
|
||||||
|
}
|
||||||
|
const totalProfit = totalEvalAmt - totalBuy;
|
||||||
|
const totalProfitRate = totalBuy > 0 ? (totalProfit / totalBuy) * 100 : 0;
|
||||||
|
return { totalBuy, totalEval: totalEvalAmt, totalProfit, totalProfitRate, hasNullPrice };
|
||||||
|
};
|
||||||
|
|
||||||
|
/* loaders */
|
||||||
|
const loadPortfolio = useCallback(async () => {
|
||||||
|
setPortfolioLoading(true);
|
||||||
|
setPortfolioError('');
|
||||||
|
try {
|
||||||
|
const data = await getPortfolio();
|
||||||
|
setPortfolio(data);
|
||||||
|
setPortfolioLoaded(true);
|
||||||
|
} catch (err) {
|
||||||
|
setPortfolioError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setPortfolioLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* actions */
|
||||||
|
const handleAddSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setAddLoading(true);
|
||||||
|
setAddError('');
|
||||||
|
try {
|
||||||
|
await addPortfolio({
|
||||||
|
broker: addForm.broker.trim(),
|
||||||
|
ticker: addForm.ticker.trim(),
|
||||||
|
name: addForm.name.trim(),
|
||||||
|
quantity: Number(addForm.quantity),
|
||||||
|
avg_price: Number(addForm.avg_price),
|
||||||
|
});
|
||||||
|
setAddForm({ ...emptyPortfolioForm });
|
||||||
|
setAddFormOpen(false);
|
||||||
|
await loadPortfolio();
|
||||||
|
} catch (err) {
|
||||||
|
setAddError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setAddLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditStart = (item) => {
|
||||||
|
setEditingId(item.id);
|
||||||
|
const data = { quantity: item.quantity, avg_price: item.avg_price, broker: item.broker, name: item.name };
|
||||||
|
setEditForm(data);
|
||||||
|
editOrigRef.current = { ...data };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = async (id) => {
|
||||||
|
setEditLoading(true);
|
||||||
|
try {
|
||||||
|
const orig = editOrigRef.current ?? {};
|
||||||
|
const diff = {};
|
||||||
|
for (const key of Object.keys(editForm)) {
|
||||||
|
if (editForm[key] !== orig[key]) diff[key] = editForm[key];
|
||||||
|
}
|
||||||
|
if (Object.keys(diff).length === 0) { setEditingId(null); return; }
|
||||||
|
await updatePortfolio(id, diff);
|
||||||
|
setEditingId(null);
|
||||||
|
await loadPortfolio();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err?.message ?? String(err);
|
||||||
|
if (msg.includes('404') || msg.includes('not found')) {
|
||||||
|
alert('해당 종목을 찾을 수 없습니다. 이미 삭제되었을 수 있습니다.');
|
||||||
|
await loadPortfolio();
|
||||||
|
} else {
|
||||||
|
alert('수정 실패: ' + msg);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setEditLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
try {
|
||||||
|
await deletePortfolio(id);
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
await loadPortfolio();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err?.message ?? String(err);
|
||||||
|
if (msg.includes('404') || msg.includes('not found')) {
|
||||||
|
alert('해당 종목을 찾을 수 없습니다. 이미 삭제되었을 수 있습니다.');
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
await loadPortfolio();
|
||||||
|
} else {
|
||||||
|
alert('삭제 실패: ' + msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* cash actions */
|
||||||
|
const handleCashSave = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!cashForm.broker.trim() || cashForm.cash === '') return;
|
||||||
|
setCashSaving(true);
|
||||||
|
setCashError('');
|
||||||
|
try {
|
||||||
|
await upsertCash(cashForm.broker.trim(), Number(cashForm.cash));
|
||||||
|
setCashForm({ broker: '', cash: '' });
|
||||||
|
await loadPortfolio();
|
||||||
|
} catch (err) {
|
||||||
|
setCashError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setCashSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCashDelete = async (broker) => {
|
||||||
|
try {
|
||||||
|
await deleteCash(broker);
|
||||||
|
await loadPortfolio();
|
||||||
|
} catch (err) {
|
||||||
|
alert('예수금 삭제 실패: ' + (err?.message ?? String(err)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCashInlineEdit = (item) => {
|
||||||
|
setCashEditingBroker(item.broker);
|
||||||
|
setCashEditingValue(String(item.cash ?? ''));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCashInlineSave = async (broker) => {
|
||||||
|
if (cashEditingValue === '') return;
|
||||||
|
setCashEditSaving(true);
|
||||||
|
try {
|
||||||
|
await upsertCash(broker, Number(cashEditingValue));
|
||||||
|
setCashEditingBroker(null);
|
||||||
|
setCashEditingValue('');
|
||||||
|
await loadPortfolio();
|
||||||
|
} catch (err) {
|
||||||
|
alert('예수금 수정 실패: ' + (err?.message ?? String(err)));
|
||||||
|
} finally {
|
||||||
|
setCashEditSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCashInlineCancel = () => {
|
||||||
|
setCashEditingBroker(null);
|
||||||
|
setCashEditingValue('');
|
||||||
|
};
|
||||||
|
|
||||||
|
/* sell (현재가 매도) */
|
||||||
|
const handleSell = async (item, { cashList: cl, loadSellHistoryAfter }) => {
|
||||||
|
const sellPrice = item.current_price ?? item.avg_price;
|
||||||
|
const avgPrice = item.avg_price ?? 0;
|
||||||
|
const qty = item.quantity ?? 0;
|
||||||
|
const saleAmount = sellPrice * qty;
|
||||||
|
const buyAmount = avgPrice * qty;
|
||||||
|
const realizedProfit = saleAmount - buyAmount;
|
||||||
|
const realizedRate = buyAmount > 0 ? (realizedProfit / buyAmount) * 100 : 0;
|
||||||
|
const broker = item.broker ?? '';
|
||||||
|
|
||||||
|
setSellLoading(true);
|
||||||
|
try {
|
||||||
|
const existing = cl.find((c) => c.broker === broker);
|
||||||
|
const newCash = (existing?.cash ?? 0) + saleAmount;
|
||||||
|
await upsertCash(broker, newCash);
|
||||||
|
await deletePortfolio(item.id);
|
||||||
|
setSellConfirmId(null);
|
||||||
|
await loadPortfolio();
|
||||||
|
if (loadSellHistoryAfter) {
|
||||||
|
await loadSellHistoryAfter({
|
||||||
|
broker, ticker: item.ticker ?? '', name: item.name ?? item.ticker ?? 'N/A',
|
||||||
|
quantity: qty, avg_price: avgPrice, sell_price: sellPrice,
|
||||||
|
buy_amount: buyAmount, sell_amount: saleAmount,
|
||||||
|
realized_profit: realizedProfit, realized_rate: realizedRate,
|
||||||
|
sold_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('매도 처리 실패: ' + (err?.message ?? String(err)));
|
||||||
|
} finally {
|
||||||
|
setSellLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
portfolio, portfolioLoading, portfolioError, portfolioLoaded, loadPortfolio,
|
||||||
|
portfolioHoldings, portfolioSummary, cashList, totalCash, totalAssets,
|
||||||
|
addForm, setAddForm, addFormOpen, setAddFormOpen, addLoading, addError, handleAddSubmit,
|
||||||
|
editingId, setEditingId, editForm, setEditForm, editLoading, handleEditStart, handleEditSave,
|
||||||
|
deleteConfirmId, setDeleteConfirmId, handleDelete,
|
||||||
|
sellConfirmId, setSellConfirmId, sellLoading, handleSell,
|
||||||
|
cashForm, setCashForm, cashSaving, cashError, handleCashSave, handleCashDelete,
|
||||||
|
cashEditingBroker, cashEditingValue, setCashEditingValue, cashEditSaving,
|
||||||
|
handleCashInlineEdit, handleCashInlineSave, handleCashInlineCancel,
|
||||||
|
brokerGroups, brokerColors, getBrokerSummary,
|
||||||
|
};
|
||||||
|
}
|
||||||
111
src/pages/stock/hooks/useReportData.js
Normal file
111
src/pages/stock/hooks/useReportData.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { toNumeric } from '../stockUtils';
|
||||||
|
|
||||||
|
export default function useReportData({ portfolioHoldings, portfolioSummary, brokerGroups, getBrokerSummary }) {
|
||||||
|
const [reportSortField, setReportSortField] = useState('profit_rate');
|
||||||
|
const [reportSortDir, setReportSortDir] = useState('desc');
|
||||||
|
|
||||||
|
const handleReportSort = (field) => {
|
||||||
|
if (reportSortField === field) {
|
||||||
|
setReportSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setReportSortField(field);
|
||||||
|
setReportSortDir('desc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const brokerPieData = useMemo(() =>
|
||||||
|
brokerGroups
|
||||||
|
.map(([broker, items]) => ({ name: broker, value: getBrokerSummary(items).totalEval }))
|
||||||
|
.filter((d) => d.value > 0),
|
||||||
|
[brokerGroups, getBrokerSummary]
|
||||||
|
);
|
||||||
|
|
||||||
|
const profitBarData = useMemo(() =>
|
||||||
|
portfolioHoldings
|
||||||
|
.filter((item) => item.profit_rate != null)
|
||||||
|
.map((item) => ({
|
||||||
|
name: item.ticker ?? (item.name ?? 'N/A').slice(0, 5),
|
||||||
|
fullName: item.name ?? item.ticker ?? 'N/A',
|
||||||
|
rate: toNumeric(item.profit_rate) ?? 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.rate - a.rate),
|
||||||
|
[portfolioHoldings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const maxAbsRate = useMemo(() =>
|
||||||
|
Math.max(1, ...portfolioHoldings.map((h) => Math.abs(toNumeric(h.profit_rate) ?? 0))),
|
||||||
|
[portfolioHoldings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const brokerConcentration = useMemo(() => {
|
||||||
|
const totalEval = toNumeric(portfolioSummary.total_eval);
|
||||||
|
if (!totalEval || totalEval === 0) return [];
|
||||||
|
return brokerGroups
|
||||||
|
.map(([broker, items]) => {
|
||||||
|
const { totalEval: brokerEval } = getBrokerSummary(items);
|
||||||
|
const ratio = Math.round((brokerEval / totalEval) * 1000) / 10;
|
||||||
|
return { broker, eval: brokerEval, ratio };
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.ratio - a.ratio);
|
||||||
|
}, [brokerGroups, portfolioSummary.total_eval, getBrokerSummary]);
|
||||||
|
|
||||||
|
const stockConcentration = useMemo(() => {
|
||||||
|
const totalEval = toNumeric(portfolioSummary.total_eval);
|
||||||
|
if (!totalEval || totalEval === 0) return [];
|
||||||
|
return portfolioHoldings
|
||||||
|
.map((item) => {
|
||||||
|
const evalAmt = item.eval_amount != null
|
||||||
|
? toNumeric(item.eval_amount)
|
||||||
|
: (item.current_price != null && item.quantity != null)
|
||||||
|
? toNumeric(item.current_price) * toNumeric(item.quantity)
|
||||||
|
: null;
|
||||||
|
if (!evalAmt) return null;
|
||||||
|
return {
|
||||||
|
name: item.name ?? item.ticker ?? 'N/A',
|
||||||
|
ticker: item.ticker ?? '',
|
||||||
|
eval: evalAmt,
|
||||||
|
ratio: Math.round((evalAmt / totalEval) * 1000) / 10,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => b.ratio - a.ratio)
|
||||||
|
.slice(0, 5);
|
||||||
|
}, [portfolioHoldings, portfolioSummary.total_eval]);
|
||||||
|
|
||||||
|
const sortedHoldings = useMemo(() => {
|
||||||
|
const getVal = (item) => {
|
||||||
|
switch (reportSortField) {
|
||||||
|
case 'profit_rate': return toNumeric(item.profit_rate) ?? -Infinity;
|
||||||
|
case 'profit_amount': return toNumeric(item.profit_amount) ?? -Infinity;
|
||||||
|
case 'eval_amount': {
|
||||||
|
const ea = toNumeric(item.eval_amount);
|
||||||
|
if (ea != null) return ea;
|
||||||
|
const cp = toNumeric(item.current_price);
|
||||||
|
const qty = toNumeric(item.quantity);
|
||||||
|
return cp != null && qty != null ? cp * qty : -Infinity;
|
||||||
|
}
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return [...portfolioHoldings].sort((a, b) => {
|
||||||
|
if (reportSortField === 'name')
|
||||||
|
return reportSortDir === 'asc'
|
||||||
|
? (a.name ?? '').localeCompare(b.name ?? '')
|
||||||
|
: (b.name ?? '').localeCompare(a.name ?? '');
|
||||||
|
if (reportSortField === 'broker')
|
||||||
|
return reportSortDir === 'asc'
|
||||||
|
? (a.broker ?? '').localeCompare(b.broker ?? '')
|
||||||
|
: (b.broker ?? '').localeCompare(a.broker ?? '');
|
||||||
|
const av = getVal(a);
|
||||||
|
const bv = getVal(b);
|
||||||
|
return reportSortDir === 'asc' ? av - bv : bv - av;
|
||||||
|
});
|
||||||
|
}, [portfolioHoldings, reportSortField, reportSortDir]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
reportSortField, reportSortDir, handleReportSort,
|
||||||
|
brokerPieData, profitBarData, maxAbsRate,
|
||||||
|
brokerConcentration, stockConcentration, sortedHoldings,
|
||||||
|
};
|
||||||
|
}
|
||||||
131
src/pages/stock/hooks/useSellHistory.js
Normal file
131
src/pages/stock/hooks/useSellHistory.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { getSellHistory, addSellHistory, updateSellHistory, deleteSellHistory } from '../../../api';
|
||||||
|
import { emptySellForm, toLocalDatetimeValue } from '../stockUtils';
|
||||||
|
|
||||||
|
export default function useSellHistory() {
|
||||||
|
const [sellHistory, setSellHistory] = useState([]);
|
||||||
|
const [sellHistoryLoading, setSellHistoryLoading] = useState(false);
|
||||||
|
const [sellHistoryBroker, setSellHistoryBroker] = useState('ALL');
|
||||||
|
const [sellHistoryPeriod, setSellHistoryPeriod] = useState('3M');
|
||||||
|
|
||||||
|
const [sellDrawerOpen, setSellDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
const [sellFormOpen, setSellFormOpen] = useState(false);
|
||||||
|
const [sellEditId, setSellEditId] = useState(null);
|
||||||
|
const [sellForm, setSellForm] = useState(emptySellForm());
|
||||||
|
const [sellFormSaving, setSellFormSaving] = useState(false);
|
||||||
|
const [sellFormError, setSellFormError] = useState('');
|
||||||
|
|
||||||
|
const loadSellHistory = useCallback(async () => {
|
||||||
|
setSellHistoryLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getSellHistory();
|
||||||
|
setSellHistory(data?.records ?? (Array.isArray(data) ? data : []));
|
||||||
|
} catch {
|
||||||
|
/* 백엔드 미구현 시 빈 배열 유지 */
|
||||||
|
} finally {
|
||||||
|
setSellHistoryLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 매도 후 실현손익 기록 추가 (usePortfolio.handleSell에서 호출) */
|
||||||
|
const addSellRecord = async (record) => {
|
||||||
|
try {
|
||||||
|
const saved = await addSellHistory(record);
|
||||||
|
setSellHistory((prev) => [saved ?? record, ...prev]);
|
||||||
|
} catch {
|
||||||
|
setSellHistory((prev) => [{ ...record, id: Date.now() }, ...prev]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSellRecord = async (id) => {
|
||||||
|
setSellHistory((prev) => prev.filter((r) => r.id !== id));
|
||||||
|
try {
|
||||||
|
await deleteSellHistory(id);
|
||||||
|
} catch {
|
||||||
|
loadSellHistory();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSellFormOpen = () => {
|
||||||
|
setSellEditId(null);
|
||||||
|
setSellForm(emptySellForm());
|
||||||
|
setSellFormError('');
|
||||||
|
setSellFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSellEditStart = (record) => {
|
||||||
|
setSellEditId(record.id);
|
||||||
|
setSellForm({
|
||||||
|
broker: record.broker ?? '',
|
||||||
|
ticker: record.ticker ?? '',
|
||||||
|
name: record.name ?? '',
|
||||||
|
quantity: String(record.quantity ?? ''),
|
||||||
|
avg_price: String(record.avg_price ?? ''),
|
||||||
|
sell_price: String(record.sell_price ?? ''),
|
||||||
|
commission: String(record.commission ?? ''),
|
||||||
|
sold_at: toLocalDatetimeValue(record.sold_at),
|
||||||
|
});
|
||||||
|
setSellFormError('');
|
||||||
|
setSellFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSellFormClose = () => {
|
||||||
|
setSellFormOpen(false);
|
||||||
|
setSellEditId(null);
|
||||||
|
setSellFormError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSellFormSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSellFormSaving(true);
|
||||||
|
setSellFormError('');
|
||||||
|
|
||||||
|
const qty = Number(sellForm.quantity);
|
||||||
|
const avgPrice = Number(sellForm.avg_price);
|
||||||
|
const sellPrice = Number(sellForm.sell_price);
|
||||||
|
const commission = Number(sellForm.commission) || 0;
|
||||||
|
const buyAmount = avgPrice * qty;
|
||||||
|
const sellAmount = sellPrice * qty;
|
||||||
|
const realizedProfit = sellAmount - buyAmount - commission;
|
||||||
|
const realizedRate = buyAmount > 0 ? (realizedProfit / buyAmount) * 100 : 0;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
broker: sellForm.broker.trim(),
|
||||||
|
ticker: sellForm.ticker.trim(),
|
||||||
|
name: sellForm.name.trim(),
|
||||||
|
quantity: qty, avg_price: avgPrice, sell_price: sellPrice, commission,
|
||||||
|
buy_amount: buyAmount, sell_amount: sellAmount,
|
||||||
|
realized_profit: realizedProfit, realized_rate: realizedRate,
|
||||||
|
sold_at: sellForm.sold_at ? new Date(sellForm.sold_at).toISOString() : new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (sellEditId != null) {
|
||||||
|
const updated = await updateSellHistory(sellEditId, payload);
|
||||||
|
setSellHistory((prev) =>
|
||||||
|
prev.map((r) => (r.id === sellEditId ? (updated ?? { ...payload, id: sellEditId }) : r))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const saved = await addSellHistory(payload);
|
||||||
|
setSellHistory((prev) => [saved ?? { ...payload, id: Date.now() }, ...prev]);
|
||||||
|
}
|
||||||
|
handleSellFormClose();
|
||||||
|
} catch (err) {
|
||||||
|
setSellFormError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setSellFormSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
sellHistory, sellHistoryLoading, loadSellHistory, addSellRecord,
|
||||||
|
sellHistoryBroker, setSellHistoryBroker,
|
||||||
|
sellHistoryPeriod, setSellHistoryPeriod,
|
||||||
|
sellDrawerOpen, setSellDrawerOpen,
|
||||||
|
sellFormOpen, sellEditId, sellForm, setSellForm,
|
||||||
|
sellFormSaving, sellFormError,
|
||||||
|
handleDeleteSellRecord,
|
||||||
|
handleSellFormOpen, handleSellEditStart, handleSellFormClose, handleSellFormSubmit,
|
||||||
|
};
|
||||||
|
}
|
||||||
125
src/pages/stock/stockUtils.js
Normal file
125
src/pages/stock/stockUtils.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/* ── helpers ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export const formatNumber = (value) => {
|
||||||
|
if (value === null || value === undefined || value === '') return '-';
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (Number.isNaN(numeric)) return value;
|
||||||
|
return new Intl.NumberFormat('ko-KR').format(numeric);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatPercent = (value) => {
|
||||||
|
if (value === null || value === undefined || value === '') return '-';
|
||||||
|
if (typeof value === 'string' && value.includes('%')) return value;
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (Number.isNaN(numeric)) return value;
|
||||||
|
return `${numeric >= 0 ? '+' : ''}${numeric.toFixed(2)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pickFirst = (...values) =>
|
||||||
|
values.find((value) => value !== undefined && value !== null && value !== '');
|
||||||
|
|
||||||
|
export const getQty = (item) =>
|
||||||
|
pickFirst(item?.qty, item?.quantity, item?.holding, item?.hold_qty);
|
||||||
|
|
||||||
|
export const getBuyPrice = (item) =>
|
||||||
|
pickFirst(
|
||||||
|
item?.buy_price,
|
||||||
|
item?.avg_price,
|
||||||
|
item?.avg,
|
||||||
|
item?.purchase_price,
|
||||||
|
item?.buyPrice,
|
||||||
|
item?.price
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getCurrentPrice = (item) =>
|
||||||
|
pickFirst(
|
||||||
|
item?.current_price,
|
||||||
|
item?.current,
|
||||||
|
item?.cur_price,
|
||||||
|
item?.now_price,
|
||||||
|
item?.market_price
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getProfitRate = (item) =>
|
||||||
|
pickFirst(
|
||||||
|
item?.profit_rate,
|
||||||
|
item?.profitRate,
|
||||||
|
item?.profit_pct,
|
||||||
|
item?.profitPercent,
|
||||||
|
item?.pnl_rate,
|
||||||
|
item?.return_rate,
|
||||||
|
item?.yield
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getProfitLoss = (item) =>
|
||||||
|
pickFirst(item?.profit_loss, item?.pnl, item?.profitLoss);
|
||||||
|
|
||||||
|
export const toNumeric = (value) => {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const numeric = Number(String(value).replace(/[^0-9.-]/g, ''));
|
||||||
|
return Number.isNaN(numeric) ? null : numeric;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Chart colors ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export const CHART_COLORS = ['#818cf8', '#fbbf24', '#34d399', '#f472b6', '#fb923c', '#a78bfa', '#38bdf8', '#4ade80'];
|
||||||
|
|
||||||
|
export const profitColorClass = (numericValue) => {
|
||||||
|
if (numericValue > 0) return 'is-up';
|
||||||
|
if (numericValue < 0) return 'is-down';
|
||||||
|
if (numericValue === 0) return 'is-flat';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getVixLabel = (vix) => {
|
||||||
|
if (vix < 12) return '극히 낮음 (안일 주의)';
|
||||||
|
if (vix < 20) return '정상 (안정적)';
|
||||||
|
if (vix < 30) return '주의 (불확실성 증가)';
|
||||||
|
if (vix < 40) return '높음 (극도의 공포)';
|
||||||
|
return '극단 (패닉)';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFgLabel = (score) => {
|
||||||
|
if (score <= 25) return '극단적 공포';
|
||||||
|
if (score <= 45) return '공포';
|
||||||
|
if (score <= 55) return '중립';
|
||||||
|
if (score <= 75) return '탐욕';
|
||||||
|
return '극단적 탐욕';
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── empty portfolio form ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export const emptyPortfolioForm = {
|
||||||
|
broker: '',
|
||||||
|
ticker: '',
|
||||||
|
name: '',
|
||||||
|
quantity: '',
|
||||||
|
avg_price: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── empty sell-history form ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
export const toLocalDatetimeValue = (isoStr) => {
|
||||||
|
if (!isoStr) return '';
|
||||||
|
const d = new Date(isoStr);
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptySellForm = () => ({
|
||||||
|
broker: '',
|
||||||
|
ticker: '',
|
||||||
|
name: '',
|
||||||
|
quantity: '',
|
||||||
|
avg_price: '',
|
||||||
|
sell_price: '',
|
||||||
|
commission: '',
|
||||||
|
sold_at: toLocalDatetimeValue(new Date().toISOString()),
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── TAB IDs ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export const TAB_PORTFOLIO = 'portfolio';
|
||||||
|
export const TAB_AI = 'ai';
|
||||||
|
export const TAB_REPORT = 'report';
|
||||||
|
export const TAB_ADVISOR = 'advisor';
|
||||||
1167
src/pages/subscription/Subscription.css
Normal file
1167
src/pages/subscription/Subscription.css
Normal file
File diff suppressed because it is too large
Load Diff
1319
src/pages/subscription/Subscription.jsx
Normal file
1319
src/pages/subscription/Subscription.jsx
Normal file
File diff suppressed because it is too large
Load Diff
408
src/pages/todo/Todo.css
Normal file
408
src/pages/todo/Todo.css
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
/* ═══════════════════════════════════════════════════════════════════════
|
||||||
|
Todo Page — Cyberpunk Kanban Board
|
||||||
|
═══════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.todo-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toolbar ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.todo-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Add Form ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.todo-form {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 20px;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
animation: fadeIn 0.2s ease both;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-form__field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-form__field input,
|
||||||
|
.todo-form__field textarea {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
color: var(--text-bright);
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
resize: vertical;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-form__field input:focus,
|
||||||
|
.todo-form__field textarea:focus {
|
||||||
|
border-color: rgba(244, 114, 182, 0.5);
|
||||||
|
box-shadow: 0 0 0 2px rgba(244, 114, 182, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-form__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Error / Loading ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.todo-error {
|
||||||
|
margin: 0;
|
||||||
|
color: #f9b6b1;
|
||||||
|
border: 1px solid rgba(249, 182, 177, 0.4);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(249, 182, 177, 0.08);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-loading {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Board ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.todo-board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Column ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.todo-col {
|
||||||
|
background: rgba(10, 18, 45, 0.6);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
min-height: 200px;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-col.is-drag-over {
|
||||||
|
border-color: rgba(244, 114, 182, 0.4);
|
||||||
|
background: rgba(244, 114, 182, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-col__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-col__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-bright);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-col__count {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(244, 114, 182, 0.12);
|
||||||
|
border: 1px solid rgba(244, 114, 182, 0.25);
|
||||||
|
color: #f472b6;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-col__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-col__empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.todo-card {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: grab;
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
border-color 0.2s ease,
|
||||||
|
opacity 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-card:hover {
|
||||||
|
border-color: rgba(244, 114, 182, 0.25);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-card.is-dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-card__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-bright);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-card__desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.6;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-card__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-card__date {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-card__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-card__btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-card__btn:hover {
|
||||||
|
background: rgba(244, 114, 182, 0.15);
|
||||||
|
border-color: rgba(244, 114, 182, 0.4);
|
||||||
|
color: #f472b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-card__btn--danger:hover {
|
||||||
|
background: rgba(249, 182, 177, 0.15);
|
||||||
|
border-color: rgba(249, 182, 177, 0.4);
|
||||||
|
color: #f9b6b1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Done Panel ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.todo-done-panel {
|
||||||
|
background: rgba(10, 18, 45, 0.6);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-done-panel.is-drag-over {
|
||||||
|
border-color: rgba(244, 114, 182, 0.4);
|
||||||
|
background: rgba(244, 114, 182, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-done-panel__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-done-panel__title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-done-panel__total-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 날짜 필터 ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.todo-done-panel__filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-date-btn {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-date-btn:hover {
|
||||||
|
background: rgba(244, 114, 182, 0.1);
|
||||||
|
border-color: rgba(244, 114, 182, 0.3);
|
||||||
|
color: #f472b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-date-btn.is-active {
|
||||||
|
background: rgba(244, 114, 182, 0.18);
|
||||||
|
border-color: rgba(244, 114, 182, 0.5);
|
||||||
|
color: #f472b6;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-date-input {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-date-input:focus {
|
||||||
|
border-color: rgba(244, 114, 182, 0.4);
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 완료 패널 카드 그리드 */
|
||||||
|
.todo-done-panel__body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-done-panel__body .todo-col__empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-done-panel__body .todo-card {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-done-panel__body .todo-card:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-done-panel__body .todo-card__title {
|
||||||
|
text-decoration: line-through;
|
||||||
|
text-decoration-color: rgba(244, 114, 182, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.todo-board {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-col {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-done-panel__head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-done-panel__filter {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-done-panel__body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.todo-toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-toolbar .button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
332
src/pages/todo/Todo.jsx
Normal file
332
src/pages/todo/Todo.jsx
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { getTodos, addTodo, updateTodo, deleteTodo, clearTodos } from '../../api';
|
||||||
|
import './Todo.css';
|
||||||
|
|
||||||
|
const ACTIVE_COLUMNS = [
|
||||||
|
{ id: 'todo', label: '할 일' },
|
||||||
|
{ id: 'in_progress', label: '진행 중' },
|
||||||
|
];
|
||||||
|
const ALL_COLUMNS = [
|
||||||
|
...ACTIVE_COLUMNS,
|
||||||
|
{ id: 'done', label: '완료' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const emptyForm = { title: '', description: '' };
|
||||||
|
|
||||||
|
const toDateStr = (iso) => {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso).toISOString().slice(0, 10); // YYYY-MM-DD
|
||||||
|
};
|
||||||
|
|
||||||
|
const Todo = () => {
|
||||||
|
const [todos, setTodos] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [dragging, setDragging] = useState(null);
|
||||||
|
const [dragOver, setDragOver] = useState(null);
|
||||||
|
const [doneDate, setDoneDate] = useState(''); // '' = 전체
|
||||||
|
const dragItem = useRef(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await getTodos();
|
||||||
|
setTodos(Array.isArray(data) ? data : []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const handleAdd = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.title.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const created = await addTodo({ ...form, status: 'todo' });
|
||||||
|
setTodos((prev) => [created, ...prev]);
|
||||||
|
setForm(emptyForm);
|
||||||
|
setFormOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMove = async (id, newStatus) => {
|
||||||
|
setTodos((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === id
|
||||||
|
? { ...t, status: newStatus, updated_at: new Date().toISOString() }
|
||||||
|
: t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await updateTodo(id, { status: newStatus, updated_at: new Date().toISOString() });
|
||||||
|
} catch {
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
setTodos((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
try {
|
||||||
|
await deleteTodo(id);
|
||||||
|
} catch {
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = async () => {
|
||||||
|
try {
|
||||||
|
await clearTodos();
|
||||||
|
setTodos((prev) => prev.filter((t) => t.status !== 'done'));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err?.message ?? String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Drag & Drop ─────────────────────────────────────────────── */
|
||||||
|
const onDragStart = (e, todo) => {
|
||||||
|
dragItem.current = todo;
|
||||||
|
setDragging(todo.id);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragEnd = () => {
|
||||||
|
setDragging(null);
|
||||||
|
setDragOver(null);
|
||||||
|
dragItem.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragOver = (e, colId) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
setDragOver(colId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = (e, colId) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragItem.current && dragItem.current.status !== colId) {
|
||||||
|
handleMove(dragItem.current.id, colId);
|
||||||
|
}
|
||||||
|
setDragOver(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const byStatus = (status) => todos.filter((t) => t.status === status);
|
||||||
|
|
||||||
|
/* ── 완료 필터 ─────────────────────────────────────────────────── */
|
||||||
|
const doneTodos = todos.filter((t) => {
|
||||||
|
if (t.status !== 'done') return false;
|
||||||
|
if (!doneDate) return true;
|
||||||
|
const ref = t.updated_at || t.created_at;
|
||||||
|
return toDateStr(ref) === doneDate;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 완료 항목에서 날짜 목록 추출 (필터 select용) */
|
||||||
|
const doneDates = [...new Set(
|
||||||
|
todos
|
||||||
|
.filter((t) => t.status === 'done')
|
||||||
|
.map((t) => toDateStr(t.updated_at || t.created_at))
|
||||||
|
.filter(Boolean)
|
||||||
|
)].sort((a, b) => b.localeCompare(a)); // 최신순
|
||||||
|
|
||||||
|
const formatDate = (iso) => {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCard = (todo, colId) => (
|
||||||
|
<div
|
||||||
|
key={todo.id}
|
||||||
|
className={`todo-card${dragging === todo.id ? ' is-dragging' : ''}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => onDragStart(e, todo)}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
>
|
||||||
|
<p className="todo-card__title">{todo.title}</p>
|
||||||
|
{todo.description && (
|
||||||
|
<p className="todo-card__desc">{todo.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="todo-card__footer">
|
||||||
|
<span className="todo-card__date">
|
||||||
|
{formatDate(todo.created_at)}
|
||||||
|
</span>
|
||||||
|
<div className="todo-card__actions">
|
||||||
|
{ALL_COLUMNS.filter((c) => c.id !== colId).map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
className="todo-card__btn"
|
||||||
|
title={`${c.label}으로 이동`}
|
||||||
|
onClick={() => handleMove(todo.id, c.id)}
|
||||||
|
>
|
||||||
|
{c.id === 'todo' ? '↩' : c.id === 'in_progress' ? '▶' : '✓'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="todo-card__btn todo-card__btn--danger"
|
||||||
|
title="삭제"
|
||||||
|
onClick={() => handleDelete(todo.id)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="todo-page">
|
||||||
|
{/* 툴바 */}
|
||||||
|
<div className="todo-toolbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button primary"
|
||||||
|
onClick={() => setFormOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{formOpen ? '취소' : '+ 태스크 추가'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button ghost"
|
||||||
|
onClick={handleClear}
|
||||||
|
>
|
||||||
|
완료 비우기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 추가 폼 */}
|
||||||
|
{formOpen && (
|
||||||
|
<form className="todo-form" onSubmit={handleAdd}>
|
||||||
|
<label className="todo-form__field">
|
||||||
|
<span>제목 *</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="태스크 제목을 입력하세요"
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => setForm((v) => ({ ...v, title: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="todo-form__field">
|
||||||
|
<span>설명</span>
|
||||||
|
<textarea
|
||||||
|
placeholder="설명 (선택)"
|
||||||
|
value={form.description}
|
||||||
|
rows={3}
|
||||||
|
onChange={(e) => setForm((v) => ({ ...v, description: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="todo-form__actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="button primary"
|
||||||
|
disabled={saving || !form.title.trim()}
|
||||||
|
>
|
||||||
|
{saving ? '저장 중...' : '추가'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="todo-error">{error}</p>}
|
||||||
|
{loading && todos.length === 0 && <p className="todo-loading">불러오는 중...</p>}
|
||||||
|
|
||||||
|
{/* 활성 보드 (할 일 + 진행 중) */}
|
||||||
|
<div className="todo-board">
|
||||||
|
{ACTIVE_COLUMNS.map((col) => {
|
||||||
|
const items = byStatus(col.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.id}
|
||||||
|
className={`todo-col${dragOver === col.id ? ' is-drag-over' : ''}`}
|
||||||
|
onDragOver={(e) => onDragOver(e, col.id)}
|
||||||
|
onDrop={(e) => onDrop(e, col.id)}
|
||||||
|
>
|
||||||
|
<div className="todo-col__head">
|
||||||
|
<span className="todo-col__title">{col.label}</span>
|
||||||
|
<span className="todo-col__count">{items.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="todo-col__body">
|
||||||
|
{items.length === 0 && (
|
||||||
|
<p className="todo-col__empty">드래그하여 이동</p>
|
||||||
|
)}
|
||||||
|
{items.map((todo) => renderCard(todo, col.id))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 완료 패널 */}
|
||||||
|
<div
|
||||||
|
className={`todo-done-panel${dragOver === 'done' ? ' is-drag-over' : ''}`}
|
||||||
|
onDragOver={(e) => onDragOver(e, 'done')}
|
||||||
|
onDrop={(e) => onDrop(e, 'done')}
|
||||||
|
>
|
||||||
|
{/* 완료 패널 헤더 */}
|
||||||
|
<div className="todo-done-panel__head">
|
||||||
|
<div className="todo-done-panel__title-row">
|
||||||
|
<span className="todo-col__title">완료</span>
|
||||||
|
<span className="todo-col__count">{doneTodos.length}</span>
|
||||||
|
{doneDates.length > 0 && doneDate === '' && (
|
||||||
|
<span className="todo-done-panel__total-hint">
|
||||||
|
전체 {todos.filter(t => t.status === 'done').length}건
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 날짜 필터 */}
|
||||||
|
<div className="todo-done-panel__filter">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`todo-date-btn${doneDate === '' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setDoneDate('')}
|
||||||
|
>
|
||||||
|
전체
|
||||||
|
</button>
|
||||||
|
{doneDates.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
type="button"
|
||||||
|
className={`todo-date-btn${doneDate === d ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setDoneDate(d)}
|
||||||
|
>
|
||||||
|
{new Date(d + 'T00:00:00').toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="todo-date-input"
|
||||||
|
value={doneDate}
|
||||||
|
onChange={(e) => setDoneDate(e.target.value)}
|
||||||
|
title="날짜 직접 선택"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 완료 카드 그리드 */}
|
||||||
|
<div className="todo-done-panel__body">
|
||||||
|
{doneTodos.length === 0 ? (
|
||||||
|
<p className="todo-col__empty">
|
||||||
|
{doneDate ? '해당 날짜에 완료된 항목이 없습니다' : '드래그하여 이동'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
doneTodos.map((todo) => renderCard(todo, 'done'))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Todo;
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
109
src/routes.jsx
109
src/routes.jsx
@@ -1,4 +1,16 @@
|
|||||||
import React, { lazy } from 'react';
|
import React, { lazy } from 'react';
|
||||||
|
import {
|
||||||
|
IconHome,
|
||||||
|
IconBlog,
|
||||||
|
IconLotto,
|
||||||
|
IconStock,
|
||||||
|
IconBuilding,
|
||||||
|
IconTravel,
|
||||||
|
IconMusic,
|
||||||
|
IconLab,
|
||||||
|
IconTodo,
|
||||||
|
IconBlogMarketing,
|
||||||
|
} from './components/Icons';
|
||||||
|
|
||||||
const Home = lazy(() => import('./pages/home/Home'));
|
const Home = lazy(() => import('./pages/home/Home'));
|
||||||
const Blog = lazy(() => import('./pages/blog/Blog'));
|
const Blog = lazy(() => import('./pages/blog/Blog'));
|
||||||
@@ -6,44 +18,113 @@ const Lotto = lazy(() => import('./pages/lotto/Lotto'));
|
|||||||
const Travel = lazy(() => import('./pages/travel/Travel'));
|
const Travel = lazy(() => import('./pages/travel/Travel'));
|
||||||
const Stock = lazy(() => import('./pages/stock/Stock'));
|
const Stock = lazy(() => import('./pages/stock/Stock'));
|
||||||
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
|
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
|
||||||
|
const Subscription = lazy(() => import('./pages/subscription/Subscription'));
|
||||||
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
|
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
|
||||||
|
const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream'));
|
||||||
|
const DayCalc = lazy(() => import('./pages/effect-lab/DayCalc'));
|
||||||
|
const Todo = lazy(() => import('./pages/todo/Todo'));
|
||||||
|
const MusicStudio = lazy(() => import('./pages/music/MusicStudio'));
|
||||||
|
const BlogMarketing = lazy(() => import('./pages/blog-marketing/BlogMarketing'));
|
||||||
|
|
||||||
export const navLinks = [
|
export const navLinks = [
|
||||||
{
|
{
|
||||||
id: 'home',
|
id: 'home',
|
||||||
label: 'Home',
|
label: 'Home',
|
||||||
path: '/',
|
path: '/',
|
||||||
|
subtitle: 'PERSONAL ARCHIVE',
|
||||||
description: '첫 인상과 최신 업데이트를 모아둔 허브',
|
description: '첫 인상과 최신 업데이트를 모아둔 허브',
|
||||||
|
icon: <IconHome />,
|
||||||
|
accent: '#f7a8a5',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'blog',
|
id: 'blog',
|
||||||
label: 'Blog',
|
label: 'Blog',
|
||||||
path: '/blog',
|
path: '/blog',
|
||||||
|
subtitle: 'JOURNAL',
|
||||||
description: '생각과 기록, 코드 스니펫을 모으는 공간',
|
description: '생각과 기록, 코드 스니펫을 모으는 공간',
|
||||||
|
icon: <IconBlog />,
|
||||||
|
accent: '#c084fc',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'lotto',
|
id: 'lotto',
|
||||||
label: 'Lotto',
|
label: 'Lotto',
|
||||||
path: '/lotto',
|
path: '/lotto',
|
||||||
|
subtitle: 'PLAYGROUND',
|
||||||
description: '숫자를 뽑고 통계를 확인하는 실험실',
|
description: '숫자를 뽑고 통계를 확인하는 실험실',
|
||||||
|
icon: <IconLotto />,
|
||||||
|
accent: '#34d399',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'stock',
|
id: 'stock',
|
||||||
label: 'Stock',
|
label: 'Stock',
|
||||||
path: '/stock',
|
path: '/stock',
|
||||||
|
subtitle: '마켓 랩',
|
||||||
description: '아침 시장 흐름을 확인하는 주식 연구실',
|
description: '아침 시장 흐름을 확인하는 주식 연구실',
|
||||||
|
icon: <IconStock />,
|
||||||
|
accent: '#60a5fa',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'realestate',
|
||||||
|
label: 'Realestate',
|
||||||
|
path: '/realestate',
|
||||||
|
subtitle: '부동산',
|
||||||
|
description: '청약 공고 자동 수집, 매칭, 프로필 기반 자격 분석',
|
||||||
|
icon: <IconBuilding />,
|
||||||
|
accent: '#f43f5e',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'travel',
|
id: 'travel',
|
||||||
label: 'Travel',
|
label: 'Travel',
|
||||||
path: '/travel',
|
path: '/travel',
|
||||||
|
subtitle: 'VISUAL DIARY',
|
||||||
description: '여행에서 담은 색과 장면을 전시하는 갤러리',
|
description: '여행에서 담은 색과 장면을 전시하는 갤러리',
|
||||||
|
icon: <IconTravel />,
|
||||||
|
accent: '#fb923c',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'music',
|
||||||
|
label: 'Music',
|
||||||
|
path: '/music',
|
||||||
|
subtitle: 'SONIC FORGE',
|
||||||
|
description: 'AI로 세상에 하나뿐인 음악을 만드는 스튜디오',
|
||||||
|
icon: <IconMusic />,
|
||||||
|
accent: '#f5a623',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'blog-lab',
|
||||||
|
label: 'Blog Lab',
|
||||||
|
path: '/blog-lab',
|
||||||
|
subtitle: 'MONETIZE',
|
||||||
|
description: 'AI 블로그 마케팅으로 수익을 만드는 연구소',
|
||||||
|
icon: <IconBlogMarketing />,
|
||||||
|
accent: '#10b981',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'lab',
|
id: 'lab',
|
||||||
label: 'Lab',
|
label: 'Lab',
|
||||||
path: '/lab',
|
path: '/lab',
|
||||||
|
subtitle: 'STREAM',
|
||||||
description: '실험적인 UI/UX 효과를 테스트하는 공간',
|
description: '실험적인 UI/UX 효과를 테스트하는 공간',
|
||||||
|
icon: <IconLab />,
|
||||||
|
accent: '#fbbf24',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'todo',
|
||||||
|
label: 'Todo',
|
||||||
|
path: '/todo',
|
||||||
|
subtitle: 'TASK BOARD',
|
||||||
|
description: '할 일을 관리하는 태스크 보드',
|
||||||
|
icon: <IconTodo />,
|
||||||
|
accent: '#f472b6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'agent-office',
|
||||||
|
label: 'Agent Office',
|
||||||
|
path: '/agent-office',
|
||||||
|
subtitle: 'AI LAB',
|
||||||
|
description: 'AI 에이전트 사무실',
|
||||||
|
icon: <span style={{fontSize:'1.2em'}}>🏢</span>,
|
||||||
|
accent: '#8b5cf6',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -68,6 +149,10 @@ export const appRoutes = [
|
|||||||
path: 'stock/trade',
|
path: 'stock/trade',
|
||||||
element: <StockTrade />,
|
element: <StockTrade />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'realestate',
|
||||||
|
element: <Subscription />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'travel',
|
path: 'travel',
|
||||||
element: <Travel />,
|
element: <Travel />,
|
||||||
@@ -76,4 +161,28 @@ export const appRoutes = [
|
|||||||
path: 'lab',
|
path: 'lab',
|
||||||
element: <EffectLab />,
|
element: <EffectLab />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'lab/sword-stream',
|
||||||
|
element: <SwordStream />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'lab/day-calc',
|
||||||
|
element: <DayCalc />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'music',
|
||||||
|
element: <MusicStudio />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'blog-lab',
|
||||||
|
element: <BlogMarketing />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'todo',
|
||||||
|
element: <Todo />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'agent-office',
|
||||||
|
lazy: () => import('./pages/agent-office/AgentOffice'),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,4 +4,82 @@ import react from '@vitejs/plugin-react'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 3007,
|
||||||
|
strictPort: false,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'https://gahusb.synology.me',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
},
|
||||||
|
// 여행 사진 미디어 파일 (/media/travel/...)
|
||||||
|
'/media': {
|
||||||
|
target: 'https://gahusb.synology.me',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
},
|
||||||
|
// Fear & Greed Index (CNN 공개 API)
|
||||||
|
// 프로덕션 nginx에서는 아래 proxy_pass 추가 필요:
|
||||||
|
// location /ext/feargreed {
|
||||||
|
// proxy_pass https://production.dataviz.cnn.io/index/fearandgreed/graphdata;
|
||||||
|
// proxy_set_header Host production.dataviz.cnn.io;
|
||||||
|
// }
|
||||||
|
'/ext/feargreed': {
|
||||||
|
target: 'https://production.dataviz.cnn.io',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
rewrite: () => '/index/fearandgreed/graphdata',
|
||||||
|
},
|
||||||
|
// VIX (CBOE 변동성 지수) — Yahoo Finance 공개 API
|
||||||
|
// 프로덕션 nginx에서는 아래 proxy_pass 추가 필요:
|
||||||
|
// location /ext/vix {
|
||||||
|
// proxy_pass https://query1.finance.yahoo.com/v8/finance/chart/%5EVIX?interval=1d&range=1d;
|
||||||
|
// proxy_set_header Host query1.finance.yahoo.com;
|
||||||
|
// }
|
||||||
|
'/ext/vix': {
|
||||||
|
target: 'https://query1.finance.yahoo.com',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
rewrite: () => '/v8/finance/chart/%5EVIX?interval=1d&range=1d',
|
||||||
|
},
|
||||||
|
// 미국 10년물 국채 금리 (^TNX) — Yahoo Finance
|
||||||
|
// 프로덕션 nginx 설정 필요:
|
||||||
|
// location /ext/treasury {
|
||||||
|
// proxy_pass https://query1.finance.yahoo.com/v8/finance/chart/%5ETNX?interval=1d&range=1d;
|
||||||
|
// proxy_set_header Host query1.finance.yahoo.com;
|
||||||
|
// }
|
||||||
|
'/ext/treasury': {
|
||||||
|
target: 'https://query1.finance.yahoo.com',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
rewrite: () => '/v8/finance/chart/%5ETNX?interval=1d&range=1d',
|
||||||
|
},
|
||||||
|
// WTI 원유 선물 (CL=F) — Yahoo Finance
|
||||||
|
// 프로덕션 nginx 설정 필요:
|
||||||
|
// location /ext/wti {
|
||||||
|
// proxy_pass https://query1.finance.yahoo.com/v8/finance/chart/CL%3DF?interval=1d&range=1d;
|
||||||
|
// proxy_set_header Host query1.finance.yahoo.com;
|
||||||
|
// }
|
||||||
|
'/ext/wti': {
|
||||||
|
target: 'https://query1.finance.yahoo.com',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
rewrite: () => '/v8/finance/chart/CL%3DF?interval=1d&range=1d',
|
||||||
|
},
|
||||||
|
// Brent 원유 선물 (BZ=F) — Yahoo Finance
|
||||||
|
// 프로덕션 nginx 설정 필요:
|
||||||
|
// location /ext/brent {
|
||||||
|
// proxy_pass https://query1.finance.yahoo.com/v8/finance/chart/BZ%3DF?interval=1d&range=1d;
|
||||||
|
// proxy_set_header Host query1.finance.yahoo.com;
|
||||||
|
// }
|
||||||
|
'/ext/brent': {
|
||||||
|
target: 'https://query1.finance.yahoo.com',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
rewrite: () => '/v8/finance/chart/BZ%3DF?interval=1d&range=1d',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user