UI 디자인 대대적으로 대시보드 형태의 전문적인 느낌으로 재구성
This commit is contained in:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm run:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
149
CLAUDE.md
Normal file
149
CLAUDE.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# 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` | 주식 트레이딩 |
|
||||||
|
| `/travel` | `Travel` | 여행 사진 갤러리 |
|
||||||
|
| `/lab` | `EffectLab` | UI/UX 실험 |
|
||||||
|
|
||||||
|
라우트 정의: `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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
개발 중 `/api/*` 요청은 NAS 백엔드로 프록시됨. 개발 서버 포트: **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/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` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 스펙 등) |
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
12
src/App.jsx
12
src/App.jsx
@@ -8,11 +8,13 @@ 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 />
|
<React.Suspense fallback={<div className="suspend-loading"><Loading /></div>}>
|
||||||
</React.Suspense>
|
<Outlet />
|
||||||
</main>
|
</React.Suspense>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/api.js
19
src/api.js
@@ -136,3 +136,22 @@ export function updatePortfolio(id, fields) {
|
|||||||
export function deletePortfolio(id) {
|
export function deletePortfolio(id) {
|
||||||
return apiDelete(`/api/portfolio/${id}`);
|
return apiDelete(`/api/portfolio/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 예수금 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();
|
||||||
|
}
|
||||||
|
|||||||
61
src/components/Icons.jsx
Normal file
61
src/components/Icons.jsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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 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" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
@@ -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,357 @@
|
|||||||
.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: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 구분선 ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.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">Dashboard</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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
236
src/index.css
236
src/index.css
@@ -1,35 +1,241 @@
|
|||||||
@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-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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-blog);
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,29 +98,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 18px;
|
border-radius: var(--radius-md);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
transition: border-color 0.2s ease;
|
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
box-shadow: var(--shadow-inset);
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-list__item:hover {
|
.blog-list__item:hover {
|
||||||
border-color: rgba(255, 255, 255, 0.25);
|
border-color: var(--line-strong);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: var(--shadow-sm), var(--shadow-inset);
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-list__item.is-active {
|
.blog-list__item.is-active {
|
||||||
border-color: rgba(247, 168, 165, 0.6);
|
border-color: rgba(192, 132, 252, 0.5);
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 4px 20px rgba(192, 132, 252, 0.12), var(--shadow-inset);
|
||||||
|
background: rgba(192, 132, 252, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-pagination {
|
.blog-pagination {
|
||||||
@@ -168,14 +173,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 +283,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,96 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 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 +405,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 +447,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 +459,124 @@
|
|||||||
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-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 +586,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const 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,7 +28,7 @@ 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>느린 기록, 깊은 회고</h2>
|
||||||
<p>
|
<p>
|
||||||
@@ -37,13 +37,13 @@ const Home = () => {
|
|||||||
</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">{posts.length}<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">이번 주말</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,12 +56,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,8 +86,11 @@ 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>
|
||||||
))}
|
))}
|
||||||
@@ -110,31 +124,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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -16,7 +16,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-stock);
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,10 +134,11 @@
|
|||||||
.stock-panel {
|
.stock-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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stock-panel--wide {
|
.stock-panel--wide {
|
||||||
@@ -169,7 +170,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-stock);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stock-panel__sub {
|
.stock-panel__sub {
|
||||||
@@ -211,9 +212,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stock-snapshot__card.is-highlight {
|
.stock-snapshot__card.is-highlight {
|
||||||
border-color: rgba(255, 255, 255, 0.4);
|
border-color: rgba(96, 165, 250, 0.4);
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(96, 165, 250, 0.06);
|
||||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08);
|
box-shadow: 0 0 0 1px rgba(96, 165, 250, 0.1), var(--shadow-inset);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stock-snapshot__card p {
|
.stock-snapshot__card p {
|
||||||
@@ -305,8 +306,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stock-tab.is-active {
|
.stock-tab.is-active {
|
||||||
border-color: rgba(255, 255, 255, 0.5);
|
border-color: rgba(96, 165, 250, 0.5);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
background: rgba(96, 165, 250, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stock-news__item {
|
.stock-news__item {
|
||||||
@@ -340,7 +342,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stock-news__meta a {
|
.stock-news__meta a {
|
||||||
color: var(--accent);
|
color: var(--accent-stock);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-news__meta a:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stock-empty {
|
.stock-empty {
|
||||||
@@ -918,4 +927,526 @@
|
|||||||
.pf-total-summary__card strong {
|
.pf-total-summary__card strong {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cash Panel (예수금) ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pf-cash-table {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-cash-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-cash-broker {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-cash-amount {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-cash-date {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-cash-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: end;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-cash-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-cash-form input {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-cash-form input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-cash-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(147, 197, 253, 0.15);
|
||||||
|
border: 1px solid rgba(147, 197, 253, 0.3);
|
||||||
|
color: #93c5fd;
|
||||||
|
margin-left: 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-total-summary__card.is-cash {
|
||||||
|
border-color: rgba(147, 197, 253, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-total-summary__card.is-cash strong {
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-total-summary__card.is-assets {
|
||||||
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-total-summary__card.is-assets strong {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pf-cash-form {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.pf-cash-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-cash-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-cash-date {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════════
|
||||||
|
Fear & Greed Index Panel
|
||||||
|
══════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.fg-panel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 28px;
|
||||||
|
padding: 16px 0 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fg-gauge {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fg-gauge__track {
|
||||||
|
position: relative;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: linear-gradient(to right, #ef4444 0%, #f97316 25%, #eab308 50%, #84cc16 75%, #22c55e 100%);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fg-gauge__needle {
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 5px;
|
||||||
|
height: 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 0 8px rgba(0, 0, 0, 0.6);
|
||||||
|
transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fg-gauge__labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fg-score-display {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 90px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fg-score-number {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fg-score-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: color 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fg-score-date {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════════
|
||||||
|
Report Charts Row
|
||||||
|
══════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.report-charts-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-chart-box {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-chart-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.report-charts-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════════
|
||||||
|
Report Table (Sortable)
|
||||||
|
══════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.report-table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table thead {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table th:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-sort-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--text);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table tbody tr:hover td {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-td-muted {
|
||||||
|
color: var(--muted) !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table-name {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table-code {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-rate-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-rate-bar {
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-rate-bar__fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-rate-bar__fill.is-up {
|
||||||
|
background: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-rate-bar__fill.is-down {
|
||||||
|
background: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════════
|
||||||
|
AI 투자 코치 패널
|
||||||
|
══════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.ai-coach-settings {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 12px 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-coach-settings label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-coach-settings input,
|
||||||
|
.ai-coach-settings select {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-coach-settings input:focus,
|
||||||
|
.ai-coach-settings select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-coach-key-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-coach-key-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-coach-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-coach-note {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Result */
|
||||||
|
.ai-coach-result {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-top: 20px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-coach-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-grade-badge {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 800;
|
||||||
|
flex-shrink: 0;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-grade-badge.grade-s { background: linear-gradient(135deg, #7c3aed, #4f46e5); color: #fff; }
|
||||||
|
.ai-grade-badge.grade-a { background: linear-gradient(135deg, #059669, #10b981); color: #fff; }
|
||||||
|
.ai-grade-badge.grade-b { background: linear-gradient(135deg, #0284c7, #38bdf8); color: #fff; }
|
||||||
|
.ai-grade-badge.grade-c { background: linear-gradient(135deg, #d97706, #fbbf24); color: #fff; }
|
||||||
|
.ai-grade-badge.grade-d { background: linear-gradient(135deg, #dc2626, #f87171); color: #fff; }
|
||||||
|
|
||||||
|
.ai-score-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-score-num {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-score-unit {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-summary-text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-evaluation-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.7;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-advice-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-advice-card {
|
||||||
|
background: rgba(129, 140, 248, 0.07);
|
||||||
|
border: 1px solid rgba(129, 140, 248, 0.2);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-advice-card:hover {
|
||||||
|
background: rgba(129, 140, 248, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-advice-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #a5b4fc;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-advice-body {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.ai-coach-settings {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-advice-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-grade-badge {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-score-num {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.fg-gauge__labels span:nth-child(2),
|
||||||
|
.fg-gauge__labels span:nth-child(4) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,9 +7,17 @@ import {
|
|||||||
addPortfolio,
|
addPortfolio,
|
||||||
updatePortfolio,
|
updatePortfolio,
|
||||||
deletePortfolio,
|
deletePortfolio,
|
||||||
|
upsertCash,
|
||||||
|
deleteCash,
|
||||||
|
getFearAndGreed,
|
||||||
} from '../../api';
|
} from '../../api';
|
||||||
import Loading from '../../components/Loading';
|
import Loading from '../../components/Loading';
|
||||||
import './Stock.css';
|
import './Stock.css';
|
||||||
|
import {
|
||||||
|
PieChart, Pie, Cell,
|
||||||
|
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||||
|
Tooltip as ChartTooltip, Legend, ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
/* ── helpers ─────────────────────────────────────────────────────── */
|
/* ── helpers ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -73,6 +81,28 @@ const toNumeric = (value) => {
|
|||||||
return Number.isNaN(numeric) ? null : numeric;
|
return Number.isNaN(numeric) ? null : numeric;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ── Fear & Greed helpers ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
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';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFgLabel = (score) => {
|
||||||
|
if (score <= 25) return '극단적 공포';
|
||||||
|
if (score <= 45) return '공포';
|
||||||
|
if (score <= 55) return '중립';
|
||||||
|
if (score <= 75) return '탐욕';
|
||||||
|
return '극단적 탐욕';
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Chart colors ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const CHART_COLORS = ['#818cf8', '#fbbf24', '#34d399', '#f472b6', '#fb923c', '#a78bfa', '#38bdf8', '#4ade80'];
|
||||||
|
|
||||||
const profitColorClass = (numericValue) => {
|
const profitColorClass = (numericValue) => {
|
||||||
if (numericValue > 0) return 'is-up';
|
if (numericValue > 0) return 'is-up';
|
||||||
if (numericValue < 0) return 'is-down';
|
if (numericValue < 0) return 'is-down';
|
||||||
@@ -94,6 +124,7 @@ const emptyPortfolioForm = {
|
|||||||
|
|
||||||
const TAB_PORTFOLIO = 'portfolio';
|
const TAB_PORTFOLIO = 'portfolio';
|
||||||
const TAB_AI = 'ai';
|
const TAB_AI = 'ai';
|
||||||
|
const TAB_REPORT = 'report';
|
||||||
|
|
||||||
/* ── component ───────────────────────────────────────────────────── */
|
/* ── component ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -124,6 +155,30 @@ const StockTrade = () => {
|
|||||||
/* Portfolio delete */
|
/* Portfolio delete */
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
|
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
|
||||||
|
|
||||||
|
/* Cash (예수금) form */
|
||||||
|
const [cashForm, setCashForm] = useState({ broker: '', cash: '' });
|
||||||
|
const [cashSaving, setCashSaving] = useState(false);
|
||||||
|
const [cashError, setCashError] = useState('');
|
||||||
|
|
||||||
|
/* ────────────────────────────────────────────────────────────── */
|
||||||
|
/* 리포트 탭 state */
|
||||||
|
/* ────────────────────────────────────────────────────────────── */
|
||||||
|
const [reportSortField, setReportSortField] = useState('profit_rate');
|
||||||
|
const [reportSortDir, setReportSortDir] = useState('desc');
|
||||||
|
|
||||||
|
/* Fear & Greed */
|
||||||
|
const [fgData, setFgData] = useState(null);
|
||||||
|
const [fgLoading, setFgLoading] = useState(false);
|
||||||
|
const [fgError, setFgError] = useState('');
|
||||||
|
const [fgLoaded, setFgLoaded] = useState(false);
|
||||||
|
|
||||||
|
/* AI Coach */
|
||||||
|
const [aiApiKey, setAiApiKey] = useState('');
|
||||||
|
const [aiModel, setAiModel] = useState('claude-haiku-4-5-20251001');
|
||||||
|
const [aiResult, setAiResult] = useState(null);
|
||||||
|
const [aiLoading, setAiLoading] = useState(false);
|
||||||
|
const [aiError, setAiError] = useState('');
|
||||||
|
|
||||||
/* ────────────────────────────────────────────────────────────── */
|
/* ────────────────────────────────────────────────────────────── */
|
||||||
/* AI 투자 (Balance) state */
|
/* AI 투자 (Balance) state */
|
||||||
/* ────────────────────────────────────────────────────────────── */
|
/* ────────────────────────────────────────────────────────────── */
|
||||||
@@ -160,6 +215,23 @@ const StockTrade = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadFearAndGreed = useCallback(async () => {
|
||||||
|
setFgLoading(true);
|
||||||
|
setFgError('');
|
||||||
|
try {
|
||||||
|
const data = await getFearAndGreed();
|
||||||
|
const fg = data?.fear_and_greed ?? data;
|
||||||
|
const score = typeof fg?.score === 'number' ? fg.score : parseFloat(fg?.score);
|
||||||
|
if (isNaN(score)) throw new Error('지수 데이터 형식 오류');
|
||||||
|
setFgData({ score, rating: fg.rating ?? '', timestamp: fg.timestamp ?? null });
|
||||||
|
setFgLoaded(true);
|
||||||
|
} catch (err) {
|
||||||
|
setFgError('F&G 지수 조회 실패: ' + (err?.message ?? String(err)));
|
||||||
|
} finally {
|
||||||
|
setFgLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadBalance = useCallback(async () => {
|
const loadBalance = useCallback(async () => {
|
||||||
setBalanceLoading(true);
|
setBalanceLoading(true);
|
||||||
setBalanceError('');
|
setBalanceError('');
|
||||||
@@ -180,9 +252,31 @@ const StockTrade = () => {
|
|||||||
loadPortfolio();
|
loadPortfolio();
|
||||||
} else if (activeTab === TAB_AI && !balanceLoaded) {
|
} else if (activeTab === TAB_AI && !balanceLoaded) {
|
||||||
loadBalance();
|
loadBalance();
|
||||||
|
} else if (activeTab === TAB_REPORT && !portfolioLoaded) {
|
||||||
|
loadPortfolio();
|
||||||
}
|
}
|
||||||
}, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance]);
|
}, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance]);
|
||||||
|
|
||||||
|
/* Fear & Greed: 리포트 탭 첫 진입 시 자동 로드 */
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === TAB_REPORT && !fgLoaded) {
|
||||||
|
loadFearAndGreed();
|
||||||
|
}
|
||||||
|
}, [activeTab, fgLoaded, loadFearAndGreed]);
|
||||||
|
|
||||||
|
/* AI Coach: 마운트 시 localStorage에서 API Key + 오늘 캐시 복원 */
|
||||||
|
useEffect(() => {
|
||||||
|
const savedKey = localStorage.getItem('ai_coach_key') ?? '';
|
||||||
|
const savedModel = localStorage.getItem('ai_coach_model') ?? 'claude-haiku-4-5-20251001';
|
||||||
|
setAiApiKey(savedKey);
|
||||||
|
setAiModel(savedModel);
|
||||||
|
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 */ }
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
/* Auto-refresh portfolio every 3 min (포트폴리오 탭 활성 시) */
|
/* Auto-refresh portfolio every 3 min (포트폴리오 탭 활성 시) */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab !== TAB_PORTFOLIO) return;
|
if (activeTab !== TAB_PORTFOLIO) return;
|
||||||
@@ -277,6 +371,126 @@ const StockTrade = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ── 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)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── report sort ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const handleReportSort = (field) => {
|
||||||
|
if (reportSortField === field) {
|
||||||
|
setReportSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setReportSortField(field);
|
||||||
|
setReportSortDir('desc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── AI coach ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const handleAiCoach = async () => {
|
||||||
|
if (!aiApiKey.trim() || 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 cache */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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}
|
||||||
|
|
||||||
|
반드시 아래 JSON 형식으로만 응답하세요 (코드블록 없이, 모든 텍스트는 한국어로):
|
||||||
|
{
|
||||||
|
"score": 85,
|
||||||
|
"grade": "A",
|
||||||
|
"summary": "30자 이내 한줄 평가",
|
||||||
|
"evaluation": "200자 이내 상세 평가",
|
||||||
|
"advice": [
|
||||||
|
{ "title": "조언 제목", "body": "50자 이내 조언 내용" },
|
||||||
|
{ "title": "조언 제목", "body": "50자 이내 조언 내용" },
|
||||||
|
{ "title": "조언 제목", "body": "50자 이내 조언 내용" }
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': aiApiKey.trim(),
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
'anthropic-dangerous-direct-browser-access': 'true',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: aiModel,
|
||||||
|
max_tokens: 1024,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Claude API 오류 (${res.status}): ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/* ── manual order ────────────────────────────────────────────── */
|
/* ── manual order ────────────────────────────────────────────── */
|
||||||
|
|
||||||
const submitManualOrder = async (event) => {
|
const submitManualOrder = async (event) => {
|
||||||
@@ -327,6 +541,9 @@ const StockTrade = () => {
|
|||||||
|
|
||||||
const portfolioHoldings = portfolio?.holdings ?? [];
|
const portfolioHoldings = portfolio?.holdings ?? [];
|
||||||
const portfolioSummary = portfolio?.summary ?? {};
|
const portfolioSummary = portfolio?.summary ?? {};
|
||||||
|
const cashList = portfolio?.cash ?? [];
|
||||||
|
const totalCash = portfolioSummary.total_cash ?? null;
|
||||||
|
const totalAssets = portfolioSummary.total_assets ?? null;
|
||||||
const brokerGroups = useMemo(() => {
|
const brokerGroups = useMemo(() => {
|
||||||
const map = {};
|
const map = {};
|
||||||
for (const item of portfolioHoldings) {
|
for (const item of portfolioHoldings) {
|
||||||
@@ -370,6 +587,62 @@ const StockTrade = () => {
|
|||||||
return map;
|
return map;
|
||||||
}, [brokerGroups]);
|
}, [brokerGroups]);
|
||||||
|
|
||||||
|
/* ── derived: Report ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const brokerPieData = useMemo(() =>
|
||||||
|
brokerGroups
|
||||||
|
.map(([broker, items]) => ({ name: broker, value: getBrokerSummary(items).totalEval }))
|
||||||
|
.filter((d) => d.value > 0),
|
||||||
|
[brokerGroups]
|
||||||
|
);
|
||||||
|
|
||||||
|
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 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]);
|
||||||
|
|
||||||
/* ── render ───────────────────────────────────────────────────── */
|
/* ── render ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -390,11 +663,9 @@ const StockTrade = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="stock-card">
|
<div className="stock-card">
|
||||||
<p className="stock-card__title">
|
<p className="stock-card__title">
|
||||||
{activeTab === TAB_PORTFOLIO
|
{activeTab === TAB_AI ? 'AI 투자 요약' : '쟁승토리 계좌 요약'}
|
||||||
? '쟁승토리 계좌 요약'
|
|
||||||
: 'AI 투자 요약'}
|
|
||||||
</p>
|
</p>
|
||||||
{activeTab === TAB_PORTFOLIO ? (
|
{activeTab === TAB_PORTFOLIO || activeTab === TAB_REPORT ? (
|
||||||
/* Portfolio summary */
|
/* Portfolio summary */
|
||||||
<div className="stock-status">
|
<div className="stock-status">
|
||||||
<div>
|
<div>
|
||||||
@@ -424,6 +695,22 @@ const StockTrade = () => {
|
|||||||
<span>보유 종목</span>
|
<span>보유 종목</span>
|
||||||
<strong>{portfolioHoldings.length}</strong>
|
<strong>{portfolioHoldings.length}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
{totalCash != null && (
|
||||||
|
<div>
|
||||||
|
<span>예수금 합계</span>
|
||||||
|
<strong style={{ color: '#93c5fd' }}>
|
||||||
|
{formatNumber(totalCash)}원
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{totalAssets != null && (
|
||||||
|
<div>
|
||||||
|
<span>총 자산</span>
|
||||||
|
<strong style={{ fontWeight: 700 }}>
|
||||||
|
{formatNumber(totalAssets)}원
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* AI balance summary */
|
/* AI balance summary */
|
||||||
@@ -472,6 +759,15 @@ const StockTrade = () => {
|
|||||||
<span className="stock-main-tab__label">AI 투자</span>
|
<span className="stock-main-tab__label">AI 투자</span>
|
||||||
<span className="stock-main-tab__sub">모의투자</span>
|
<span className="stock-main-tab__sub">모의투자</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`stock-main-tab ${activeTab === TAB_REPORT ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setActiveTab(TAB_REPORT)}
|
||||||
|
>
|
||||||
|
<span className="stock-main-tab__icon">📊</span>
|
||||||
|
<span className="stock-main-tab__label">리포트</span>
|
||||||
|
<span className="stock-main-tab__sub">분석·AI코치</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ════════════════════════════════════════════════════════
|
{/* ════════════════════════════════════════════════════════
|
||||||
@@ -612,10 +908,102 @@ const StockTrade = () => {
|
|||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{totalCash != null && (
|
||||||
|
<div className="pf-total-summary__card is-cash">
|
||||||
|
<span>예수금 합계</span>
|
||||||
|
<strong>{formatNumber(totalCash)}원</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{totalAssets != null && (
|
||||||
|
<div className="pf-total-summary__card is-assets">
|
||||||
|
<span>총 자산</span>
|
||||||
|
<strong>{formatNumber(totalAssets)}원</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
||||||
|
|
||||||
|
{cashList.length > 0 && (
|
||||||
|
<div className="pf-cash-table">
|
||||||
|
{cashList.map((item) => (
|
||||||
|
<div key={item.id ?? item.broker} className="pf-cash-row">
|
||||||
|
<span className="pf-cash-broker">{item.broker}</span>
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
className="button ghost small pf-btn-danger"
|
||||||
|
onClick={() => handleCashDelete(item.broker)}
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cashList.length === 0 && (
|
||||||
|
<p className="stock-empty" style={{ fontSize: 13 }}>
|
||||||
|
등록된 예수금이 없습니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className="pf-cash-form" onSubmit={handleCashSave}>
|
||||||
|
<label>
|
||||||
|
증권사명
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={cashForm.broker}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCashForm((p) => ({ ...p, broker: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="KB증권"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
예수금 (원)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={cashForm.cash}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCashForm((p) => ({ ...p, cash: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="1500000"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={cashSaving}
|
||||||
|
>
|
||||||
|
{cashSaving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
{cashError && <p className="stock-error">{cashError}</p>}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Broker cards stacked */}
|
{/* Broker cards stacked */}
|
||||||
{brokerGroups.map(([broker, items]) => {
|
{brokerGroups.map(([broker, items]) => {
|
||||||
const bSummary = getBrokerSummary(items);
|
const bSummary = getBrokerSummary(items);
|
||||||
@@ -649,6 +1037,16 @@ const StockTrade = () => {
|
|||||||
{formatNumber(bSummary.totalProfit)} (
|
{formatNumber(bSummary.totalProfit)} (
|
||||||
{formatPercent(bSummary.totalProfitRate)})
|
{formatPercent(bSummary.totalProfitRate)})
|
||||||
</span>
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const bc = cashList.find(
|
||||||
|
(c) => c.broker === broker
|
||||||
|
);
|
||||||
|
return bc ? (
|
||||||
|
<span className="pf-cash-badge">
|
||||||
|
예수금 {formatNumber(bc.cash)}원
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1044,6 +1442,344 @@ const StockTrade = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ════════════════════════════════════════════════════════
|
||||||
|
TAB 3: 리포트 + AI 코치
|
||||||
|
════════════════════════════════════════════════════════ */}
|
||||||
|
{activeTab === TAB_REPORT && (
|
||||||
|
<>
|
||||||
|
{portfolioLoading && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
|
||||||
|
<Loading type="spinner" message="포트폴리오 로딩 중..." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{portfolioError && <p className="stock-error">{portfolioError}</p>}
|
||||||
|
|
||||||
|
{/* ── Fear & Greed Index ─────────────────────────── */}
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">시장 심리 지표</p>
|
||||||
|
<h3>Fear & Greed Index</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
CNN Fear & Greed 지수로 현재 시장 심리를 파악합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="stock-panel__actions">
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={loadFearAndGreed}
|
||||||
|
disabled={fgLoading}
|
||||||
|
>
|
||||||
|
{fgLoading ? '조회 중...' : '새로고침'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{fgError && <p className="stock-error">{fgError}</p>}
|
||||||
|
{fgData ? (
|
||||||
|
<div className="fg-panel">
|
||||||
|
<div className="fg-gauge">
|
||||||
|
<div className="fg-gauge__track">
|
||||||
|
<div
|
||||||
|
className="fg-gauge__needle"
|
||||||
|
style={{ left: `${Math.min(100, Math.max(0, fgData.score))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="fg-gauge__labels">
|
||||||
|
<span>극단적 공포</span>
|
||||||
|
<span>공포</span>
|
||||||
|
<span>중립</span>
|
||||||
|
<span>탐욕</span>
|
||||||
|
<span>극단적 탐욕</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="fg-score-display">
|
||||||
|
<span className="fg-score-number" style={{ color: getFgColor(fgData.score) }}>
|
||||||
|
{Math.round(fgData.score)}
|
||||||
|
</span>
|
||||||
|
<span className="fg-score-label" style={{ color: getFgColor(fgData.score) }}>
|
||||||
|
{getFgLabel(fgData.score)}
|
||||||
|
</span>
|
||||||
|
{fgData.timestamp && (
|
||||||
|
<span className="fg-score-date">
|
||||||
|
{new Date(fgData.timestamp).toLocaleDateString('ko-KR')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : !fgError ? (
|
||||||
|
<p className="stock-empty">지수 데이터를 불러오는 중...</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── 자산 배분 + 수익률 차트 ────────────────────── */}
|
||||||
|
{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={brokerPieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={52}
|
||||||
|
outerRadius={84}
|
||||||
|
dataKey="value"
|
||||||
|
paddingAngle={2}
|
||||||
|
>
|
||||||
|
{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={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]}>
|
||||||
|
{profitBarData.map((entry, i) => (
|
||||||
|
<Cell key={i} fill={entry.rate >= 0 ? '#34d399' : '#f87171'} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 수익률 랭킹 테이블 ─────────────────────────── */}
|
||||||
|
{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={() => handleReportSort(key)}>
|
||||||
|
{label}{' '}
|
||||||
|
<span className="report-sort-icon">
|
||||||
|
{reportSortField === key
|
||||||
|
? reportSortDir === 'asc' ? '↑' : '↓'
|
||||||
|
: '↕'}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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;
|
||||||
|
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: `${maxAbsRate > 0 ? Math.abs(rateN) / 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>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{portfolioLoaded && portfolioHoldings.length === 0 && !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>
|
||||||
|
|
||||||
|
{/* API Key 설정 */}
|
||||||
|
<div className="ai-coach-settings">
|
||||||
|
<label>
|
||||||
|
Anthropic API Key
|
||||||
|
<div className="ai-coach-key-row">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="ai-coach-key-input"
|
||||||
|
value={aiApiKey}
|
||||||
|
onChange={(e) => setAiApiKey(e.target.value)}
|
||||||
|
placeholder="sk-ant-api03-..."
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.setItem('ai_coach_key', aiApiKey);
|
||||||
|
localStorage.setItem('ai_coach_model', aiModel);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
AI 모델
|
||||||
|
<select
|
||||||
|
value={aiModel}
|
||||||
|
onChange={(e) => {
|
||||||
|
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={handleAiCoach}
|
||||||
|
disabled={aiLoading || !aiApiKey.trim() || portfolioHoldings.length === 0}
|
||||||
|
>
|
||||||
|
{aiLoading ? 'AI 분석 중...' : '오늘 투자 평가 받기'}
|
||||||
|
</button>
|
||||||
|
{portfolioHoldings.length === 0 && (
|
||||||
|
<span className="ai-coach-note">종목 등록 후 이용 가능합니다.</span>
|
||||||
|
)}
|
||||||
|
{aiResult?.generated_at && (
|
||||||
|
<span className="ai-coach-note">
|
||||||
|
{aiResult.cached ? '오늘 캐시 결과 · ' : ''}
|
||||||
|
{new Date(aiResult.generated_at).toLocaleTimeString('ko-KR')} 생성
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{aiError && <p className="stock-error" style={{ marginTop: 8 }}>{aiError}</p>}
|
||||||
|
|
||||||
|
{aiResult && !aiLoading && (
|
||||||
|
<div className="ai-coach-result">
|
||||||
|
<div className="ai-coach-header">
|
||||||
|
<div className={`ai-grade-badge grade-${(aiResult.grade ?? 'c').toLowerCase()}`}>
|
||||||
|
{aiResult.grade ?? '?'}
|
||||||
|
</div>
|
||||||
|
<div className="ai-score-wrap">
|
||||||
|
<span className="ai-score-num">{aiResult.score ?? 0}</span>
|
||||||
|
<span className="ai-score-unit">/ 100</span>
|
||||||
|
</div>
|
||||||
|
<p className="ai-summary-text">{aiResult.summary}</p>
|
||||||
|
</div>
|
||||||
|
<p className="ai-evaluation-text">{aiResult.evaluation}</p>
|
||||||
|
{aiResult.advice?.length > 0 && (
|
||||||
|
<div className="ai-advice-list">
|
||||||
|
{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}`);
|
||||||
|
setAiResult(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
다시 평가받기 (캐시 삭제)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* KIS modal */}
|
{/* KIS modal */}
|
||||||
{kisModal ? (
|
{kisModal ? (
|
||||||
<div className="stock-modal" role="dialog" aria-modal="true">
|
<div className="stock-modal" role="dialog" aria-modal="true">
|
||||||
|
|||||||
@@ -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-travel);
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,10 +88,11 @@
|
|||||||
.travel-map__canvas {
|
.travel-map__canvas {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 520px;
|
min-height: 520px;
|
||||||
border-radius: 22px;
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(10, 12, 20, 0.6);
|
background: rgba(10, 12, 20, 0.6);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -118,7 +119,7 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
letter-spacing: 0.2em;
|
letter-spacing: 0.2em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--accent);
|
color: var(--accent-travel);
|
||||||
}
|
}
|
||||||
|
|
||||||
.travel-map__desc {
|
.travel-map__desc {
|
||||||
@@ -303,7 +304,7 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
letter-spacing: 0.16em;
|
letter-spacing: 0.16em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #f1c07a;
|
color: var(--accent-travel);
|
||||||
}
|
}
|
||||||
|
|
||||||
.travel-modal__summary-meta {
|
.travel-modal__summary-meta {
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import React, { lazy } from 'react';
|
import React, { lazy } from 'react';
|
||||||
|
import {
|
||||||
|
IconHome,
|
||||||
|
IconBlog,
|
||||||
|
IconLotto,
|
||||||
|
IconStock,
|
||||||
|
IconTravel,
|
||||||
|
IconLab,
|
||||||
|
} 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'));
|
||||||
@@ -14,36 +22,48 @@ export const navLinks = [
|
|||||||
label: 'Home',
|
label: 'Home',
|
||||||
path: '/',
|
path: '/',
|
||||||
description: '첫 인상과 최신 업데이트를 모아둔 허브',
|
description: '첫 인상과 최신 업데이트를 모아둔 허브',
|
||||||
|
icon: <IconHome />,
|
||||||
|
accent: '#f7a8a5',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'blog',
|
id: 'blog',
|
||||||
label: 'Blog',
|
label: 'Blog',
|
||||||
path: '/blog',
|
path: '/blog',
|
||||||
description: '생각과 기록, 코드 스니펫을 모으는 공간',
|
description: '생각과 기록, 코드 스니펫을 모으는 공간',
|
||||||
|
icon: <IconBlog />,
|
||||||
|
accent: '#c084fc',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'lotto',
|
id: 'lotto',
|
||||||
label: 'Lotto',
|
label: 'Lotto',
|
||||||
path: '/lotto',
|
path: '/lotto',
|
||||||
description: '숫자를 뽑고 통계를 확인하는 실험실',
|
description: '숫자를 뽑고 통계를 확인하는 실험실',
|
||||||
|
icon: <IconLotto />,
|
||||||
|
accent: '#34d399',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'stock',
|
id: 'stock',
|
||||||
label: 'Stock',
|
label: 'Stock',
|
||||||
path: '/stock',
|
path: '/stock',
|
||||||
description: '아침 시장 흐름을 확인하는 주식 연구실',
|
description: '아침 시장 흐름을 확인하는 주식 연구실',
|
||||||
|
icon: <IconStock />,
|
||||||
|
accent: '#60a5fa',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'travel',
|
id: 'travel',
|
||||||
label: 'Travel',
|
label: 'Travel',
|
||||||
path: '/travel',
|
path: '/travel',
|
||||||
description: '여행에서 담은 색과 장면을 전시하는 갤러리',
|
description: '여행에서 담은 색과 장면을 전시하는 갤러리',
|
||||||
|
icon: <IconTravel />,
|
||||||
|
accent: '#fb923c',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'lab',
|
id: 'lab',
|
||||||
label: 'Lab',
|
label: 'Lab',
|
||||||
path: '/lab',
|
path: '/lab',
|
||||||
description: '실험적인 UI/UX 효과를 테스트하는 공간',
|
description: '실험적인 UI/UX 효과를 테스트하는 공간',
|
||||||
|
icon: <IconLab />,
|
||||||
|
accent: '#fbbf24',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ export default defineConfig({
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: 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',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user