diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..47b3190
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,7 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(npm run:*)"
+ ]
+ }
+}
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..abd6885
--- /dev/null
+++ b/CLAUDE.md
@@ -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 스펙 등) |
diff --git a/package-lock.json b/package-lock.json
index 9994b8a..1e8d924 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 6d62014..7c239e9 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/src/App.css b/src/App.css
index 57c0ade..c7da39d 100644
--- a/src/App.css
+++ b/src/App.css
@@ -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;
-}
\ No newline at end of file
+.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;
+ }
+}
diff --git a/src/App.jsx b/src/App.jsx
index 49b766f..1530411 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -8,11 +8,13 @@ function App() {
return (
-
-
}>
-
-
-
+
+
+
}>
+
+
+
+
);
}
diff --git a/src/api.js b/src/api.js
index ccda8be..734ca2a 100644
--- a/src/api.js
+++ b/src/api.js
@@ -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();
+}
diff --git a/src/components/Icons.jsx b/src/components/Icons.jsx
new file mode 100644
index 0000000..2d3905f
--- /dev/null
+++ b/src/components/Icons.jsx
@@ -0,0 +1,61 @@
+const S = {
+ fill: 'none',
+ stroke: 'currentColor',
+ strokeWidth: '1.6',
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+};
+
+const svg = (children) => (
+
+);
+
+export const IconHome = () =>
+ svg(
+ <>
+
+
+ >
+ );
+
+export const IconBlog = () =>
+ svg(
+ <>
+
+
+ >
+ );
+
+export const IconLotto = () =>
+ svg(
+ <>
+
+
+
+
+
+
+ >
+ );
+
+export const IconStock = () =>
+ svg(
+ <>
+
+
+ >
+ );
+
+export const IconTravel = () =>
+ svg();
+
+export const IconLab = () =>
+ svg(
+ <>
+
+
+
+ >
+ );
diff --git a/src/components/Loading.css b/src/components/Loading.css
index 0fa3ae3..bc859cc 100644
--- a/src/components/Loading.css
+++ b/src/components/Loading.css
@@ -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%
+ 90deg,
+ 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;
diff --git a/src/components/Navbar.css b/src/components/Navbar.css
index 30b50d7..11db13a 100644
--- a/src/components/Navbar.css
+++ b/src/components/Navbar.css
@@ -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);
-}
-
-.site-nav__inner {
- max-width: 1200px;
- margin: 0 auto;
- padding: 18px 20px;
+ bottom: 0;
+ width: var(--sidebar-w);
+ z-index: 200;
display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
+ 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__brand {
- display: flex;
- align-items: center;
- gap: 14px;
-}
+/* ── 브랜드 섹션 ─────────────────────────────────────────────────────── */
-.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);
-}
-
-.site-nav__logo {
- width: 42px;
- height: 42px;
- border-radius: 14px;
- display: grid;
- place-items: center;
- font-family: var(--font-display);
- font-size: 20px;
- color: #1b1a24;
- background: linear-gradient(135deg, #fdd4b1, #f7a8a5);
- box-shadow: 0 8px 18px rgba(0, 0, 0, 0.25);
-}
-
-.site-nav__title {
- margin: 0;
- font-weight: 600;
- letter-spacing: 0.02em;
-}
-
-.site-nav__subtitle {
- margin: 4px 0 0;
- font-size: 12px;
- color: var(--muted);
-}
-
-.site-nav__links {
+.sidebar__brand {
display: flex;
align-items: center;
gap: 12px;
- flex-wrap: wrap;
+ padding: 20px 16px;
+ flex-shrink: 0;
}
-.site-nav__link {
+.sidebar__logo {
+ width: 38px;
+ height: 38px;
+ border-radius: 12px;
+ object-fit: cover;
+ flex-shrink: 0;
+ box-shadow:
+ 0 0 0 1px rgba(0, 212, 255, 0.2),
+ 0 0 12px rgba(0, 212, 255, 0.15),
+ 0 4px 12px rgba(0, 0, 0, 0.4);
+}
+
+.sidebar__brand-text {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.sidebar__brand-name {
+ margin: 0;
+ font-family: 'Space Grotesk', 'Manrope', sans-serif;
+ font-weight: 700;
+ font-size: 15px;
+ color: var(--text-bright);
+ letter-spacing: 0.01em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.sidebar__brand-sub {
+ margin: 0;
+ font-size: 10px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.18em;
+ color: var(--neon-cyan);
+ white-space: nowrap;
+}
+
+/* ── 구분선 ──────────────────────────────────────────────────────────── */
+
+.sidebar__divider {
+ height: 1px;
+ background: var(--line, rgba(255, 255, 255, 0.1));
+ margin: 8px 0;
+ flex-shrink: 0;
+}
+
+/* ── 네비게이션 ──────────────────────────────────────────────────────── */
+
+.sidebar__nav {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 4px 0;
+ /* 스크롤바 숨김 */
+ scrollbar-width: none;
+}
+
+.sidebar__nav::-webkit-scrollbar {
+ display: none;
+}
+
+.sidebar__section-label {
+ margin: 0;
+ padding: 8px 24px 4px;
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.2em;
+ color: var(--text-muted);
+}
+
+/* ── 네비게이션 아이템 ───────────────────────────────────────────────── */
+
+.sidebar__item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 14px;
+ border-radius: var(--radius-sm, 12px);
+ margin: 2px 10px;
text-decoration: none;
+ color: var(--text-dim);
font-size: 14px;
- letter-spacing: 0.02em;
- color: var(--text);
- padding: 8px 12px;
- border-radius: 999px;
+ font-weight: 500;
+ font-family: var(--font-body, 'Manrope', sans-serif);
border: 1px solid transparent;
- transition: all 0.2s ease;
+ position: relative;
+ transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
+ overflow: hidden;
}
-.site-nav__link:hover {
- border-color: rgba(255, 255, 255, 0.18);
- background: rgba(255, 255, 255, 0.06);
+.sidebar__item:hover {
+ background: rgba(255, 255, 255, 0.05);
+ color: var(--text, #f0ebe4);
+ border-color: rgba(255, 255, 255, 0.08);
}
-.site-nav__link.is-active {
- border-color: rgba(247, 168, 165, 0.6);
- background: rgba(247, 168, 165, 0.16);
- color: #ffe9e2;
+/* 활성 아이템 */
+.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);
}
-@media (max-width: 800px) {
- .site-nav__inner {
- flex-direction: column;
- align-items: flex-start;
- }
+/* 활성 아이템 좌측 네온 바 */
+.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;
+}
+
+.sidebar__status {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+}
+
+.sidebar__status-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #34d399;
+ box-shadow: 0 0 6px rgba(52, 211, 153, 0.8), 0 0 12px rgba(52, 211, 153, 0.4);
+ flex-shrink: 0;
+ animation: pulse-dot 2.4s ease-in-out infinite;
+}
+
+@keyframes pulse-dot {
+ 0%, 100% { opacity: 1; box-shadow: 0 0 6px rgba(52, 211, 153, 0.8), 0 0 12px rgba(52, 211, 153, 0.4); }
+ 50% { opacity: 0.7; box-shadow: 0 0 3px rgba(52, 211, 153, 0.5), 0 0 6px rgba(52, 211, 153, 0.2); }
+}
+
+.sidebar__status-text {
+ font-size: 11px;
+ color: var(--text-muted);
+ font-weight: 500;
+ letter-spacing: 0.02em;
+}
+
+.sidebar__version {
+ margin: 0;
+ font-size: 10px;
+ color: var(--text-muted);
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
+ letter-spacing: 0.05em;
+ opacity: 0.6;
+}
+
+/* ── 모바일 토글 버튼 ────────────────────────────────────────────────── */
+
+.sidebar-toggle {
+ display: none;
+ position: fixed;
+ top: 10px;
+ left: 10px;
+ z-index: 201;
+ width: 40px;
+ height: 40px;
+ border-radius: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(7, 12, 28, 0.88);
+ backdrop-filter: blur(12px) saturate(1.4);
+ -webkit-backdrop-filter: blur(12px) saturate(1.4);
+ cursor: pointer;
+ padding: 0;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
+}
+
+.sidebar-toggle:hover {
+ background: rgba(0, 212, 255, 0.1);
+ border-color: rgba(0, 212, 255, 0.25);
+ box-shadow: 0 2px 16px rgba(0, 0, 0, 0.4), 0 0 8px rgba(0, 212, 255, 0.15);
+}
+
+.sidebar-toggle__icon {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 5px;
+ width: 18px;
+ height: 18px;
+}
+
+.sidebar-toggle__icon span {
+ display: block;
+ width: 16px;
+ height: 1.5px;
+ background: var(--text-bright, #ffffff);
+ border-radius: 2px;
+ transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
+ opacity 0.28s ease,
+ width 0.28s ease;
+ transform-origin: center;
+}
+
+.sidebar-toggle__icon.is-open span:nth-child(1) {
+ transform: translateY(6.5px) rotate(45deg);
+}
+
+.sidebar-toggle__icon.is-open span:nth-child(2) {
+ opacity: 0;
+ width: 0;
+}
+
+.sidebar-toggle__icon.is-open span:nth-child(3) {
+ transform: translateY(-6.5px) rotate(-45deg);
+}
+
+/* ── 오버레이 ────────────────────────────────────────────────────────── */
+
+.sidebar__overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 199;
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.3s ease;
+}
+
+.sidebar__overlay.is-visible {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+/* ── 모바일 반응형 ───────────────────────────────────────────────────── */
+
@media (max-width: 768px) {
- .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;
}
}
diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx
index 3b0aa56..dfd362f 100644
--- a/src/components/Navbar.jsx
+++ b/src/components/Navbar.jsx
@@ -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 (
-
-
-
-

-
-
Jaeoh Archive
-
Stories, notes, and snapshots
+ <>
+ {/* 모바일 오버레이 */}
+
+
+ {/* 모바일 토글 버튼 */}
+
+
+ {/* 사이드바 본체 */}
+
-
+
+ {/* 사이드바 푸터 */}
+
+
+
+
+
+ System Online
+
+
v2.0.0
+
+
+
+ >
);
};
diff --git a/src/index.css b/src/index.css
index f7110dd..cedc139 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,35 +1,241 @@
-@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Manrope:wght@300;400;500;600;700&display=swap');
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
-* {
+/* ── Reset ───────────────────────────────────────────────────────────── */
+
+*,
+*::before,
+*::after {
box-sizing: border-box;
+ 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;
+ }
}
diff --git a/src/pages/blog/Blog.css b/src/pages/blog/Blog.css
index 3bd5808..5e64e12 100644
--- a/src/pages/blog/Blog.css
+++ b/src/pages/blog/Blog.css
@@ -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);
}
diff --git a/src/pages/home/Home.css b/src/pages/home/Home.css
index 8a9ff32..6903891 100644
--- a/src/pages/home/Home.css
+++ b/src/pages/home/Home.css
@@ -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;
}
}
diff --git a/src/pages/home/Home.jsx b/src/pages/home/Home.jsx
index d173007..39e7491 100644
--- a/src/pages/home/Home.jsx
+++ b/src/pages/home/Home.jsx
@@ -14,7 +14,7 @@ const Home = () => {
Personal Archive
-
기록을 모으고, 이야기를 이어붙이는 작은 집.
+
기록을 모으고,
이야기를 이어붙이는 작은 집.
개발, 여행 스냅, 그리고 생각을 모아두는 공간입니다.
@@ -28,7 +28,7 @@ const Home = () => {
-
이번 달 집중 테마
+
이번 달 집중 테마
느린 기록, 깊은 회고
@@ -37,13 +37,13 @@ const Home = () => {
-
+
게시 글
-
{posts.length}편
+
{posts.length}편
-
@@ -56,12 +56,23 @@ const Home = () => {
{highlights.map((item) => (
-
-
+
+
+ {item.icon}
+
+
{item.label}
{item.description}
-
열기
+
→
))}
@@ -75,8 +86,11 @@ const Home = () => {
{posts.map((post) => (
-
{post.title}
-
{post.excerpt}
+
+
+
{post.title}
+
{post.excerpt}
+
{post.date || '작성일 미정'}
))}
@@ -110,31 +124,26 @@ const Home = () => {
연혁
-
- 2023.02 - 현재
+ 2023.02 - 현재
Server Developer
- 내비 TIS 교통 서버/현대오토에버
+ 내비 TIS 교통 서버 / 현대오토에버
-
- 2020.01 - 2023.02
+ 2020.01 - 2023.02
Embedded Device SW Developer
- 캐시비 단말기 개발/롯데정보통신
+ 캐시비 단말기 개발 / 롯데정보통신
-
- 2019.07 - 2019.12
+ 2019.07 - 2019.12
SSAFY - 삼성 SW Academy
- SSAFY
+ SSAFY 1기 수료
- C++
- Git
- AWS
- Jira
- MySQL
- Docker
- Kubernetes
- Linux
+ {['C++', 'Git', 'AWS', 'Jira', 'MySQL', 'Docker', 'Kubernetes', 'Linux'].map((tag) => (
+ {tag}
+ ))}
diff --git a/src/pages/lotto/Lotto.css b/src/pages/lotto/Lotto.css
index 3a7007b..20aee89 100644
--- a/src/pages/lotto/Lotto.css
+++ b/src/pages/lotto/Lotto.css
@@ -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 {
diff --git a/src/pages/stock/Stock.css b/src/pages/stock/Stock.css
index 5a0d312..51024a0 100644
--- a/src/pages/stock/Stock.css
+++ b/src/pages/stock/Stock.css
@@ -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 {
@@ -918,4 +927,526 @@
.pf-total-summary__card strong {
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;
+ }
}
\ No newline at end of file
diff --git a/src/pages/stock/StockTrade.jsx b/src/pages/stock/StockTrade.jsx
index a8db1d2..c798655 100644
--- a/src/pages/stock/StockTrade.jsx
+++ b/src/pages/stock/StockTrade.jsx
@@ -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 = () => {
- {activeTab === TAB_PORTFOLIO
- ? '쟁승토리 계좌 요약'
- : 'AI 투자 요약'}
+ {activeTab === TAB_AI ? 'AI 투자 요약' : '쟁승토리 계좌 요약'}
- {activeTab === TAB_PORTFOLIO ? (
+ {activeTab === TAB_PORTFOLIO || activeTab === TAB_REPORT ? (
/* Portfolio summary */
@@ -424,6 +695,22 @@ const StockTrade = () => {
보유 종목
{portfolioHoldings.length}
+ {totalCash != null && (
+
+ 예수금 합계
+
+ {formatNumber(totalCash)}원
+
+
+ )}
+ {totalAssets != null && (
+
+ 총 자산
+
+ {formatNumber(totalAssets)}원
+
+
+ )}
) : (
/* AI balance summary */
@@ -472,6 +759,15 @@ const StockTrade = () => {
AI 투자
모의투자
+
{/* ════════════════════════════════════════════════════════
@@ -612,10 +908,102 @@ const StockTrade = () => {
))}
+ {totalCash != null && (
+
+ 예수금 합계
+ {formatNumber(totalCash)}원
+
+ )}
+ {totalAssets != null && (
+
+ 총 자산
+ {formatNumber(totalAssets)}원
+
+ )}
)}
+ {/* 예수금 패널 */}
+
+
+
+
예수금 관리
+
증권사별 예수금
+
+ 증권사별 예수금을 입력하면 총 자산에 자동 반영됩니다.
+
+
+
+
+ {cashList.length > 0 && (
+
+ {cashList.map((item) => (
+
+ {item.broker}
+
+ {formatNumber(item.cash)}원
+
+
+ {item.updated_at
+ ? new Date(item.updated_at).toLocaleDateString('ko-KR')
+ : ''}
+
+
+
+ ))}
+
+ )}
+ {cashList.length === 0 && (
+
+ 등록된 예수금이 없습니다.
+
+ )}
+
+
+
+
{/* Broker cards stacked */}
{brokerGroups.map(([broker, items]) => {
const bSummary = getBrokerSummary(items);
@@ -649,6 +1037,16 @@ const StockTrade = () => {
{formatNumber(bSummary.totalProfit)} (
{formatPercent(bSummary.totalProfitRate)})
+ {(() => {
+ const bc = cashList.find(
+ (c) => c.broker === broker
+ );
+ return bc ? (
+
+ 예수금 {formatNumber(bc.cash)}원
+
+ ) : null;
+ })()}
@@ -1044,6 +1442,344 @@ const StockTrade = () => {
>
)}
+ {/* ════════════════════════════════════════════════════════
+ TAB 3: 리포트 + AI 코치
+ ════════════════════════════════════════════════════════ */}
+ {activeTab === TAB_REPORT && (
+ <>
+ {portfolioLoading && (
+
+
+
+ )}
+ {portfolioError && {portfolioError}
}
+
+ {/* ── Fear & Greed Index ─────────────────────────── */}
+
+
+
+
시장 심리 지표
+
Fear & Greed Index
+
+ CNN Fear & Greed 지수로 현재 시장 심리를 파악합니다.
+
+
+
+
+
+
+ {fgError && {fgError}
}
+ {fgData ? (
+
+
+
+
+ 극단적 공포
+ 공포
+ 중립
+ 탐욕
+ 극단적 탐욕
+
+
+
+
+ {Math.round(fgData.score)}
+
+
+ {getFgLabel(fgData.score)}
+
+ {fgData.timestamp && (
+
+ {new Date(fgData.timestamp).toLocaleDateString('ko-KR')}
+
+ )}
+
+
+ ) : !fgError ? (
+ 지수 데이터를 불러오는 중...
+ ) : null}
+
+
+ {/* ── 자산 배분 + 수익률 차트 ────────────────────── */}
+ {portfolioHoldings.length > 0 && (
+
+
+
+
+
증권사별 자산 배분
+
+
+
+ {brokerPieData.map((_, i) => (
+ |
+ ))}
+
+ [formatNumber(v) + '원', '평가금액']}
+ contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8, fontSize: 12 }}
+ />
+
+
+
+
+
종목별 수익률 (%)
+
+
+
+
+ `${v}%`}
+ />
+ [`${v.toFixed(2)}%`, props.payload.fullName]}
+ contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8, fontSize: 12 }}
+ />
+
+ {profitBarData.map((entry, i) => (
+ = 0 ? '#34d399' : '#f87171'} />
+ ))}
+ |
+
+
+
+
+
+ )}
+
+ {/* ── 수익률 랭킹 테이블 ─────────────────────────── */}
+ {portfolioHoldings.length > 0 && (
+
+
+
+
수익률 랭킹
+
종목별 상세 현황
+
헤더 클릭으로 정렬
+
+
+
+
+
+
+ {[
+ { key: 'name', label: '종목명' },
+ { key: 'broker', label: '증권사' },
+ { key: 'profit_rate', label: '수익률' },
+ { key: 'profit_amount', label: '평가손익' },
+ { key: 'eval_amount', label: '평가금액' },
+ ].map(({ key, label }) => (
+ | handleReportSort(key)}>
+ {label}{' '}
+
+ {reportSortField === key
+ ? reportSortDir === 'asc' ? '↑' : '↓'
+ : '↕'}
+
+ |
+ ))}
+
+
+
+ {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 (
+
+ |
+ {item.name ?? item.ticker ?? 'N/A'}
+ {item.ticker ?? ''}
+ |
+ {item.broker ?? '-'} |
+
+
+ {item.profit_rate != null ? formatPercent(item.profit_rate) : '-'}
+ {rateN != null && (
+
+ = 0 ? 'is-up' : 'is-down'}`}
+ style={{ width: `${maxAbsRate > 0 ? Math.abs(rateN) / maxAbsRate * 100 : 0}%` }}
+ />
+
+ )}
+
+ |
+
+ {item.profit_amount != null ? formatNumber(item.profit_amount) : '-'}
+ |
+
+ {evalAmt != null ? formatNumber(evalAmt) : '-'}
+ |
+
+ );
+ })}
+
+
+
+
+ )}
+
+ {portfolioLoaded && portfolioHoldings.length === 0 && !portfolioError && (
+
+
+ 등록된 종목이 없습니다. 쟁승토리 계좌 탭에서 종목을 먼저 등록하세요.
+
+
+ )}
+
+ {/* ── AI 투자 코치 ───────────────────────────────── */}
+
+
+
+
AI 투자 코치
+
오늘의 투자 평가
+
+ 포트폴리오를 AI가 분석하여 성취도 등급과 내일을 위한 투자 조언을 드립니다.
+
+
+
+
+ {/* API Key 설정 */}
+
+
+
+
+
+
+
+ {portfolioHoldings.length === 0 && (
+ 종목 등록 후 이용 가능합니다.
+ )}
+ {aiResult?.generated_at && (
+
+ {aiResult.cached ? '오늘 캐시 결과 · ' : ''}
+ {new Date(aiResult.generated_at).toLocaleTimeString('ko-KR')} 생성
+
+ )}
+
+
+ {aiError && {aiError}
}
+
+ {aiResult && !aiLoading && (
+
+
+
+ {aiResult.grade ?? '?'}
+
+
+ {aiResult.score ?? 0}
+ / 100
+
+
{aiResult.summary}
+
+
{aiResult.evaluation}
+ {aiResult.advice?.length > 0 && (
+
+ {aiResult.advice.map((a, i) => (
+
+ ))}
+
+ )}
+
+
+ )}
+
+ >
+ )}
+
{/* KIS modal */}
{kisModal ? (
diff --git a/src/pages/travel/Travel.css b/src/pages/travel/Travel.css
index e75d708..cd25772 100644
--- a/src/pages/travel/Travel.css
+++ b/src/pages/travel/Travel.css
@@ -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 {
diff --git a/src/routes.jsx b/src/routes.jsx
index 6ee3ebf..a8adde6 100644
--- a/src/routes.jsx
+++ b/src/routes.jsx
@@ -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: ,
+ accent: '#f7a8a5',
},
{
id: 'blog',
label: 'Blog',
path: '/blog',
description: '생각과 기록, 코드 스니펫을 모으는 공간',
+ icon: ,
+ accent: '#c084fc',
},
{
id: 'lotto',
label: 'Lotto',
path: '/lotto',
description: '숫자를 뽑고 통계를 확인하는 실험실',
+ icon: ,
+ accent: '#34d399',
},
{
id: 'stock',
label: 'Stock',
path: '/stock',
description: '아침 시장 흐름을 확인하는 주식 연구실',
+ icon: ,
+ accent: '#60a5fa',
},
{
id: 'travel',
label: 'Travel',
path: '/travel',
description: '여행에서 담은 색과 장면을 전시하는 갤러리',
+ icon: ,
+ accent: '#fb923c',
},
{
id: 'lab',
label: 'Lab',
path: '/lab',
description: '실험적인 UI/UX 효과를 테스트하는 공간',
+ icon: ,
+ accent: '#fbbf24',
},
];
diff --git a/vite.config.js b/vite.config.js
index 3ecd99d..edffe2d 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -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',
+ },
},
},
})