From 618d5f8e6f10b6f0929990ca534552ca5d91078e Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 4 Mar 2026 01:39:26 +0900 Subject: [PATCH] =?UTF-8?q?UI=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EB=8C=80?= =?UTF-8?q?=EB=8C=80=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=ED=98=95=ED=83=9C=EC=9D=98=20=EC=A0=84?= =?UTF-8?q?=EB=AC=B8=EC=A0=81=EC=9D=B8=20=EB=8A=90=EB=82=8C=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=AC=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 7 + CLAUDE.md | 149 +++++++ package-lock.json | 413 +++++++++++++++++- package.json | 1 + src/App.css | 492 ++++++++++++++++++++-- src/App.jsx | 12 +- src/api.js | 19 + src/components/Icons.jsx | 61 +++ src/components/Loading.css | 47 ++- src/components/Navbar.css | 427 ++++++++++++++----- src/components/Navbar.jsx | 83 +++- src/index.css | 236 ++++++++++- src/pages/blog/Blog.css | 33 +- src/pages/home/Home.css | 492 ++++++++++++++++------ src/pages/home/Home.jsx | 59 +-- src/pages/lotto/Lotto.css | 10 +- src/pages/stock/Stock.css | 547 +++++++++++++++++++++++- src/pages/stock/StockTrade.jsx | 744 ++++++++++++++++++++++++++++++++- src/pages/travel/Travel.css | 9 +- src/routes.jsx | 20 + vite.config.js | 12 + 21 files changed, 3499 insertions(+), 374 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 CLAUDE.md create mode 100644 src/components/Icons.jsx 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) => ( + + {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 ( -
-
-
- Logo -
-

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 && ( +

+ 등록된 예수금이 없습니다. +

+ )} + +
+ + + + {cashError &&

{cashError}

} +
+
+ {/* 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}%`} + /> + [`${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 }) => ( + + ))} + + + + {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 ( + + + + + + + + ); + })} + +
handleReportSort(key)}> + {label}{' '} + + {reportSortField === key + ? reportSortDir === 'asc' ? '↑' : '↓' + : '↕'} + +
+

{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) => ( +
+

{a.title}

+

{a.body}

+
+ ))} +
+ )} + +
+ )} +
+ + )} + {/* 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', + }, }, }, })