UI 디자인 대대적으로 대시보드 형태의 전문적인 느낌으로 재구성

This commit is contained in:
2026-03-04 01:39:26 +09:00
parent 840b0a5300
commit 618d5f8e6f
21 changed files with 3499 additions and 374 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npm run:*)"
]
}
}

149
CLAUDE.md Normal file
View 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
View File

@@ -13,6 +13,7 @@
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.3",
"recharts": "^3.7.0",
"three": "^0.182.0"
},
"devDependencies": {
@@ -1045,6 +1046,42 @@
"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": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@@ -1411,6 +1448,18 @@
"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": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1456,6 +1505,69 @@
"@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": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1474,14 +1586,14 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.2.79",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.79.tgz",
"integrity": "sha512-RwGAGXPl9kSXwdNTafkOEuFrTBD5SA2B3iEB96xi8+xu5ddUa/cpvyVCSNn+asgLCTHkb5ZxN8gbuibYJi4s1w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -1498,6 +1610,12 @@
"@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": {
"version": "5.1.2",
"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"
}
},
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1745,9 +1872,130 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"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": {
"version": "4.4.3",
"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": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1780,6 +2034,16 @@
"dev": true,
"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": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@@ -2029,6 +2293,12 @@
"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": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2241,6 +2511,16 @@
"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": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -2268,6 +2548,15 @@
"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": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2713,6 +3002,13 @@
"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": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
@@ -2727,6 +3023,29 @@
"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": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -2769,6 +3088,57 @@
"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": {
"version": "4.0.0",
"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==",
"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": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2999,6 +3375,37 @@
"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": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",

View File

@@ -18,6 +18,7 @@
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.3",
"recharts": "^3.7.0",
"three": "^0.182.0"
},
"devDependencies": {

View File

@@ -1,77 +1,493 @@
:root {
--bg: #0f0d12;
--surface: rgba(26, 23, 32, 0.88);
--text: #f4efe9;
--muted: #b6b1a9;
--line: rgba(255, 255, 255, 0.12);
--accent: #f7a8a5;
--accent-strong: #fdd4b1;
--font-display: "DM Serif Display", "Noto Serif KR", serif;
--font-body: "Manrope", "Noto Sans KR", sans-serif;
}
/* ═══════════════════════════════════════════════════════════════════════
App.css — Dashboard Layout & Design System
Cyberpunk / Futuristic Dashboard UI
═══════════════════════════════════════════════════════════════════════ */
/* ── Layout: 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 {
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px 80px;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 28px 32px;
background: transparent;
position: relative;
}
@media (max-width: 768px) {
.site-main {
padding: 20px 16px 60px;
padding: 16px;
}
}
/* ── Loading State ───────────────────────────────────────────────────── */
.suspend-loading {
display: grid;
place-items: center;
min-height: 50vh;
color: var(--text-dim);
font-size: 13px;
letter-spacing: 0.1em;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(16px);
}
/* ═══════════════════════════════════════════════════════════════════════
Animations
═══════════════════════════════════════════════════════════════════════ */
@keyframes fadeIn {
0% {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.site-main>* {
animation: fadeUp 0.6s ease both;
@keyframes glowPulse {
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 {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 1px solid var(--line);
padding: 10px 18px;
border-radius: 999px;
text-decoration: none;
background: var(--surface-card);
color: var(--text);
font-size: 14px;
letter-spacing: 0.08em;
text-transform: uppercase;
transition: all 0.2s ease;
background: rgba(255, 255, 255, 0.06);
font-family: var(--font-body);
font-size: 13px;
font-weight: 500;
letter-spacing: 0.05em;
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 {
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
border-color: var(--line-bright);
color: var(--neon-cyan);
box-shadow: 0 0 12px rgba(0, 212, 255, 0.15);
}
.button:active {
transform: scale(0.97);
}
/* Primary */
.button.primary {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #1a1414;
background: var(--grad-accent);
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;
}
.button.ghost {
background: transparent;
.button.ghost:hover {
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;
}
}

View File

@@ -8,12 +8,14 @@ function App() {
return (
<div className="app-shell">
<Navbar />
<div className="app-content">
<main className="site-main">
<React.Suspense fallback={<div className="suspend-loading"><Loading /></div>}>
<Outlet />
</React.Suspense>
</main>
</div>
</div>
);
}

View File

@@ -136,3 +136,22 @@ export function updatePortfolio(id, fields) {
export function deletePortfolio(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
View 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" />
</>
);

View File

@@ -8,49 +8,58 @@
}
.loading-spinner__circle {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.1);
width: 28px;
height: 28px;
border: 2px solid rgba(255, 255, 255, 0.08);
border-radius: 50%;
border-top-color: var(--accent, #f7a8a5);
animation: spin 0.8s linear infinite;
animation: loading-spin 0.75s linear infinite;
}
.loading-spinner__text {
font-size: 13px;
color: var(--muted, #b6b1a9);
font-size: 12px;
color: var(--muted, #9b9490);
margin: 0;
letter-spacing: 0.04em;
}
@keyframes spin {
@keyframes loading-spin {
to {
transform: rotate(360deg);
}
}
/* ── Skeleton ─────────────────────────────────────────────────────── */
.loading-skeleton {
display: grid;
gap: 12px;
padding: 16px;
gap: 14px;
padding: 4px 0;
width: 100%;
}
.loading-skeleton__line {
height: 16px;
border-radius: 4px;
height: 14px;
border-radius: 7px;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.05) 25%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0.05) 75%
rgba(255, 255, 255, 0.04) 0%,
rgba(255, 255, 255, 0.09) 40%,
rgba(255, 255, 255, 0.04) 80%
);
background-size: 200% 100%;
animation: pulse 1.5s ease-in-out infinite;
background-size: 300% 100%;
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% {
background-position: 200% 0;
background-position: 100% 0;
}
100% {
background-position: -200% 0;

View File

@@ -1,126 +1,357 @@
.site-nav {
position: sticky;
/* ── 사이드바 본체 ───────────────────────────────────────────────────── */
.sidebar {
position: fixed;
left: 0;
top: 0;
z-index: 10;
background: rgba(16, 16, 24, 0.82);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
bottom: 0;
width: var(--sidebar-w);
z-index: 200;
display: flex;
flex-direction: column;
background: rgba(7, 12, 28, 0.92);
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__inner {
max-width: 1200px;
margin: 0 auto;
padding: 18px 20px;
/* ── 브랜드 섹션 ─────────────────────────────────────────────────────── */
.sidebar__brand {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 16px;
flex-shrink: 0;
}
.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;
color: var(--text-dim);
font-size: 14px;
font-weight: 500;
font-family: var(--font-body, 'Manrope', sans-serif);
border: 1px solid transparent;
position: relative;
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
overflow: hidden;
}
.sidebar__item:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text, #f0ebe4);
border-color: rgba(255, 255, 255, 0.08);
}
/* 활성 아이템 */
.sidebar__item.is-active {
background: linear-gradient(90deg, rgba(0, 212, 255, 0.12) 0%, rgba(0, 212, 255, 0.04) 100%);
border-color: rgba(0, 212, 255, 0.2);
color: var(--text-bright);
}
/* 활성 아이템 좌측 네온 바 */
.sidebar__item.is-active::before {
content: '';
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;
gap: 16px;
}
.site-nav__brand {
.sidebar__status {
display: flex;
align-items: center;
gap: 14px;
gap: 7px;
}
.site-nav__logo-image {
width: 42px;
height: 42px;
border-radius: 14px;
object-fit: cover;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.25);
.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;
}
.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);
@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); }
}
.site-nav__title {
.sidebar__status-text {
font-size: 11px;
color: var(--text-muted);
font-weight: 500;
letter-spacing: 0.02em;
}
.sidebar__version {
margin: 0;
font-weight: 600;
letter-spacing: 0.02em;
font-size: 10px;
color: var(--text-muted);
font-family: 'JetBrains Mono', 'Fira Code', monospace;
letter-spacing: 0.05em;
opacity: 0.6;
}
.site-nav__subtitle {
margin: 4px 0 0;
font-size: 12px;
color: var(--muted);
}
/* ── 모바일 토글 버튼 ────────────────────────────────────────────────── */
.site-nav__links {
display: flex;
.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;
gap: 12px;
flex-wrap: wrap;
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);
}
.site-nav__link {
text-decoration: none;
font-size: 14px;
letter-spacing: 0.02em;
color: var(--text);
padding: 8px 12px;
border-radius: 999px;
border: 1px solid transparent;
transition: all 0.2s ease;
.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);
}
.site-nav__link:hover {
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.06);
}
.site-nav__link.is-active {
border-color: rgba(247, 168, 165, 0.6);
background: rgba(247, 168, 165, 0.16);
color: #ffe9e2;
}
@media (max-width: 800px) {
.site-nav__inner {
.sidebar-toggle__icon {
display: flex;
flex-direction: column;
align-items: flex-start;
}
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) {
.site-nav__inner {
padding: 14px 16px;
gap: 12px;
.sidebar {
transform: translateX(-100%);
}
.site-nav__brand {
gap: 10px;
.sidebar.is-open {
transform: translateX(0);
}
.site-nav__logo-image {
width: 36px;
height: 36px;
}
.site-nav__title {
font-size: 14px;
}
.site-nav__subtitle {
font-size: 11px;
}
.site-nav__links {
gap: 8px;
}
.site-nav__link {
font-size: 13px;
padding: 6px 10px;
.sidebar-toggle {
display: flex;
}
}
/* ── 데스크톱: 토글 버튼 숨김 ────────────────────────────────────────── */
@media (min-width: 769px) {
.sidebar-toggle {
display: none;
}
.sidebar__overlay {
display: none;
}
}

View File

@@ -1,35 +1,92 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { NavLink } from 'react-router-dom';
import { navLinks } from '../routes.jsx';
import mainLogo from '../assets/main_logo.png';
import './Navbar.css';
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 (
<header className="site-nav">
<div className="site-nav__inner">
<div className="site-nav__brand">
<img src={mainLogo} alt="Logo" className="site-nav__logo-image" />
<div>
<p className="site-nav__title">Jaeoh Archive</p>
<p className="site-nav__subtitle">Stories, notes, and snapshots</p>
<>
{/* 모바일 오버레이 */}
<div
className={`sidebar__overlay${menuOpen ? ' is-visible' : ''}`}
onClick={closeMenu}
aria-hidden="true"
/>
{/* 모바일 토글 버튼 */}
<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>
<nav className="site-nav__links">
{/* 구분선 */}
<div className="sidebar__divider" />
{/* 네비게이션 */}
<nav className="sidebar__nav">
<p className="sidebar__section-label">NAVIGATION</p>
{navLinks.map((link) => (
<NavLink
key={link.id}
to={link.path}
onClick={closeMenu}
className={({ isActive }) =>
`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>
))}
</nav>
{/* 사이드바 푸터 */}
<div className="sidebar__footer">
<div className="sidebar__divider" />
<div className="sidebar__footer-content">
<div className="sidebar__status">
<span className="sidebar__status-dot" />
<span className="sidebar__status-text">System Online</span>
</div>
</header>
<p className="sidebar__version">v2.0.0</p>
</div>
</div>
</aside>
</>
);
};

View File

@@ -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;
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 {
height: 100%;
}
body {
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;
overflow: hidden;
background-color: var(--bg);
background-image: var(--grad-bg-radial);
background-attachment: fixed;
color: var(--text);
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) {
body {
background-attachment: scroll;
}
/* ── Scrollbar ───────────────────────────────────────────────────────── */
::-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 {
color: inherit;
text-decoration: none;
}
a:hover {
color: var(--neon-cyan);
}
/* ── Images ──────────────────────────────────────────────────────────── */
img {
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;
}
}

View File

@@ -14,7 +14,7 @@
text-transform: uppercase;
letter-spacing: 0.3em;
font-size: 12px;
color: var(--accent);
color: var(--accent-blog);
margin: 0 0 10px;
}
@@ -98,29 +98,34 @@
}
.blog-category-chip.is-active {
border-color: rgba(247, 168, 165, 0.6);
background: rgba(247, 168, 165, 0.2);
border-color: rgba(192, 132, 252, 0.55);
background: rgba(192, 132, 252, 0.15);
color: var(--accent-blog);
}
.blog-list__item {
border: 1px solid var(--line);
background: var(--surface);
padding: 16px;
border-radius: 18px;
border-radius: var(--radius-md);
text-align: left;
cursor: pointer;
display: grid;
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 {
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 {
border-color: rgba(247, 168, 165, 0.6);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
border-color: rgba(192, 132, 252, 0.5);
box-shadow: 0 4px 20px rgba(192, 132, 252, 0.12), var(--shadow-inset);
background: rgba(192, 132, 252, 0.05);
}
.blog-pagination {
@@ -168,14 +173,15 @@
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--accent);
color: var(--accent-blog);
}
.blog-article {
border: 1px solid var(--line);
border-radius: 24px;
border-radius: var(--radius-lg);
background: rgba(9, 10, 16, 0.65);
padding: 24px;
padding: 28px;
box-shadow: var(--shadow-md), var(--shadow-inset);
}
.blog-article__meta {
@@ -277,8 +283,9 @@
.md-quote {
margin: 0 0 14px;
padding: 12px 16px;
border-left: 3px solid rgba(247, 168, 165, 0.6);
background: rgba(255, 255, 255, 0.03);
border-left: 3px solid rgba(192, 132, 252, 0.5);
background: rgba(192, 132, 252, 0.05);
border-radius: 0 8px 8px 0;
color: var(--muted);
}

View File

@@ -1,77 +1,113 @@
/* ═══════════════════════════════════════════════════════════════════════
Home Page — Dashboard Style
═══════════════════════════════════════════════════════════════════════ */
.home {
display: grid;
gap: 60px;
gap: 32px;
animation: fadeIn 0.4s var(--ease-out) both;
}
.home > section {
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;
}
/* ── Hero ────────────────────────────────────────────────────────────── */
.home-hero {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
gap: 32px;
align-items: center;
grid-template-columns: minmax(0, 1.3fr) minmax(0, 0.7fr);
gap: 24px;
align-items: stretch;
}
.home-hero__kicker {
font-size: 12px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.28em;
color: var(--accent);
margin: 0 0 12px;
letter-spacing: 0.3em;
color: var(--neon-cyan);
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 {
font-family: var(--font-display);
font-size: clamp(32px, 4vw, 46px);
font-size: clamp(28px, 3.5vw, 44px);
margin: 0 0 16px;
line-height: 1.2;
color: var(--text-bright);
letter-spacing: -0.03em;
}
.home-hero__lead {
color: var(--muted);
line-height: 1.7;
color: var(--text-dim);
line-height: 1.75;
margin: 0 0 24px;
font-size: 14px;
}
.home-hero__actions {
display: flex;
gap: 12px;
gap: 10px;
flex-wrap: wrap;
}
/* ── Hero Card ───────────────────────────────────────────────────────── */
.home-hero__card {
background: var(--surface);
background: var(--surface-card);
border: 1px solid var(--line);
border-radius: 24px;
border-radius: var(--radius-lg);
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;
color: var(--muted);
font-size: 13px;
letter-spacing: 0.12em;
color: var(--text-muted);
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
font-family: var(--font-display);
}
.home-hero__card-body h2 {
font-family: var(--font-display);
font-size: 24px;
margin: 0 0 12px;
font-size: 20px;
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 {
@@ -85,81 +121,184 @@
.stat-label {
margin: 0;
color: var(--muted);
font-size: 12px;
color: var(--text-muted);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.stat-value {
margin: 6px 0 0;
font-weight: 600;
font-size: 18px;
margin: 5px 0 0;
font-weight: 700;
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 {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 18px;
gap: 4px;
margin-bottom: 16px;
}
.home-section__header h2 {
margin: 0;
font-size: 26px;
font-size: clamp(17px, 2vw, 22px);
font-family: var(--font-display);
color: var(--text-bright);
letter-spacing: -0.02em;
}
.home-section__header p {
margin: 0;
color: var(--muted);
color: var(--text-muted);
font-size: 13px;
}
/* ── Navigation Cards Grid ───────────────────────────────────────────── */
.home-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.home-card {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
flex-direction: column;
gap: 12px;
text-decoration: none;
color: inherit;
padding: 18px;
border-radius: 18px;
border-radius: var(--radius-md);
border: 1px solid var(--line);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.01));
transition: transform 0.2s ease, border-color 0.2s ease;
background: var(--surface-card);
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 {
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 {
font-weight: 600;
font-size: 18px;
margin: 0 0 8px;
font-weight: 700;
font-size: 15px;
margin: 0 0 5px;
color: var(--text-bright);
letter-spacing: -0.01em;
}
.home-card__desc {
margin: 0;
color: var(--muted);
font-size: 14px;
color: var(--text-dim);
font-size: 12px;
line-height: 1.6;
}
.home-card__cta {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--accent);
.home-card__arrow {
font-size: 16px;
color: var(--neon-cyan);
opacity: 0;
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 {
display: grid;
gap: 12px;
gap: 8px;
}
.home-post {
@@ -167,46 +306,96 @@
color: inherit;
border: 1px solid var(--line);
padding: 16px 18px;
border-radius: 16px;
background: var(--surface);
border-radius: var(--radius-md);
background: var(--surface-card);
display: grid;
gap: 8px;
transition: border-color 0.2s ease;
grid-template-columns: auto 1fr auto;
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 {
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 {
margin: 0;
font-weight: 600;
font-size: 18px;
font-size: 15px;
color: var(--text-bright);
letter-spacing: -0.01em;
}
.home-post__excerpt {
margin: 0;
color: var(--muted);
color: var(--text-dim);
font-size: 12px;
line-height: 1.6;
}
.home-post__meta {
font-size: 12px;
color: var(--accent);
font-size: 11px;
color: var(--neon-purple-dim);
text-transform: uppercase;
letter-spacing: 0.14em;
letter-spacing: 0.12em;
white-space: nowrap;
padding-top: 4px;
}
/* ── Profile ─────────────────────────────────────────────────────────── */
.home-profile {
display: grid;
}
.home-profile__card {
border: 1px solid var(--line);
border-radius: 22px;
padding: 22px;
background: var(--surface);
border-radius: var(--radius-lg);
padding: 24px;
background: var(--surface-card);
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 {
@@ -216,31 +405,39 @@
}
.home-profile__avatar {
width: 52px;
height: 52px;
width: 56px;
height: 56px;
border-radius: 16px;
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 {
margin: 0;
font-size: 12px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--accent);
letter-spacing: 0.22em;
color: var(--neon-cyan);
font-family: var(--font-display);
}
.home-profile__name {
margin: 6px 0 0;
font-weight: 600;
margin: 4px 0 0;
font-weight: 700;
font-size: 18px;
color: var(--text-bright);
letter-spacing: -0.02em;
}
.home-profile__bio {
margin: 0;
color: var(--muted);
line-height: 1.6;
color: var(--text-dim);
line-height: 1.75;
font-size: 13px;
}
.home-profile__timeline {
@@ -250,10 +447,11 @@
.home-profile__section-title {
margin: 0;
font-size: 12px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.22em;
color: var(--accent);
letter-spacing: 0.24em;
color: var(--neon-cyan);
font-family: var(--font-display);
}
.home-profile__timeline ul {
@@ -261,87 +459,124 @@
margin: 0;
padding: 0;
display: grid;
gap: 10px;
gap: 6px;
}
.home-profile__timeline li {
display: grid;
gap: 4px;
gap: 2px;
padding: 12px 14px;
border-radius: 16px;
border-radius: var(--radius-sm);
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 {
font-size: 12px;
color: var(--muted);
.home-profile__timeline li:hover {
border-color: rgba(0, 212, 255, 0.15);
background: rgba(0, 212, 255, 0.03);
}
.timeline-period {
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.home-profile__timeline strong {
font-size: 15px;
font-size: 13px;
font-weight: 600;
color: var(--text-bright);
}
.home-profile__timeline span:not(.timeline-period) {
font-size: 12px;
color: var(--text-dim);
}
.home-profile__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
gap: 6px;
}
.home-profile__tags span {
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
color: var(--muted);
padding: 4px 10px;
font-size: 11px;
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 {
display: flex;
flex-wrap: wrap;
gap: 10px;
gap: 8px;
}
@media (max-width: 900px) {
/* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 960px) {
.home-hero {
grid-template-columns: 1fr;
}
.home-hero__card {
max-width: 480px;
}
}
@media (max-width: 768px) {
.home {
gap: 24px;
}
.home-hero h1 {
font-size: clamp(24px, 6vw, 36px);
font-size: clamp(22px, 6vw, 32px);
}
.home-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.home-card {
padding: 14px;
gap: 12px;
}
.home-card__title {
font-size: 16px;
}
.home-card__desc {
font-size: 13px;
}
.home-posts {
gap: 10px;
}
.home-card__icon {
width: 34px;
height: 34px;
}
.home-card__title {
font-size: 13px;
}
.home-card__desc {
font-size: 11px;
}
.home-post {
padding: 14px 16px;
padding: 12px 14px;
grid-template-columns: auto 1fr;
gap: 10px;
}
.home-post__meta {
grid-column: 2;
}
.home-post__title {
font-size: 16px;
font-size: 14px;
}
.home-profile__card {
@@ -351,8 +586,15 @@
.home-profile__name {
font-size: 16px;
}
}
.home-profile__bio {
font-size: 14px;
@media (max-width: 480px) {
.home-grid {
grid-template-columns: 1fr 1fr;
}
.home-hero__stats {
grid-template-columns: 1fr;
gap: 10px;
}
}

View File

@@ -14,7 +14,7 @@ const Home = () => {
<section className="home-hero">
<div className="home-hero__text">
<p className="home-hero__kicker">Personal Archive</p>
<h1>기록을 모으고, 이야기를 이어붙이는 작은 .</h1>
<h1>기록을 모으고,<br />이야기를 이어붙이는 작은 .</h1>
<p className="home-hero__lead">
개발, 여행 스냅, 그리고 생각을 모아두는 공간입니다.
</p>
@@ -28,7 +28,7 @@ const Home = () => {
</div>
</div>
<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">
<h2>느린 기록, 깊은 회고</h2>
<p>
@@ -37,13 +37,13 @@ const Home = () => {
</p>
</div>
<div className="home-hero__stats">
<div>
<div className="home-hero__stat">
<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 className="home-hero__stat">
<p className="stat-label">다음 업데이트</p>
<p className="stat-value">이번 주말</p>
<p className="stat-value stat-value--sm">이번 주말</p>
</div>
</div>
</div>
@@ -56,12 +56,23 @@ const Home = () => {
</div>
<div className="home-grid">
{highlights.map((item) => (
<Link key={item.id} to={item.path} className="home-card">
<div>
<Link
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__desc">{item.description}</p>
</div>
<span className="home-card__cta">열기</span>
<span className="home-card__arrow"></span>
</Link>
))}
</div>
@@ -75,8 +86,11 @@ const Home = () => {
<div className="home-posts">
{posts.map((post) => (
<Link key={post.slug} to="/blog" className="home-post">
<div className="home-post__dot" />
<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>
</Link>
))}
@@ -110,31 +124,26 @@ const Home = () => {
<p className="home-profile__section-title">연혁</p>
<ul>
<li>
<span>2023.02 - 현재</span>
<span className="timeline-period">2023.02 - 현재</span>
<strong>Server Developer</strong>
<span>내비 TIS 교통 서버/현대오토에버</span>
<span>내비 TIS 교통 서버 / 현대오토에버</span>
</li>
<li>
<span>2020.01 - 2023.02</span>
<span className="timeline-period">2020.01 - 2023.02</span>
<strong>Embedded Device SW Developer</strong>
<span>캐시비 단말기 개발/롯데정보통신</span>
<span>캐시비 단말기 개발 / 롯데정보통신</span>
</li>
<li>
<span>2019.07 - 2019.12</span>
<span className="timeline-period">2019.07 - 2019.12</span>
<strong>SSAFY - 삼성 SW Academy</strong>
<span>SSAFY</span>
<span>SSAFY 1 수료</span>
</li>
</ul>
</div>
<div className="home-profile__tags">
<span>C++</span>
<span>Git</span>
<span>AWS</span>
<span>Jira</span>
<span>MySQL</span>
<span>Docker</span>
<span>Kubernetes</span>
<span>Linux</span>
{['C++', 'Git', 'AWS', 'Jira', 'MySQL', 'Docker', 'Kubernetes', 'Linux'].map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
<div className="home-profile__actions">
<button className="button ghost">프로필 수정</button>

View File

@@ -14,7 +14,7 @@
text-transform: uppercase;
letter-spacing: 0.3em;
font-size: 12px;
color: var(--accent);
color: var(--accent-lotto);
margin: 0 0 10px;
}
@@ -63,10 +63,11 @@
.lotto-panel {
border: 1px solid var(--line);
background: var(--surface);
border-radius: 24px;
border-radius: var(--radius-lg);
padding: 20px;
display: grid;
gap: 16px;
box-shadow: var(--shadow-sm), var(--shadow-inset);
}
.lotto-panel--wide .lotto-chart {
@@ -94,7 +95,7 @@
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.22em;
color: var(--accent);
color: var(--accent-lotto);
}
.lotto-panel__sub {
@@ -213,7 +214,8 @@
}
.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 {

View File

@@ -16,7 +16,7 @@
text-transform: uppercase;
letter-spacing: 0.3em;
font-size: 12px;
color: var(--accent);
color: var(--accent-stock);
margin: 0 0 10px;
}
@@ -134,10 +134,11 @@
.stock-panel {
border: 1px solid var(--line);
background: var(--surface);
border-radius: 24px;
border-radius: var(--radius-lg);
padding: 20px;
display: grid;
gap: 16px;
box-shadow: var(--shadow-sm), var(--shadow-inset);
}
.stock-panel--wide {
@@ -169,7 +170,7 @@
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.22em;
color: var(--accent);
color: var(--accent-stock);
}
.stock-panel__sub {
@@ -211,9 +212,9 @@
}
.stock-snapshot__card.is-highlight {
border-color: rgba(255, 255, 255, 0.4);
background: rgba(255, 255, 255, 0.05);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08);
border-color: rgba(96, 165, 250, 0.4);
background: rgba(96, 165, 250, 0.06);
box-shadow: 0 0 0 1px rgba(96, 165, 250, 0.1), var(--shadow-inset);
}
.stock-snapshot__card p {
@@ -305,8 +306,9 @@
}
.stock-tab.is-active {
border-color: rgba(255, 255, 255, 0.5);
border-color: rgba(96, 165, 250, 0.5);
color: var(--text);
background: rgba(96, 165, 250, 0.1);
}
.stock-news__item {
@@ -340,7 +342,14 @@
}
.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 {
@@ -919,3 +928,525 @@
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;
}
}

View File

@@ -7,9 +7,17 @@ import {
addPortfolio,
updatePortfolio,
deletePortfolio,
upsertCash,
deleteCash,
getFearAndGreed,
} from '../../api';
import Loading from '../../components/Loading';
import './Stock.css';
import {
PieChart, Pie, Cell,
BarChart, Bar, XAxis, YAxis, CartesianGrid,
Tooltip as ChartTooltip, Legend, ResponsiveContainer,
} from 'recharts';
/* ── helpers ─────────────────────────────────────────────────────── */
@@ -73,6 +81,28 @@ const toNumeric = (value) => {
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) => {
if (numericValue > 0) return 'is-up';
if (numericValue < 0) return 'is-down';
@@ -94,6 +124,7 @@ const emptyPortfolioForm = {
const TAB_PORTFOLIO = 'portfolio';
const TAB_AI = 'ai';
const TAB_REPORT = 'report';
/* ── component ───────────────────────────────────────────────────── */
@@ -124,6 +155,30 @@ const StockTrade = () => {
/* Portfolio delete */
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 */
/* ────────────────────────────────────────────────────────────── */
@@ -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 () => {
setBalanceLoading(true);
setBalanceError('');
@@ -180,9 +252,31 @@ const StockTrade = () => {
loadPortfolio();
} else if (activeTab === TAB_AI && !balanceLoaded) {
loadBalance();
} else if (activeTab === TAB_REPORT && !portfolioLoaded) {
loadPortfolio();
}
}, [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 (포트폴리오 탭 활성 시) */
useEffect(() => {
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 ────────────────────────────────────────────── */
const submitManualOrder = async (event) => {
@@ -327,6 +541,9 @@ const StockTrade = () => {
const portfolioHoldings = portfolio?.holdings ?? [];
const portfolioSummary = portfolio?.summary ?? {};
const cashList = portfolio?.cash ?? [];
const totalCash = portfolioSummary.total_cash ?? null;
const totalAssets = portfolioSummary.total_assets ?? null;
const brokerGroups = useMemo(() => {
const map = {};
for (const item of portfolioHoldings) {
@@ -370,6 +587,62 @@ const StockTrade = () => {
return map;
}, [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 ───────────────────────────────────────────────────── */
return (
@@ -390,11 +663,9 @@ const StockTrade = () => {
</div>
<div className="stock-card">
<p className="stock-card__title">
{activeTab === TAB_PORTFOLIO
? '쟁승토리 계좌 요약'
: 'AI 투자 요약'}
{activeTab === TAB_AI ? 'AI 투자 요약' : '쟁승토리 계좌 요약'}
</p>
{activeTab === TAB_PORTFOLIO ? (
{activeTab === TAB_PORTFOLIO || activeTab === TAB_REPORT ? (
/* Portfolio summary */
<div className="stock-status">
<div>
@@ -424,6 +695,22 @@ const StockTrade = () => {
<span>보유 종목</span>
<strong>{portfolioHoldings.length}</strong>
</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>
) : (
/* AI balance summary */
@@ -472,6 +759,15 @@ const StockTrade = () => {
<span className="stock-main-tab__label">AI 투자</span>
<span className="stock-main-tab__sub">모의투자</span>
</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>
{/* ════════════════════════════════════════════════════════
@@ -612,8 +908,100 @@ const StockTrade = () => {
</strong>
</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>
)}
</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 */}
@@ -649,6 +1037,16 @@ const StockTrade = () => {
{formatNumber(bSummary.totalProfit)} (
{formatPercent(bSummary.totalProfitRate)})
</span>
{(() => {
const bc = cashList.find(
(c) => c.broker === broker
);
return bc ? (
<span className="pf-cash-badge">
예수금 {formatNumber(bc.cash)}
</span>
) : null;
})()}
</p>
</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 &amp; 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 */}
{kisModal ? (
<div className="stock-modal" role="dialog" aria-modal="true">

View File

@@ -14,7 +14,7 @@
text-transform: uppercase;
letter-spacing: 0.3em;
font-size: 12px;
color: var(--accent);
color: var(--accent-travel);
margin: 0 0 10px;
}
@@ -88,10 +88,11 @@
.travel-map__canvas {
width: 100%;
min-height: 520px;
border-radius: 22px;
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(10, 12, 20, 0.6);
box-shadow: var(--shadow-md);
}
@media (max-width: 768px) {
@@ -118,7 +119,7 @@
font-size: 14px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--accent);
color: var(--accent-travel);
}
.travel-map__desc {
@@ -303,7 +304,7 @@
font-size: 15px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #f1c07a;
color: var(--accent-travel);
}
.travel-modal__summary-meta {

View File

@@ -1,4 +1,12 @@
import React, { lazy } from 'react';
import {
IconHome,
IconBlog,
IconLotto,
IconStock,
IconTravel,
IconLab,
} from './components/Icons';
const Home = lazy(() => import('./pages/home/Home'));
const Blog = lazy(() => import('./pages/blog/Blog'));
@@ -14,36 +22,48 @@ export const navLinks = [
label: 'Home',
path: '/',
description: '첫 인상과 최신 업데이트를 모아둔 허브',
icon: <IconHome />,
accent: '#f7a8a5',
},
{
id: 'blog',
label: 'Blog',
path: '/blog',
description: '생각과 기록, 코드 스니펫을 모으는 공간',
icon: <IconBlog />,
accent: '#c084fc',
},
{
id: 'lotto',
label: 'Lotto',
path: '/lotto',
description: '숫자를 뽑고 통계를 확인하는 실험실',
icon: <IconLotto />,
accent: '#34d399',
},
{
id: 'stock',
label: 'Stock',
path: '/stock',
description: '아침 시장 흐름을 확인하는 주식 연구실',
icon: <IconStock />,
accent: '#60a5fa',
},
{
id: 'travel',
label: 'Travel',
path: '/travel',
description: '여행에서 담은 색과 장면을 전시하는 갤러리',
icon: <IconTravel />,
accent: '#fb923c',
},
{
id: 'lab',
label: 'Lab',
path: '/lab',
description: '실험적인 UI/UX 효과를 테스트하는 공간',
icon: <IconLab />,
accent: '#fbbf24',
},
];

View File

@@ -14,6 +14,18 @@ export default defineConfig({
changeOrigin: true,
secure: true,
},
// Fear & Greed Index (CNN 공개 API)
// 프로덕션 nginx에서는 아래 proxy_pass 추가 필요:
// location /ext/feargreed {
// proxy_pass https://production.dataviz.cnn.io/index/fearandgreed/graphdata;
// proxy_set_header Host production.dataviz.cnn.io;
// }
'/ext/feargreed': {
target: 'https://production.dataviz.cnn.io',
changeOrigin: true,
secure: true,
rewrite: () => '/index/fearandgreed/graphdata',
},
},
},
})