Compare commits
26 Commits
837408423e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d78b2c430 | |||
| bdb055cb32 | |||
| d7e7ccdb16 | |||
| 8fc7c2cb70 | |||
| 7d01c72e58 | |||
| 9ab45b64b6 | |||
| 22897c3eb6 | |||
| 5f4742085c | |||
| 5dab3d99c1 | |||
| b559eeda58 | |||
| 07b43c48c1 | |||
| 9d8af6b03b | |||
| d53f581c58 | |||
| bca9724a4b | |||
| d67f925878 | |||
| 472a55c0a7 | |||
| 80a61e74ee | |||
| b76e0ef779 | |||
| bd8b4dd425 | |||
| dcd2910cea | |||
| 2495feef3e | |||
| 57133a38db | |||
| c44bcb4a0f | |||
| 846b353a37 | |||
| d713d2c4a7 | |||
| d4ec482289 |
29
README.md
29
README.md
@@ -1,16 +1,27 @@
|
|||||||
# React + Vite
|
# Web UI
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
블로그, 로또 추천 실험, 여행 기록을 한 곳에서 모아 보는 개인 웹 UI입니다.
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
## 블로그
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
- 마크다운 기반 글 작성 및 자동 목록화 (`src/content/blog`)
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
- 태그 기반 카테고리 분류와 카테고리별 목록 뷰
|
||||||
|
- 목록/본문 분리 UI, 페이지네이션 지원
|
||||||
|
- 인라인 스타일(링크/강조/코드/이미지) 렌더링 지원
|
||||||
|
|
||||||
## React Compiler
|
## Lotto Lab
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
- 최신 로또 결과 조회
|
||||||
|
- 추천 번호 생성 (가중치/최근 회차/회피 수 등 파라미터 반영)
|
||||||
|
- 프리셋 파라미터로 빠른 추천 생성
|
||||||
|
- 추천 히스토리 목록 확인 및 삭제
|
||||||
|
- 번호 복사 기능
|
||||||
|
- API 스펙 다운로드 링크 제공 (`public/lotto-api.md`)
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
## 여행 기록 (Travel Archive)
|
||||||
|
|
||||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
- 지도 기반 지역 선택 (GeoJSON)
|
||||||
|
- 선택한 지역의 사진 목록 로딩 및 캐시
|
||||||
|
- 스크롤 기반 사진 추가 로딩 (chunked lazy load)
|
||||||
|
- 썸네일/모달 뷰, 키보드/스와이프 네비게이션
|
||||||
|
- 앨범/파일 메타 정보 표시
|
||||||
|
|||||||
130
package-lock.json
generated
130
package-lock.json
generated
@@ -8,14 +8,17 @@
|
|||||||
"name": "web-ui",
|
"name": "web-ui",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.0",
|
"leaflet": "^1.9.4",
|
||||||
"react-dom": "^19.2.0",
|
"react": "^18.2.0",
|
||||||
"react-router-dom": "^6.30.3"
|
"react-dom": "^18.2.0",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
|
"react-router-dom": "^6.30.3",
|
||||||
|
"three": "^0.182.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^18.2.79",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^18.2.25",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
@@ -56,7 +59,6 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -1032,6 +1034,17 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-leaflet/core": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@remix-run/router": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.2",
|
"version": "1.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||||
@@ -1457,25 +1470,32 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/prop-types": {
|
||||||
|
"version": "15.7.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
|
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.8",
|
"version": "18.2.79",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.79.tgz",
|
||||||
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
|
"integrity": "sha512-RwGAGXPl9kSXwdNTafkOEuFrTBD5SA2B3iEB96xi8+xu5ddUa/cpvyVCSNn+asgLCTHkb5ZxN8gbuibYJi4s1w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"@types/prop-types": "*",
|
||||||
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "19.2.3",
|
"version": "18.2.25",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.25.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"dependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
@@ -1505,7 +1525,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1611,7 +1630,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -1833,7 +1851,6 @@
|
|||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -2285,7 +2302,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
@@ -2358,6 +2374,12 @@
|
|||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -2395,6 +2417,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/loose-envify": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"loose-envify": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -2598,7 +2632,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -2656,26 +2689,42 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.3",
|
"version": "18.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.1.0"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.3",
|
"version": "18.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"loose-envify": "^1.1.0",
|
||||||
|
"scheduler": "^0.23.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.2.3"
|
"react": "^18.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-leaflet": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-leaflet/core": "^2.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
@@ -2796,10 +2845,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.1.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
@@ -2870,6 +2922,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.182.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
|
||||||
|
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -2947,7 +3005,6 @@
|
|||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -3069,7 +3126,6 @@
|
|||||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
15
package.json
15
package.json
@@ -7,20 +7,23 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"clean": "rimraf dist",
|
"clean": "rimraf dist",
|
||||||
"build": "npm run clean && vite build",
|
"build": "npm run clean && vite build",
|
||||||
"deploy:nas": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$ErrorActionPreference='Stop'; $src='dist'; $dst='Z:\\\\docker\\\\webpage\\\\frontend\\\\'; if(!(Test-Path $src)){ throw 'dist not found. Run build first.' }; if(!(Test-Path $dst)){ throw 'NAS drive not found. Check Z: mapping.' }; robocopy $src $dst /MIR /R:1 /W:1 /E /NFL /NDL /NP; if($LASTEXITCODE -ge 8){ exit $LASTEXITCODE }\"",
|
"deploy:nas": "node scripts/deploy-nas.cjs",
|
||||||
"release:nas": "npm run build && npm run deploy:nas",
|
"release:nas": "npm run build && npm run deploy:nas",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.0",
|
"leaflet": "^1.9.4",
|
||||||
"react-dom": "^19.2.0",
|
"react": "^18.2.0",
|
||||||
"react-router-dom": "^6.30.3"
|
"react-dom": "^18.2.0",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
|
"react-router-dom": "^6.30.3",
|
||||||
|
"three": "^0.182.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^18.2.79",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^18.2.25",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
|||||||
63
public/lotto-api.md
Normal file
63
public/lotto-api.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Lotto API 스펙 (프론트 참고)
|
||||||
|
|
||||||
|
## 추천 1개 생성
|
||||||
|
|
||||||
|
요청:
|
||||||
|
|
||||||
|
GET /api/lotto/recommend?recent_window=200&recent_weight=2.0&avoid_recent_k=5&sum_min=120&sum_max=180&max_overlap_latest=1
|
||||||
|
|
||||||
|
응답 핵심:
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"saved": true,
|
||||||
|
"deduped": false,
|
||||||
|
"numbers": [1,2,3,4,5,6],
|
||||||
|
"based_on_latest_draw": 1145,
|
||||||
|
"explain": {...},
|
||||||
|
"metrics": {...},
|
||||||
|
"recent_overlap": {...},
|
||||||
|
"params": {...}
|
||||||
|
}
|
||||||
|
|
||||||
|
## 히스토리 목록 (페이지/필터/검색)
|
||||||
|
|
||||||
|
기본: GET /api/history?limit=30&offset=0
|
||||||
|
즐겨찾기만: GET /api/history?favorite=true
|
||||||
|
태그 필터: GET /api/history?tag=jeju
|
||||||
|
메모 검색: GET /api/history?q=맛집
|
||||||
|
정렬: GET /api/history?sort=favorite_desc
|
||||||
|
|
||||||
|
응답:
|
||||||
|
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"created_at": "...",
|
||||||
|
"numbers": [...],
|
||||||
|
"params": {...},
|
||||||
|
"based_on_draw": 1145,
|
||||||
|
"favorite": false,
|
||||||
|
"note": null,
|
||||||
|
"tags": [],
|
||||||
|
"metrics": {...},
|
||||||
|
"recent_overlap": {...}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"limit": 30,
|
||||||
|
"offset": 0,
|
||||||
|
"filters": {...}
|
||||||
|
}
|
||||||
|
|
||||||
|
## 히스토리 수정 (즐겨찾기/메모/태그)
|
||||||
|
|
||||||
|
요청:
|
||||||
|
|
||||||
|
PATCH /api/history/{id}
|
||||||
|
|
||||||
|
{
|
||||||
|
"favorite": true,
|
||||||
|
"note": "이번주는 이 조합",
|
||||||
|
"tags": ["jeju","safe"]
|
||||||
|
}
|
||||||
98
scripts/deploy-nas.cjs
Normal file
98
scripts/deploy-nas.cjs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
const { execSync } = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const isWin = process.platform === "win32";
|
||||||
|
const isMac = process.platform === "darwin";
|
||||||
|
const src = "dist";
|
||||||
|
const dstWin = "Z:\\docker\\webpage\\frontend\\";
|
||||||
|
const dstMac = "/Volumes/gahusb.synology.me/docker/webpage/frontend/";
|
||||||
|
const dst = isWin ? dstWin : dstMac;
|
||||||
|
|
||||||
|
if (!fs.existsSync(src)) {
|
||||||
|
console.error("dist not found. Run build first.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(dst)) {
|
||||||
|
console.error("NAS path not found. Check mount: " + dst);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWin) {
|
||||||
|
const cmd =
|
||||||
|
'powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=\\"Stop\\"; $src=\\"dist\\"; $dst=\\"Z:\\\\docker\\\\webpage\\\\frontend\\\\\\"; if(!(Test-Path $src)){ throw \\"dist not found. Run build first.\\" }; if(!(Test-Path $dst)){ throw \\"NAS drive not found. Check Z: mapping.\\" }; $log = Join-Path (Get-Location) \\"robocopy.log\\"; robocopy $src $dst /MIR /R:1 /W:1 /E /NFL /NDL /NP /V /TEE /LOG:$log; $rc = $LASTEXITCODE; if($rc -ge 8){ Write-Host \\"robocopy failed with code $rc. See $log\\"; exit $rc } else { exit 0 }"';
|
||||||
|
execSync(cmd, { stdio: "inherit" });
|
||||||
|
} else if (isMac) {
|
||||||
|
const sshTarget = process.env.NAS_SSH_TARGET;
|
||||||
|
const sshPath =
|
||||||
|
process.env.NAS_SSH_PATH || "/volume1/docker/webpage/frontend/";
|
||||||
|
const sshPort = process.env.NAS_SSH_PORT;
|
||||||
|
if (sshTarget) {
|
||||||
|
const sshCmd = sshPort ? `ssh -p ${sshPort}` : "ssh";
|
||||||
|
execSync(
|
||||||
|
`rsync -r --delete --delete-delay -e \"${sshCmd}\" ${src}/ ${sshTarget}:${sshPath}`,
|
||||||
|
{ stdio: "inherit" }
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
// rsync on macOS + SMB/NAS can be flaky; use ditto after a safe clean.
|
||||||
|
if (!dst.includes("docker/webpage/frontend")) {
|
||||||
|
console.error("Safety check failed: unexpected dst path: " + dst);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const testPath = `${dst}.deploy-write-test`;
|
||||||
|
fs.writeFileSync(testPath, "ok");
|
||||||
|
fs.unlinkSync(testPath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
"NAS write test failed. Files may be locked or permissions are read-only."
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
"Try stopping services using the folder, remounting the share with write access,",
|
||||||
|
"or set NAS_SSH_TARGET to deploy over SSH instead."
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const sleep = (ms) =>
|
||||||
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||||
|
const retry = (fn, attempts = 6) => {
|
||||||
|
for (let i = 0; i < attempts; i += 1) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (i === attempts - 1) throw err;
|
||||||
|
sleep(150);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const removeTree = (target) => {
|
||||||
|
const stat = fs.lstatSync(target);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
retry(() => {
|
||||||
|
const entries = fs.readdirSync(target);
|
||||||
|
for (const entry of entries) {
|
||||||
|
removeTree(`${target}/${entry}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
retry(() => fs.rmdirSync(target));
|
||||||
|
} else {
|
||||||
|
retry(() => fs.unlinkSync(target));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (process.env.NAS_CLEAN === "1") {
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(dst);
|
||||||
|
for (const entry of entries) {
|
||||||
|
removeTree(`${dst}${entry}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Clean skipped due to NAS lock:", err?.message ?? err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
execSync(`ditto ${src} ${dst}`, { stdio: "inherit" });
|
||||||
|
} else {
|
||||||
|
const baseArgs = ["rsync", "-r", "--delete", "--delete-delay", "-t"];
|
||||||
|
const cmd = `${baseArgs.join(" ")} ${src}/ ${dst}`;
|
||||||
|
execSync(cmd, { stdio: "inherit" });
|
||||||
|
}
|
||||||
15
src/App.css
15
src/App.css
@@ -20,18 +20,31 @@
|
|||||||
padding: 40px 20px 80px;
|
padding: 40px 20px 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.site-main {
|
||||||
|
padding: 20px 16px 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.suspend-loading {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes fadeUp {
|
@keyframes fadeUp {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(16px);
|
transform: translateY(16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-main > * {
|
.site-main>* {
|
||||||
animation: fadeUp 0.6s ease both;
|
animation: fadeUp 0.6s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import Navbar from './components/Navbar';
|
import Navbar from './components/Navbar';
|
||||||
|
import Loading from './components/Loading';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -8,7 +9,9 @@ function App() {
|
|||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main className="site-main">
|
<main className="site-main">
|
||||||
<Outlet />
|
<React.Suspense fallback={<div className="suspend-loading"><Loading /></div>}>
|
||||||
|
<Outlet />
|
||||||
|
</React.Suspense>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
72
src/api.js
72
src/api.js
@@ -1,6 +1,30 @@
|
|||||||
// src/api.js
|
// src/api.js
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || "";
|
||||||
|
|
||||||
|
const toApiUrl = (path) => {
|
||||||
|
if (!API_BASE) return path;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base = new URL(API_BASE, window.location.origin);
|
||||||
|
// Ensure base pathname ends with '/' if it's not the root or if likely intended as a directory
|
||||||
|
if (!base.pathname.endsWith('/')) {
|
||||||
|
base.pathname += '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove leading slash from path to avoid double slashes when joining
|
||||||
|
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
||||||
|
|
||||||
|
return new URL(cleanPath, base).toString();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Invalid VITE_API_BASE configuration:", error);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export async function apiGet(path) {
|
export async function apiGet(path) {
|
||||||
const res = await fetch(path, { headers: { "Accept": "application/json" } });
|
const res = await fetch(toApiUrl(path), {
|
||||||
|
headers: { "Accept": "application/json" },
|
||||||
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
||||||
@@ -9,7 +33,23 @@ export async function apiGet(path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function apiDelete(path) {
|
export async function apiDelete(path) {
|
||||||
const res = await fetch(path, { method: "DELETE" });
|
const res = await fetch(toApiUrl(path), { method: "DELETE" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPost(path, body) {
|
||||||
|
const res = await fetch(toApiUrl(path), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json",
|
||||||
|
...(body ? { "Content-Type": "application/json" } : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
||||||
@@ -21,6 +61,10 @@ export function getLatest() {
|
|||||||
return apiGet("/api/lotto/latest");
|
return apiGet("/api/lotto/latest");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStats() {
|
||||||
|
return apiGet("/api/lotto/stats");
|
||||||
|
}
|
||||||
|
|
||||||
export function recommend(params) {
|
export function recommend(params) {
|
||||||
const qs = new URLSearchParams({
|
const qs = new URLSearchParams({
|
||||||
recent_window: String(params.recent_window),
|
recent_window: String(params.recent_window),
|
||||||
@@ -30,10 +74,30 @@ export function recommend(params) {
|
|||||||
return apiGet(`/api/lotto/recommend?${qs.toString()}`);
|
return apiGet(`/api/lotto/recommend?${qs.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHistory(limit = 30) {
|
export function getHistory(limit = 30, offset = 0) {
|
||||||
return apiGet(`/api/history?limit=${limit}`);
|
return apiGet(`/api/history?limit=${limit}&offset=${offset}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteHistory(id) {
|
export function deleteHistory(id) {
|
||||||
return apiDelete(`/api/history/${id}`);
|
return apiDelete(`/api/history/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStockNews(limit = 20, category) {
|
||||||
|
const qs = new URLSearchParams({ limit: String(limit) });
|
||||||
|
if (category) {
|
||||||
|
qs.set("category", category);
|
||||||
|
}
|
||||||
|
return apiGet(`/api/stock/news?${qs.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStockIndices() {
|
||||||
|
return apiGet("/api/stock/indices");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTradeBalance() {
|
||||||
|
return apiGet("/api/trade/balance");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTradeOrder(payload) {
|
||||||
|
return apiPost("/api/trade/order", payload);
|
||||||
|
}
|
||||||
|
|||||||
58
src/components/Loading.css
Normal file
58
src/components/Loading.css
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
.loading-spinner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner__circle {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: var(--accent, #f7a8a5);
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner__text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted, #b6b1a9);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-skeleton {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-skeleton__line {
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
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%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/components/Loading.jsx
Normal file
23
src/components/Loading.jsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './Loading.css';
|
||||||
|
|
||||||
|
const Loading = ({ type = 'spinner', message = '로딩 중...' }) => {
|
||||||
|
if (type === 'skeleton') {
|
||||||
|
return (
|
||||||
|
<div className="loading-skeleton">
|
||||||
|
<div className="loading-skeleton__line" style={{ width: '60%' }}></div>
|
||||||
|
<div className="loading-skeleton__line" style={{ width: '80%' }}></div>
|
||||||
|
<div className="loading-skeleton__line" style={{ width: '40%' }}></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="loading-spinner">
|
||||||
|
<div className="loading-spinner__circle"></div>
|
||||||
|
{message && <p className="loading-spinner__text">{message}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Loading;
|
||||||
@@ -91,3 +91,36 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.site-nav__inner {
|
||||||
|
padding: 14px 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav__brand {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ const inferDateFromSlug = (slug) => {
|
|||||||
|
|
||||||
export const getBlogPosts = () => {
|
export const getBlogPosts = () => {
|
||||||
const modules = import.meta.glob('/src/content/blog/**/*.md', {
|
const modules = import.meta.glob('/src/content/blog/**/*.md', {
|
||||||
as: 'raw',
|
query: '?raw',
|
||||||
|
import: 'default',
|
||||||
eager: true,
|
eager: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
34
src/data/travelRegions.js
Normal file
34
src/data/travelRegions.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export const travelRegions = [
|
||||||
|
{
|
||||||
|
id: 'jeju',
|
||||||
|
label: 'Jeju',
|
||||||
|
bounds: [
|
||||||
|
[33.06, 126.1],
|
||||||
|
[33.6, 126.95],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'maldives',
|
||||||
|
label: 'Maldives',
|
||||||
|
bounds: [
|
||||||
|
[-0.7, 72.6],
|
||||||
|
[7.3, 73.9],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'osaka',
|
||||||
|
label: 'Osaka',
|
||||||
|
bounds: [
|
||||||
|
[34.4, 135.2],
|
||||||
|
[35.0, 135.8],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taipei',
|
||||||
|
label: 'Taipei',
|
||||||
|
bounds: [
|
||||||
|
[24.9, 121.3],
|
||||||
|
[25.2, 121.7],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -11,13 +11,21 @@ body {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: radial-gradient(1000px 600px at 15% 5%, rgba(247, 168, 165, 0.25), transparent 60%),
|
background: radial-gradient(2000px 1200px at 15% 5%, rgba(247, 168, 165, 0.25), transparent 70%),
|
||||||
radial-gradient(800px 600px at 85% 0%, rgba(253, 212, 177, 0.18), transparent 60%),
|
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;
|
#0f0d12;
|
||||||
|
background-attachment: fixed;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body {
|
||||||
|
background-attachment: scroll;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,29 @@
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blog-toggle-list {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
z-index: 1000;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(10, 12, 20, 0.8);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-toggle-list:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
.blog-list {
|
.blog-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -184,7 +207,10 @@
|
|||||||
|
|
||||||
.blog-article__body h1,
|
.blog-article__body h1,
|
||||||
.blog-article__body h2,
|
.blog-article__body h2,
|
||||||
.blog-article__body h3 {
|
.blog-article__body h3,
|
||||||
|
.blog-article__body h4,
|
||||||
|
.blog-article__body h5,
|
||||||
|
.blog-article__body h6 {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
margin: 22px 0 10px;
|
margin: 22px 0 10px;
|
||||||
}
|
}
|
||||||
@@ -201,6 +227,18 @@
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blog-article__body h4 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-article__body h5 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-article__body h6 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.md-paragraph {
|
.md-paragraph {
|
||||||
margin: 0 0 14px;
|
margin: 0 0 14px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -220,6 +258,11 @@
|
|||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blog-article__body del {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
.blog-article__body a {
|
.blog-article__body a {
|
||||||
color: #f7d4c9;
|
color: #f7d4c9;
|
||||||
}
|
}
|
||||||
@@ -325,4 +368,104 @@
|
|||||||
.blog-grid {
|
.blog-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blog-toggle-list {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-list {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-list.is-visible {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(6, 8, 12, 0.7);
|
||||||
|
z-index: 999;
|
||||||
|
padding: 80px 20px 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-list.is-visible .blog-category-filter {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-list.is-visible .blog-pagination {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-article {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.blog-header h1 {
|
||||||
|
font-size: clamp(24px, 6vw, 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-grid {
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-list {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-list__item {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-list__title {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-list__excerpt {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-article {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-article__body h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-article__body h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-article__body h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-article__body h4 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-article__body h5 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-article__body h6 {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-categories__grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-category-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import './Blog.css';
|
|||||||
const renderInline = (text) => {
|
const renderInline = (text) => {
|
||||||
const normalized = text.replace(/<br\s*\/?>/gi, '\n');
|
const normalized = text.replace(/<br\s*\/?>/gi, '\n');
|
||||||
const pattern =
|
const pattern =
|
||||||
/(!\[[^\]]*\]\([^)]+\)|\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g;
|
/(!\[[^\]]*\]\([^)]+\)|\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|\*[^*]+\*|~~[^~]+~~|`[^`]+`)/g;
|
||||||
const segments = normalized.split('\n');
|
const segments = normalized.split('\n');
|
||||||
|
|
||||||
return segments.flatMap((segment, segmentIndex) => {
|
return segments.flatMap((segment, segmentIndex) => {
|
||||||
@@ -48,6 +48,9 @@ const renderInline = (text) => {
|
|||||||
if (part.startsWith('*')) {
|
if (part.startsWith('*')) {
|
||||||
return <em key={`${part}-${index}`}>{part.replace(/\*/g, '')}</em>;
|
return <em key={`${part}-${index}`}>{part.replace(/\*/g, '')}</em>;
|
||||||
}
|
}
|
||||||
|
if (part.startsWith('~~')) {
|
||||||
|
return <del key={`${part}-${index}`}>{part.replace(/~~/g, '')}</del>;
|
||||||
|
}
|
||||||
if (part.startsWith('`')) {
|
if (part.startsWith('`')) {
|
||||||
return <code key={`${part}-${index}`}>{part.replace(/`/g, '')}</code>;
|
return <code key={`${part}-${index}`}>{part.replace(/`/g, '')}</code>;
|
||||||
}
|
}
|
||||||
@@ -123,6 +126,18 @@ const renderMarkdown = (body) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('###### ')) {
|
||||||
|
blocks.push({ type: 'h6', value: line.replace(/^######\s+/, '') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (line.startsWith('##### ')) {
|
||||||
|
blocks.push({ type: 'h5', value: line.replace(/^#####\s+/, '') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (line.startsWith('#### ')) {
|
||||||
|
blocks.push({ type: 'h4', value: line.replace(/^####\s+/, '') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (line.startsWith('### ')) {
|
if (line.startsWith('### ')) {
|
||||||
blocks.push({ type: 'h3', value: line.replace(/^###\s+/, '') });
|
blocks.push({ type: 'h3', value: line.replace(/^###\s+/, '') });
|
||||||
return;
|
return;
|
||||||
@@ -146,6 +161,9 @@ const renderMarkdown = (body) => {
|
|||||||
if (block.type === 'h1') return <h1 key={index}>{block.value}</h1>;
|
if (block.type === 'h1') return <h1 key={index}>{block.value}</h1>;
|
||||||
if (block.type === 'h2') return <h2 key={index}>{block.value}</h2>;
|
if (block.type === 'h2') return <h2 key={index}>{block.value}</h2>;
|
||||||
if (block.type === 'h3') return <h3 key={index}>{block.value}</h3>;
|
if (block.type === 'h3') return <h3 key={index}>{block.value}</h3>;
|
||||||
|
if (block.type === 'h4') return <h4 key={index}>{block.value}</h4>;
|
||||||
|
if (block.type === 'h5') return <h5 key={index}>{block.value}</h5>;
|
||||||
|
if (block.type === 'h6') return <h6 key={index}>{block.value}</h6>;
|
||||||
if (block.type === 'list')
|
if (block.type === 'list')
|
||||||
return (
|
return (
|
||||||
<ul key={index}>
|
<ul key={index}>
|
||||||
@@ -202,6 +220,7 @@ const Blog = () => {
|
|||||||
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState('전체');
|
const [selectedCategory, setSelectedCategory] = useState('전체');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [showList, setShowList] = useState(false);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
const filteredPosts = useMemo(() => {
|
const filteredPosts = useMemo(() => {
|
||||||
if (selectedCategory === '전체') return posts;
|
if (selectedCategory === '전체') return posts;
|
||||||
@@ -250,7 +269,15 @@ const Blog = () => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="blog-grid">
|
<div className="blog-grid">
|
||||||
<aside className="blog-list">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="blog-toggle-list"
|
||||||
|
onClick={() => setShowList((prev) => !prev)}
|
||||||
|
aria-label="글 목록 토글"
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
<aside className={`blog-list ${showList ? 'is-visible' : ''}`}>
|
||||||
<div className="blog-category-filter">
|
<div className="blog-category-filter">
|
||||||
{['전체', ...categoryNames, '기타'].map((name) => (
|
{['전체', ...categoryNames, '기타'].map((name) => (
|
||||||
<button
|
<button
|
||||||
@@ -272,7 +299,10 @@ const Blog = () => {
|
|||||||
className={`blog-list__item${
|
className={`blog-list__item${
|
||||||
post.slug === activeSlug ? ' is-active' : ''
|
post.slug === activeSlug ? ' is-active' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setActiveSlug(post.slug)}
|
onClick={() => {
|
||||||
|
setActiveSlug(post.slug);
|
||||||
|
setShowList(false); // 모바일에서 글 선택 시 리스트 숨김
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<p className="blog-list__title">{post.title}</p>
|
<p className="blog-list__title">{post.title}</p>
|
||||||
<p className="blog-list__excerpt">{post.excerpt}</p>
|
<p className="blog-list__excerpt">{post.excerpt}</p>
|
||||||
|
|||||||
59
src/pages/effect-lab/EffectLab.css
Normal file
59
src/pages/effect-lab/EffectLab.css
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
.effect-lab {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: calc(100vh - 80px);
|
||||||
|
/* Adjust based on navbar height */
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #050505;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-lab canvas {
|
||||||
|
display: block;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-lab-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-lab-overlay h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 28px;
|
||||||
|
color: var(--text);
|
||||||
|
text-shadow: 0 0 20px rgba(68, 170, 221, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-mode {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(68, 170, 221, 0.1);
|
||||||
|
border: 1px solid rgba(68, 170, 221, 0.3);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 99px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #44aadd;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-mode span {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-lab-overlay p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 400px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
218
src/pages/effect-lab/EffectLab.jsx
Normal file
218
src/pages/effect-lab/EffectLab.jsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import './EffectLab.css';
|
||||||
|
|
||||||
|
const EffectLab = () => {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const requestRef = useRef();
|
||||||
|
const [mode, setMode] = useState('HOVER'); // HOVER, ATTACK, ORBIT
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
// --- Configuration ---
|
||||||
|
const COUNT = 1500;
|
||||||
|
const SWORD_COLOR = 0x44aadd;
|
||||||
|
const SWORD_EMISSIVE = 0x112244;
|
||||||
|
|
||||||
|
// --- Helper: Random Range ---
|
||||||
|
const rand = (min, max) => Math.random() * (max - min) + min;
|
||||||
|
|
||||||
|
// --- Setup Scene ---
|
||||||
|
const width = containerRef.current.clientWidth;
|
||||||
|
const height = containerRef.current.clientHeight;
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.fog = new THREE.FogExp2(0x050505, 0.002);
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
||||||
|
camera.position.z = 80;
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
// Tone mapping for better glow look
|
||||||
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
containerRef.current.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
// --- Lighting ---
|
||||||
|
const ambientLight = new THREE.AmbientLight(0x404040);
|
||||||
|
scene.add(ambientLight);
|
||||||
|
|
||||||
|
const pointLight = new THREE.PointLight(SWORD_COLOR, 2, 100);
|
||||||
|
scene.add(pointLight);
|
||||||
|
|
||||||
|
// --- Geometry & Material ---
|
||||||
|
// Sword shape: Cone stretched
|
||||||
|
const geometry = new THREE.CylinderGeometry(0.1, 0.4, 4, 8);
|
||||||
|
geometry.rotateX(Math.PI / 2); // Point towards Z
|
||||||
|
|
||||||
|
const material = new THREE.MeshPhongMaterial({
|
||||||
|
color: SWORD_COLOR,
|
||||||
|
emissive: SWORD_EMISSIVE,
|
||||||
|
shininess: 100,
|
||||||
|
flatShading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mesh = new THREE.InstancedMesh(geometry, material, COUNT);
|
||||||
|
scene.add(mesh);
|
||||||
|
|
||||||
|
// --- Particle Data ---
|
||||||
|
const dummy = new THREE.Object3D();
|
||||||
|
const particles = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
particles.push({
|
||||||
|
pos: new THREE.Vector3(rand(-100, 100), rand(-100, 100), rand(-50, 50)),
|
||||||
|
vel: new THREE.Vector3(),
|
||||||
|
acc: new THREE.Vector3(),
|
||||||
|
// Orbit parameters
|
||||||
|
angle: rand(0, Math.PI * 2),
|
||||||
|
radius: rand(15, 30),
|
||||||
|
speed: rand(0.02, 0.05),
|
||||||
|
// Offset for natural movement
|
||||||
|
offset: new THREE.Vector3(rand(-5, 5), rand(-5, 5), rand(-5, 5))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mouse & Interaction State ---
|
||||||
|
const mouse = new THREE.Vector3();
|
||||||
|
const target = new THREE.Vector3();
|
||||||
|
const mousePlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
|
||||||
|
const raycaster = new THREE.Raycaster();
|
||||||
|
|
||||||
|
let isMouseDown = false;
|
||||||
|
let time = 0;
|
||||||
|
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
const rect = renderer.domElement.getBoundingClientRect();
|
||||||
|
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
|
||||||
|
raycaster.setFromCamera(new THREE.Vector2(x, y), camera);
|
||||||
|
raycaster.ray.intersectPlane(mousePlane, mouse);
|
||||||
|
|
||||||
|
// Allow light to follow mouse
|
||||||
|
pointLight.position.copy(mouse);
|
||||||
|
pointLight.position.z = 20;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = () => { isMouseDown = true; setMode('ORBIT'); };
|
||||||
|
const handleMouseUp = () => { isMouseDown = false; setMode('HOVER'); };
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
|
window.addEventListener('mousedown', handleMouseDown);
|
||||||
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
// --- Animation Loop ---
|
||||||
|
const animate = () => {
|
||||||
|
requestRef.current = requestAnimationFrame(animate);
|
||||||
|
time += 0.01;
|
||||||
|
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
const p = particles[i];
|
||||||
|
|
||||||
|
// --- Behavior Logic ---
|
||||||
|
if (isMouseDown) {
|
||||||
|
// 1. ORBIT MODE: Rotate around mouse
|
||||||
|
p.angle += p.speed + 0.02; // Spin faster
|
||||||
|
|
||||||
|
const orbitX = mouse.x + Math.cos(p.angle + time) * p.radius;
|
||||||
|
const orbitY = mouse.y + Math.sin(p.angle + time) * p.radius;
|
||||||
|
// Spiraling Z for depth
|
||||||
|
const orbitZ = Math.sin(p.angle * 2 + time) * 10;
|
||||||
|
|
||||||
|
target.set(orbitX, orbitY, orbitZ);
|
||||||
|
|
||||||
|
// Strong pull to orbit positions
|
||||||
|
p.acc.subVectors(target, p.pos).multiplyScalar(0.08);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// 2. HOVER/FOLLOW MODE: Follow mouse with flocking feel
|
||||||
|
|
||||||
|
// Add noise/wandering
|
||||||
|
const noiseX = Math.sin(time + i * 0.1) * 5;
|
||||||
|
const noiseY = Math.cos(time + i * 0.1) * 5;
|
||||||
|
|
||||||
|
target.copy(mouse).add(p.offset).add(new THREE.Vector3(noiseX, noiseY, 0));
|
||||||
|
|
||||||
|
// Gentle pull
|
||||||
|
p.acc.subVectors(target, p.pos).multiplyScalar(0.008);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Physics update
|
||||||
|
p.vel.add(p.acc);
|
||||||
|
p.vel.multiplyScalar(isMouseDown ? 0.90 : 0.94); // Drag
|
||||||
|
p.pos.add(p.vel);
|
||||||
|
|
||||||
|
// Update Matrix
|
||||||
|
dummy.position.copy(p.pos);
|
||||||
|
|
||||||
|
// Rotation: Look at velocity direction (dynamic) or mouse (focused)
|
||||||
|
// Blending lookAt target for smoother rotation
|
||||||
|
const lookPos = new THREE.Vector3().copy(p.pos).add(p.vel.clone().multiplyScalar(5));
|
||||||
|
|
||||||
|
// If moving very slowly, keep previous rotation to avoid jitter
|
||||||
|
if (p.vel.lengthSq() > 0.01) {
|
||||||
|
dummy.lookAt(lookPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale effect based on speed (stretch when fast)
|
||||||
|
const speedScale = 1 + Math.min(p.vel.length(), 2) * 0.5;
|
||||||
|
dummy.scale.set(1, 1, speedScale);
|
||||||
|
|
||||||
|
dummy.updateMatrix();
|
||||||
|
mesh.setMatrixAt(i, dummy.matrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
};
|
||||||
|
|
||||||
|
animate();
|
||||||
|
|
||||||
|
// --- Resize ---
|
||||||
|
const handleResize = () => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const newWidth = containerRef.current.clientWidth;
|
||||||
|
const newHeight = containerRef.current.clientHeight;
|
||||||
|
|
||||||
|
camera.aspect = newWidth / newHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(newWidth, newHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
window.removeEventListener('mousedown', handleMouseDown);
|
||||||
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
cancelAnimationFrame(requestRef.current);
|
||||||
|
if (containerRef.current && renderer.domElement) {
|
||||||
|
containerRef.current.removeChild(renderer.domElement);
|
||||||
|
}
|
||||||
|
geometry.dispose();
|
||||||
|
material.dispose();
|
||||||
|
renderer.dispose();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="effect-lab" ref={containerRef}>
|
||||||
|
<div className="effect-lab-overlay">
|
||||||
|
<h2>Sword Stream</h2>
|
||||||
|
<div className="active-mode">
|
||||||
|
MODE: <span>{mode}</span>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<strong>Move</strong> to Guide |
|
||||||
|
<strong>Click & Hold</strong> to Orbit & Charge
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EffectLab;
|
||||||
@@ -308,3 +308,51 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.home-hero h1 {
|
||||||
|
font-size: clamp(24px, 6vw, 36px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card {
|
||||||
|
padding: 14px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card__title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card__desc {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-posts {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-post {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-post__title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__card {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__name {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile__bio {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ const Home = () => {
|
|||||||
<p className="home-hero__kicker">Personal Archive</p>
|
<p className="home-hero__kicker">Personal Archive</p>
|
||||||
<h1>기록을 모으고, 이야기를 이어붙이는 작은 집.</h1>
|
<h1>기록을 모으고, 이야기를 이어붙이는 작은 집.</h1>
|
||||||
<p className="home-hero__lead">
|
<p className="home-hero__lead">
|
||||||
개발 실험, 여행 스냅, 그리고 생각을 모아두는 공간입니다. 블로그 글은
|
개발, 여행 스냅, 그리고 생각을 모아두는 공간입니다.
|
||||||
마크다운으로 작성해 계속 추가할 수 있어요.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="home-hero__actions">
|
<div className="home-hero__actions">
|
||||||
<Link className="button primary" to="/blog">
|
<Link className="button primary" to="/blog">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { deleteHistory, getHistory, getLatest, recommend } from '../../api';
|
import { deleteHistory, getHistory, getLatest, getStats, recommend } from '../../api';
|
||||||
|
|
||||||
const fmtKST = (value) => value?.replace('T', ' ') ?? '';
|
const fmtKST = (value) => value?.replace('T', ' ') ?? '';
|
||||||
|
|
||||||
@@ -21,6 +21,227 @@ const NumberRow = ({ nums }) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const bucketOrder = ['1-10', '11-20', '21-30', '31-40', '41-45'];
|
||||||
|
const STATS_CACHE_KEY = 'lotto_stats_v1';
|
||||||
|
|
||||||
|
const readStatsCache = () => {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STATS_CACHE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed || !Array.isArray(parsed.frequency)) return null;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeStatsCache = (data) => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STATS_CACHE_KEY, JSON.stringify(data));
|
||||||
|
} catch {
|
||||||
|
// ignore storage failures
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFrequencySeries = (frequency) => {
|
||||||
|
const map = new Map();
|
||||||
|
(frequency ?? []).forEach((item) => {
|
||||||
|
const number = Number(item?.number);
|
||||||
|
const count = Number(item?.count) || 0;
|
||||||
|
if (Number.isFinite(number) && number >= 1 && number <= 45) {
|
||||||
|
map.set(number, count);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const series = Array.from({ length: 45 }, (_, idx) => ({
|
||||||
|
number: idx + 1,
|
||||||
|
count: map.get(idx + 1) ?? 0,
|
||||||
|
}));
|
||||||
|
const max = Math.max(1, ...series.map((item) => item.count));
|
||||||
|
return { series, max };
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMetricsFromCounts = (counts) => {
|
||||||
|
if (!counts?.length) return null;
|
||||||
|
const total = counts.reduce((acc, value) => acc + value, 0);
|
||||||
|
if (!total) return null;
|
||||||
|
const min = Math.min(...counts);
|
||||||
|
const max = Math.max(...counts);
|
||||||
|
const range = max - min;
|
||||||
|
const odd = counts.reduce(
|
||||||
|
(acc, value, idx) => (idx % 2 === 0 ? acc + value : acc),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const even = total - odd;
|
||||||
|
const buckets = {
|
||||||
|
'1-10': counts.slice(0, 10).reduce((a, b) => a + b, 0),
|
||||||
|
'11-20': counts.slice(10, 20).reduce((a, b) => a + b, 0),
|
||||||
|
'21-30': counts.slice(20, 30).reduce((a, b) => a + b, 0),
|
||||||
|
'31-40': counts.slice(30, 40).reduce((a, b) => a + b, 0),
|
||||||
|
'41-45': counts.slice(40, 45).reduce((a, b) => a + b, 0),
|
||||||
|
};
|
||||||
|
return { sum: total, min, max, range, odd, even, buckets };
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMetricsFromFrequency = (frequency) => {
|
||||||
|
if (!frequency?.length) return null;
|
||||||
|
const counts = Array.from({ length: 45 }, () => 0);
|
||||||
|
frequency.forEach((item) => {
|
||||||
|
const number = Number(item?.number);
|
||||||
|
const count = Number(item?.count) || 0;
|
||||||
|
if (number >= 1 && number <= 45) {
|
||||||
|
counts[number - 1] = count;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return buildMetricsFromCounts(counts);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMetricsFromHistory = (items) => {
|
||||||
|
if (!items?.length) return null;
|
||||||
|
const counts = Array.from({ length: 45 }, () => 0);
|
||||||
|
items.forEach((item) => {
|
||||||
|
(item?.numbers ?? []).forEach((value) => {
|
||||||
|
const number = Number(value);
|
||||||
|
if (number >= 1 && number <= 45) {
|
||||||
|
counts[number - 1] += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return buildMetricsFromCounts(counts);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toBucketEntries = (metrics) => {
|
||||||
|
if (!metrics?.buckets) return [];
|
||||||
|
const entries = Object.entries(metrics.buckets);
|
||||||
|
const ordered = bucketOrder
|
||||||
|
.filter((key) => Object.prototype.hasOwnProperty.call(metrics.buckets, key))
|
||||||
|
.map((key) => [key, metrics.buckets[key]]);
|
||||||
|
const rest = entries
|
||||||
|
.filter(([key]) => !bucketOrder.includes(key))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aStart = Number(a[0].split('-')[0]);
|
||||||
|
const bStart = Number(b[0].split('-')[0]);
|
||||||
|
if (Number.isNaN(aStart) || Number.isNaN(bStart)) return 0;
|
||||||
|
return aStart - bStart;
|
||||||
|
});
|
||||||
|
return [...ordered, ...rest];
|
||||||
|
};
|
||||||
|
|
||||||
|
const MetricBlock = ({ title, metrics }) => {
|
||||||
|
if (!metrics) return null;
|
||||||
|
const buckets = toBucketEntries(metrics);
|
||||||
|
const maxBucket = buckets.length
|
||||||
|
? Math.max(...buckets.map(([, value]) => Number(value) || 0), 1)
|
||||||
|
: 1;
|
||||||
|
const odd = Number(metrics.odd) || 0;
|
||||||
|
const even = Number(metrics.even) || 0;
|
||||||
|
const totalOE = odd + even || 1;
|
||||||
|
const oddPct = (odd / totalOE) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lotto-metrics">
|
||||||
|
<div className="lotto-metrics__head">
|
||||||
|
<p className="lotto-metrics__title">{title}</p>
|
||||||
|
<span className="lotto-metrics__sum">
|
||||||
|
총 출현 횟수 {metrics.sum ?? '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-metric-cards">
|
||||||
|
<div className="lotto-metric-card">
|
||||||
|
<p className="lotto-metric-card__label">최소 출현</p>
|
||||||
|
<p className="lotto-metric-card__value">{metrics.min ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-metric-card">
|
||||||
|
<p className="lotto-metric-card__label">최대 출현</p>
|
||||||
|
<p className="lotto-metric-card__value">{metrics.max ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-metric-card">
|
||||||
|
<p className="lotto-metric-card__label">출현 편차</p>
|
||||||
|
<p className="lotto-metric-card__value">{metrics.range ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-odd-even">
|
||||||
|
<div className="lotto-odd-even__labels">
|
||||||
|
<span>홀 {odd}</span>
|
||||||
|
<span>짝 {even}</span>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-odd-even__bar" aria-hidden>
|
||||||
|
<span className="lotto-odd-even__odd" style={{ width: `${oddPct}%` }} />
|
||||||
|
<span
|
||||||
|
className="lotto-odd-even__even"
|
||||||
|
style={{ width: `${100 - oddPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{buckets.length ? (
|
||||||
|
<div className="lotto-buckets">
|
||||||
|
{buckets.map(([label, value]) => (
|
||||||
|
<div key={label} className="lotto-bucket">
|
||||||
|
<span className="lotto-bucket__label">{label}</span>
|
||||||
|
<div className="lotto-bucket__bar" aria-hidden>
|
||||||
|
<span
|
||||||
|
style={{ width: `${((Number(value) || 0) / maxBucket) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="lotto-bucket__value">{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FrequencyChart = ({ stats }) => {
|
||||||
|
const { series, max } = useMemo(
|
||||||
|
() => buildFrequencySeries(stats?.frequency),
|
||||||
|
[stats]
|
||||||
|
);
|
||||||
|
const ticks = useMemo(() => {
|
||||||
|
const mid = Math.round(max * 0.5);
|
||||||
|
return [max, mid, 0];
|
||||||
|
}, [max]);
|
||||||
|
|
||||||
|
if (!stats) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lotto-chart">
|
||||||
|
<div className="lotto-chart__y">
|
||||||
|
<span>횟수</span>
|
||||||
|
<div className="lotto-chart__ticks">
|
||||||
|
{ticks.map((value) => (
|
||||||
|
<span key={value}>{value}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-chart__plot" role="list">
|
||||||
|
{series.map((item) => {
|
||||||
|
const showLabel = item.number === 1 || item.number % 5 === 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.number}
|
||||||
|
className="lotto-chart__col"
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="lotto-chart__bar"
|
||||||
|
style={{ height: `${(item.count / max) * 100}%` }}
|
||||||
|
title={`${item.number}번: ${item.count}회`}
|
||||||
|
aria-label={`${item.number}번 ${item.count}회`}
|
||||||
|
/>
|
||||||
|
<span className="lotto-chart__x" aria-hidden={!showLabel}>
|
||||||
|
{showLabel ? item.number : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function Functions() {
|
export default function Functions() {
|
||||||
const [latest, setLatest] = useState(null);
|
const [latest, setLatest] = useState(null);
|
||||||
const [params, setParams] = useState({
|
const [params, setParams] = useState({
|
||||||
@@ -40,12 +261,26 @@ export default function Functions() {
|
|||||||
|
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [history, setHistory] = useState([]);
|
const [history, setHistory] = useState([]);
|
||||||
|
const [historyExpanded, setHistoryExpanded] = useState(false);
|
||||||
|
const historyEndRef = useRef(null);
|
||||||
|
const prevHistoryExpandedRef = useRef(false);
|
||||||
|
const [stats, setStats] = useState(() => readStatsCache());
|
||||||
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
|
const [statsError, setStatsError] = useState('');
|
||||||
const [loading, setLoading] = useState({
|
const [loading, setLoading] = useState({
|
||||||
latest: false,
|
latest: false,
|
||||||
recommend: false,
|
recommend: false,
|
||||||
history: false,
|
history: false,
|
||||||
});
|
});
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const overallMetrics = useMemo(
|
||||||
|
() => buildMetricsFromFrequency(stats?.frequency),
|
||||||
|
[stats]
|
||||||
|
);
|
||||||
|
const historyMetrics = useMemo(
|
||||||
|
() => buildMetricsFromHistory(history),
|
||||||
|
[history]
|
||||||
|
);
|
||||||
|
|
||||||
const refreshLatest = async () => {
|
const refreshLatest = async () => {
|
||||||
setLoading((s) => ({ ...s, latest: true }));
|
setLoading((s) => ({ ...s, latest: true }));
|
||||||
@@ -64,8 +299,17 @@ export default function Functions() {
|
|||||||
setLoading((s) => ({ ...s, history: true }));
|
setLoading((s) => ({ ...s, history: true }));
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const data = await getHistory(30);
|
const limit = 100;
|
||||||
setHistory(data.items ?? []);
|
let offset = 0;
|
||||||
|
const allItems = [];
|
||||||
|
while (true) {
|
||||||
|
const data = await getHistory(limit, offset);
|
||||||
|
const items = data.items ?? [];
|
||||||
|
allItems.push(...items);
|
||||||
|
if (items.length < limit) break;
|
||||||
|
offset += limit;
|
||||||
|
}
|
||||||
|
setHistory(allItems);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e?.message ?? String(e));
|
setError(e?.message ?? String(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -73,6 +317,28 @@ export default function Functions() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshStats = async () => {
|
||||||
|
setStatsLoading(true);
|
||||||
|
setStatsError('');
|
||||||
|
try {
|
||||||
|
const cached = readStatsCache();
|
||||||
|
if (cached && !stats) {
|
||||||
|
setStats(cached);
|
||||||
|
}
|
||||||
|
const data = await getStats();
|
||||||
|
const shouldUpdate =
|
||||||
|
!cached || cached.total_draws !== data?.total_draws;
|
||||||
|
if (shouldUpdate) {
|
||||||
|
setStats(data);
|
||||||
|
writeStatsCache(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setStatsError(e?.message ?? String(e));
|
||||||
|
} finally {
|
||||||
|
setStatsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onRecommend = async () => {
|
const onRecommend = async () => {
|
||||||
setLoading((s) => ({ ...s, recommend: true }));
|
setLoading((s) => ({ ...s, recommend: true }));
|
||||||
setError('');
|
setError('');
|
||||||
@@ -113,8 +379,23 @@ export default function Functions() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshLatest();
|
refreshLatest();
|
||||||
refreshHistory();
|
refreshHistory();
|
||||||
|
refreshStats();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const visibleHistory = historyExpanded ? history : history.slice(0, 5);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (historyExpanded && !prevHistoryExpandedRef.current) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
historyEndRef.current?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'end',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prevHistoryExpandedRef.current = historyExpanded;
|
||||||
|
}, [historyExpanded, visibleHistory.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="lotto-functions">
|
<div className="lotto-functions">
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -169,6 +450,12 @@ export default function Functions() {
|
|||||||
<p className="lotto-bonus">
|
<p className="lotto-bonus">
|
||||||
보너스 <strong>{latest.bonus}</strong>
|
보너스 <strong>{latest.bonus}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
{overallMetrics ? (
|
||||||
|
<MetricBlock
|
||||||
|
title="당첨 통계 (전체 회차)"
|
||||||
|
metrics={overallMetrics}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
|
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
|
||||||
@@ -285,18 +572,104 @@ export default function Functions() {
|
|||||||
번호 복사
|
번호 복사
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<NumberRow nums={result.numbers} />
|
{result.numbers ? <NumberRow nums={result.numbers} /> : null}
|
||||||
<details className="lotto-details">
|
{historyMetrics ? (
|
||||||
<summary>설명 보기</summary>
|
<div className="lotto-compare">
|
||||||
<pre>{JSON.stringify(result.explain, null, 2)}</pre>
|
<MetricBlock
|
||||||
</details>
|
title="추천 통계 (히스토리)"
|
||||||
|
metrics={historyMetrics}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{Array.isArray(result.items) && result.items.length ? (
|
||||||
|
<details className="lotto-details">
|
||||||
|
<summary>추천 후보 보기</summary>
|
||||||
|
<div className="lotto-batch">
|
||||||
|
{result.items.map((item, idx) => (
|
||||||
|
<div
|
||||||
|
key={item.id ?? `candidate-${idx}`}
|
||||||
|
className="lotto-batch__item"
|
||||||
|
>
|
||||||
|
<div className="lotto-batch__meta">
|
||||||
|
<div>
|
||||||
|
<p className="lotto-batch__title">
|
||||||
|
후보 #{item.id ?? idx + 1}
|
||||||
|
</p>
|
||||||
|
<p className="lotto-batch__sub">
|
||||||
|
기준 회차 {item.based_on_draw ?? '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => copyNumbers(item.numbers)}
|
||||||
|
>
|
||||||
|
복사
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<NumberRow nums={item.numbers} />
|
||||||
|
{item.metrics ? (
|
||||||
|
<MetricBlock
|
||||||
|
title="후보 통계"
|
||||||
|
metrics={item.metrics}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
{result.explain ? (
|
||||||
|
<details className="lotto-details">
|
||||||
|
<summary>설명 보기</summary>
|
||||||
|
<pre>{JSON.stringify(result.explain, null, 2)}</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
|
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section className="lotto-panel lotto-panel--wide">
|
||||||
|
<div className="lotto-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="lotto-panel__eyebrow">Distribution</p>
|
||||||
|
<h3>전체 회차 번호 분포</h3>
|
||||||
|
<p className="lotto-panel__sub">
|
||||||
|
1~45번 번호가 등장한 횟수를 기준으로 분포를 표시합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-panel__actions">
|
||||||
|
{statsLoading ? (
|
||||||
|
<span className="lotto-chip">로딩 중</span>
|
||||||
|
) : null}
|
||||||
|
{stats?.total_draws ? (
|
||||||
|
<span className="lotto-chip">
|
||||||
|
{stats.total_draws}회차
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={refreshStats}
|
||||||
|
disabled={statsLoading}
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{statsError ? <p className="lotto-empty">{statsError}</p> : null}
|
||||||
|
{stats ? (
|
||||||
|
<FrequencyChart stats={stats} />
|
||||||
|
) : (
|
||||||
|
<p className="lotto-empty">
|
||||||
|
통계 데이터를 불러오지 못했습니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="lotto-panel">
|
<section className="lotto-panel">
|
||||||
<div className="lotto-panel__head">
|
<div className="lotto-panel__head">
|
||||||
<div>
|
<div>
|
||||||
@@ -308,6 +681,30 @@ export default function Functions() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="lotto-panel__actions">
|
<div className="lotto-panel__actions">
|
||||||
<span className="lotto-chip">{history.length}건</span>
|
<span className="lotto-chip">{history.length}건</span>
|
||||||
|
{history.length > 5 ? (
|
||||||
|
<button
|
||||||
|
className="button ghost small lotto-history-toggle"
|
||||||
|
onClick={() =>
|
||||||
|
setHistoryExpanded((prev) => !prev)
|
||||||
|
}
|
||||||
|
aria-expanded={historyExpanded}
|
||||||
|
aria-label={
|
||||||
|
historyExpanded
|
||||||
|
? '히스토리 접기'
|
||||||
|
: '히스토리 더보기'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{historyExpanded ? '접기' : '더보기'}
|
||||||
|
<span
|
||||||
|
className={`lotto-history-toggle__icon ${
|
||||||
|
historyExpanded ? 'is-open' : ''
|
||||||
|
}`}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
className="button ghost small"
|
className="button ghost small"
|
||||||
onClick={refreshHistory}
|
onClick={refreshHistory}
|
||||||
@@ -324,7 +721,7 @@ export default function Functions() {
|
|||||||
<p className="lotto-empty">저장된 히스토리가 없습니다.</p>
|
<p className="lotto-empty">저장된 히스토리가 없습니다.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="lotto-history">
|
<div className="lotto-history">
|
||||||
{history.map((item) => (
|
{visibleHistory.map((item) => (
|
||||||
<div key={item.id} className="lotto-history__item">
|
<div key={item.id} className="lotto-history__item">
|
||||||
<div className="lotto-history__meta">
|
<div className="lotto-history__meta">
|
||||||
<p>#{item.id}</p>
|
<p>#{item.id}</p>
|
||||||
@@ -355,12 +752,16 @@ export default function Functions() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<span ref={historyEndRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer className="lotto-foot">
|
<footer className="lotto-foot">
|
||||||
backend: FastAPI / nginx proxy / DB: sqlite
|
backend: FastAPI / nginx proxy / DB: sqlite •{' '}
|
||||||
|
<a className="lotto-foot__link" href="/lotto-api.md" download>
|
||||||
|
API 스펙 다운로드
|
||||||
|
</a>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -69,6 +69,18 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lotto-panel--wide .lotto-chart {
|
||||||
|
grid-template-columns: 60px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-panel--wide .lotto-chart__plot {
|
||||||
|
height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-panel--wide .lotto-chart__ticks {
|
||||||
|
min-height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
.lotto-panel__head {
|
.lotto-panel__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -244,6 +256,235 @@
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lotto-compare {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-metrics {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-metrics__head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-metrics__title {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-metrics__sum {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-metric-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-metric-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-metric-card__label {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-metric-card__value {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-odd-even {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-odd-even__labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-odd-even__bar {
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-odd-even__odd {
|
||||||
|
background: rgba(247, 168, 165, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-odd-even__even {
|
||||||
|
background: rgba(151, 201, 170, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-buckets {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-bucket {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 54px minmax(0, 1fr) 28px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-bucket__label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-bucket__bar {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-bucket__bar span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(133, 165, 216, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-bucket__value {
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-chart {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 48px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-chart__y {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-chart__ticks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-chart__plot {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(45, minmax(0, 1fr));
|
||||||
|
align-items: end;
|
||||||
|
gap: 2px;
|
||||||
|
height: 180px;
|
||||||
|
padding: 0 4px 18px 6px;
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-chart__col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-chart__bar {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 6px 6px 2px 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(133, 165, 216, 0.8),
|
||||||
|
rgba(133, 165, 216, 0.2)
|
||||||
|
);
|
||||||
|
min-height: 2px;
|
||||||
|
transition: transform 0.2s ease, filter 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-chart__bar:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-chart__x {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-batch {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-batch__item {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-batch__meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-batch__title {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-batch__sub {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.lotto-empty {
|
.lotto-empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -276,6 +517,22 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lotto-history-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-history-toggle__icon {
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-history-toggle__icon.is-open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
.lotto-history__item {
|
.lotto-history__item {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
@@ -322,6 +579,10 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lotto-foot__link {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.button.danger {
|
.button.danger {
|
||||||
border-color: rgba(247, 116, 125, 0.5);
|
border-color: rgba(247, 116, 125, 0.5);
|
||||||
color: #fbc4c8;
|
color: #fbc4c8;
|
||||||
@@ -337,3 +598,43 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.lotto-header h1 {
|
||||||
|
font-size: clamp(24px, 6vw, 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-panel {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-panel__head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-row {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-ball {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-meta__title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-history__item {
|
||||||
|
padding: 14px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ const Lotto = () => {
|
|||||||
<p className="lotto-kicker">Playground</p>
|
<p className="lotto-kicker">Playground</p>
|
||||||
<h1>Lotto Lab</h1>
|
<h1>Lotto Lab</h1>
|
||||||
<p className="lotto-sub">
|
<p className="lotto-sub">
|
||||||
기존 로또 추천 기능을 그대로 유지하면서 새로운 블로그 스타일에 맞게
|
로또 추천 및 기록 관리 도구입니다. 다양한 알고리즘을 통해 번호를 추천받고,
|
||||||
레이아웃을 정리했습니다.
|
추첨 기록을 손쉽게 관리할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="lotto-card">
|
<div className="lotto-card">
|
||||||
|
|||||||
626
src/pages/stock/Stock.css
Normal file
626
src/pages/stock/Stock.css
Normal file
@@ -0,0 +1,626 @@
|
|||||||
|
.stock {
|
||||||
|
display: grid;
|
||||||
|
gap: 28px;
|
||||||
|
/* Prevent overflow on small screens */
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||||
|
gap: 24px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-kicker {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-header h1 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(30px, 4vw, 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-sub {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-ideas {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-card__title {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-status {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-status>div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-status strong {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-status__note {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-pill {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-pill.is-ok {
|
||||||
|
border-color: rgba(106, 220, 187, 0.6);
|
||||||
|
color: #b5f0dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-pill.is-warn {
|
||||||
|
border-color: rgba(245, 200, 115, 0.6);
|
||||||
|
color: #f5d28a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-pill.is-unknown {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-error {
|
||||||
|
margin: 0;
|
||||||
|
color: #f9b6b1;
|
||||||
|
border: 1px solid rgba(249, 182, 177, 0.4);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(249, 182, 177, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-success {
|
||||||
|
margin: 0;
|
||||||
|
color: #b5f0dd;
|
||||||
|
border: 1px solid rgba(106, 220, 187, 0.4);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(106, 220, 187, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-panel {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 20px;
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-panel--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-filter-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-filter-row .stock-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-panel__head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-panel__eyebrow {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-panel__sub {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-panel__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-chip {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-snapshot {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-snapshot__card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
min-height: 94px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-snapshot__card p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-snapshot__card strong {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-snapshot__card span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-snapshot__change {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-snapshot__change.is-up {
|
||||||
|
color: #f3a7a7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-snapshot__change.is-down {
|
||||||
|
color: #9fc5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-schedule {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-schedule strong {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-filter {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-filter label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-filter select {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-filter__note {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-news {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-tab {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-tab.is-active {
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-news__item {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-news__title {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-news__summary {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-news__meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-news__meta a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-trade {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-balance {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-balance__summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-balance__card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-balance__card strong {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__item {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) repeat(5, minmax(0, 0.6fr));
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__name {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__code {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__metric {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__metric span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__metric strong {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-profit {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-profit.is-up {
|
||||||
|
color: #f3a7a7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-profit.is-down {
|
||||||
|
color: #9fc5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-profit.is-flat {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-result {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-result__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-result pre {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-modal__backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-modal__card {
|
||||||
|
position: relative;
|
||||||
|
width: min(520px, 90vw);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-modal__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-modal__head h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-modal pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-order {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-order label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-order input,
|
||||||
|
.stock-order select {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.stock-header {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stock {
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-panel {
|
||||||
|
padding: 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-filter-row {
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-actions .button {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-status>div {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-status strong {
|
||||||
|
font-size: 14px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-balance__summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__item {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__name {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__metric {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
justify-items: start;
|
||||||
|
gap: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make the last item span full width if it's odd */
|
||||||
|
.stock-holdings__metric>*:last-child:nth-child(odd) {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__metric span {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__metric strong {
|
||||||
|
font-size: 15px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.stock-holdings__metric {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__metric strong {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
416
src/pages/stock/Stock.jsx
Normal file
416
src/pages/stock/Stock.jsx
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { getStockIndices, getStockNews } from '../../api';
|
||||||
|
import Loading from '../../components/Loading';
|
||||||
|
import './Stock.css';
|
||||||
|
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) return '-';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString('sv-SE');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toDateValue = (value) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLatestBy = (items, key) => {
|
||||||
|
const filtered = items
|
||||||
|
.map((item) => ({ ...item, __date: toDateValue(item?.[key]) }))
|
||||||
|
.filter((item) => item.__date);
|
||||||
|
if (!filtered.length) return null;
|
||||||
|
filtered.sort((a, b) => b.__date - a.__date);
|
||||||
|
return filtered[0]?.[key] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeIndices = (data) => {
|
||||||
|
if (!data) return [];
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data.map((item) => ({
|
||||||
|
name: item?.name ?? item?.key ?? '-',
|
||||||
|
value: item?.value ?? '-',
|
||||||
|
change: item?.change_value ?? item?.change ?? '',
|
||||||
|
percent: item?.change_percent ?? item?.percent ?? '',
|
||||||
|
direction: item?.direction ?? '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data?.indices)) {
|
||||||
|
return data.indices.map((item) => ({
|
||||||
|
name: item?.name ?? item?.key ?? '-',
|
||||||
|
value: item?.value ?? '-',
|
||||||
|
change: item?.change_value ?? item?.change ?? '',
|
||||||
|
percent: item?.change_percent ?? item?.percent ?? '',
|
||||||
|
direction: item?.direction ?? '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data === 'object') {
|
||||||
|
return Object.entries(data)
|
||||||
|
.filter(([, value]) => value && typeof value === 'object')
|
||||||
|
.map(([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value: value?.value ?? '-',
|
||||||
|
change: value?.change ?? '',
|
||||||
|
percent: value?.percent ?? '',
|
||||||
|
direction: value?.direction ?? '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDirection = (change, percent, direction) => {
|
||||||
|
if (direction === 'red') return 'up';
|
||||||
|
if (direction === 'blue') return 'down';
|
||||||
|
const pick = (value) =>
|
||||||
|
value === undefined || value === null || value === '' ? null : value;
|
||||||
|
const raw = pick(change) ?? pick(percent);
|
||||||
|
if (!raw) return '';
|
||||||
|
const str = String(raw).trim();
|
||||||
|
if (str.startsWith('-')) return 'down';
|
||||||
|
if (str.startsWith('+')) return 'up';
|
||||||
|
const numeric = Number(str.replace(/[^0-9.-]/g, ''));
|
||||||
|
if (Number.isFinite(numeric)) {
|
||||||
|
if (numeric > 0) return 'up';
|
||||||
|
if (numeric < 0) return 'down';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const Stock = () => {
|
||||||
|
const [newsDomestic, setNewsDomestic] = useState([]);
|
||||||
|
const [newsOverseas, setNewsOverseas] = useState([]);
|
||||||
|
const [newsCategory, setNewsCategory] = useState('domestic');
|
||||||
|
const [limit, setLimit] = useState(20);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [newsError, setNewsError] = useState('');
|
||||||
|
const [indicesError, setIndicesError] = useState('');
|
||||||
|
const [indices, setIndices] = useState([]);
|
||||||
|
const [indicesLoading, setIndicesLoading] = useState(false);
|
||||||
|
const [autoRefreshMs] = useState(180000);
|
||||||
|
|
||||||
|
const combinedNews = useMemo(
|
||||||
|
() => [...newsDomestic, ...newsOverseas],
|
||||||
|
[newsDomestic, newsOverseas]
|
||||||
|
);
|
||||||
|
const latestPublished = useMemo(
|
||||||
|
() => getLatestBy(combinedNews, 'published_at'),
|
||||||
|
[combinedNews]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadNews = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setNewsError('');
|
||||||
|
try {
|
||||||
|
const [domestic, overseas] = await Promise.all([
|
||||||
|
getStockNews(limit, 'domestic'),
|
||||||
|
getStockNews(limit, 'overseas'),
|
||||||
|
]);
|
||||||
|
setNewsDomestic(Array.isArray(domestic) ? domestic : []);
|
||||||
|
setNewsOverseas(Array.isArray(overseas) ? overseas : []);
|
||||||
|
} catch (err) {
|
||||||
|
setNewsError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadIndices = async () => {
|
||||||
|
setIndicesLoading(true);
|
||||||
|
setIndicesError('');
|
||||||
|
try {
|
||||||
|
const data = await getStockIndices();
|
||||||
|
setIndices(normalizeIndices(data));
|
||||||
|
} catch (err) {
|
||||||
|
setIndicesError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setIndicesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadNews();
|
||||||
|
}, [limit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadIndices();
|
||||||
|
const timer = window.setInterval(loadIndices, autoRefreshMs);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [autoRefreshMs]);
|
||||||
|
|
||||||
|
const indexOrder = [
|
||||||
|
'KOSPI',
|
||||||
|
'KOSDAQ',
|
||||||
|
'KOSPI200',
|
||||||
|
'다우산업',
|
||||||
|
'나스닥',
|
||||||
|
'S&P500',
|
||||||
|
'원달러 환율',
|
||||||
|
];
|
||||||
|
const sortedIndices = [...indices].sort((a, b) => {
|
||||||
|
const aIndex = indexOrder.indexOf(a.name);
|
||||||
|
const bIndex = indexOrder.indexOf(b.name);
|
||||||
|
if (aIndex !== -1 || bIndex !== -1) {
|
||||||
|
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex);
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
const highlighted = new Set(['KOSPI', 'KOSDAQ', '원달러 환율']);
|
||||||
|
const activeNews =
|
||||||
|
newsCategory === 'domestic' ? newsDomestic : newsOverseas;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stock">
|
||||||
|
<header className="stock-header">
|
||||||
|
<div>
|
||||||
|
<p className="stock-kicker">마켓 랩</p>
|
||||||
|
<h1>주식 랩</h1>
|
||||||
|
<p className="stock-sub">
|
||||||
|
최신 시장 뉴스와 주요 지수 흐름을 한눈에 확인하세요.
|
||||||
|
</p>
|
||||||
|
<div className="stock-actions">
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
onClick={loadNews}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
뉴스 새로고침
|
||||||
|
</button>
|
||||||
|
<Link className="button ghost" to="/stock/trade">
|
||||||
|
거래 데스크
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-card">
|
||||||
|
<p className="stock-card__title">다음 업데이트 아이디어</p>
|
||||||
|
<ul className="stock-ideas">
|
||||||
|
<li>관심 종목 실적 캘린더/일정 보기</li>
|
||||||
|
<li>뉴스 감성 요약 및 키워드 트렌드</li>
|
||||||
|
<li>보유 종목 알림(수익률/목표가)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">스냅샷</p>
|
||||||
|
<h3>주요 지수</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
주요 지수 값과 등락을 함께 확인합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="stock-panel__actions">
|
||||||
|
{indicesLoading ? (
|
||||||
|
<Loading type="spinner" message="" />
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={loadIndices}
|
||||||
|
disabled={indicesLoading}
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-snapshot">
|
||||||
|
{indicesError ? (
|
||||||
|
<p className="stock-empty">{indicesError}</p>
|
||||||
|
) : sortedIndices.length === 0 ? (
|
||||||
|
<p className="stock-empty">
|
||||||
|
지수 데이터가 없습니다.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
sortedIndices.map((item) => {
|
||||||
|
const direction = getDirection(
|
||||||
|
item.change,
|
||||||
|
item.percent,
|
||||||
|
item.direction
|
||||||
|
);
|
||||||
|
const changeText = [item.change, item.percent]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.name}
|
||||||
|
className={`stock-snapshot__card ${highlighted.has(item.name)
|
||||||
|
? 'is-highlight'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p>{item.name}</p>
|
||||||
|
<strong>{item.value ?? '--'}</strong>
|
||||||
|
<span
|
||||||
|
className={`stock-snapshot__change ${direction === 'up'
|
||||||
|
? 'is-up'
|
||||||
|
: direction === 'down'
|
||||||
|
? 'is-down'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{changeText || '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="stock-filter-row">
|
||||||
|
<div className="stock-panel stock-panel--compact">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">필터</p>
|
||||||
|
<h3>뉴스 필터</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
표시할 뉴스 개수를 조정합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-filter">
|
||||||
|
<label>
|
||||||
|
표시 개수
|
||||||
|
<select
|
||||||
|
value={limit}
|
||||||
|
onChange={(event) =>
|
||||||
|
setLimit(Number(event.target.value))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{[10, 20, 30, 40].map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{value}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p className="stock-filter__note">
|
||||||
|
최신 뉴스가 먼저 표시됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-panel stock-panel--compact">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">요약</p>
|
||||||
|
<h3>뉴스 요약</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
최신 발행 시각과 기사 수를 확인합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-status">
|
||||||
|
<div>
|
||||||
|
<span>최신 발행</span>
|
||||||
|
<strong>{formatDate(latestPublished)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>국내</span>
|
||||||
|
<strong>{newsDomestic.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>해외</span>
|
||||||
|
<strong>{newsOverseas.length}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">헤드라인</p>
|
||||||
|
<h3>시장 뉴스</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
Stock Lab API에서 최신 뉴스를 불러옵니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="stock-panel__actions">
|
||||||
|
{loading && <span className="stock-chip">Updating...</span>}
|
||||||
|
<span className="stock-chip">
|
||||||
|
국내 {newsDomestic.length} / 해외{' '}
|
||||||
|
{newsOverseas.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && combinedNews.length === 0 ? (
|
||||||
|
<Loading type="skeleton" />
|
||||||
|
) : newsError ? (
|
||||||
|
<p className="stock-empty">{newsError}</p>
|
||||||
|
) : combinedNews.length === 0 ? (
|
||||||
|
<p className="stock-empty">뉴스가 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="stock-tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`stock-tab ${newsCategory === 'domestic'
|
||||||
|
? 'is-active'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
onClick={() => setNewsCategory('domestic')}
|
||||||
|
>
|
||||||
|
국내
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`stock-tab ${newsCategory === 'overseas'
|
||||||
|
? 'is-active'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
onClick={() => setNewsCategory('overseas')}
|
||||||
|
>
|
||||||
|
해외
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{activeNews.length === 0 ? (
|
||||||
|
<p className="stock-empty">
|
||||||
|
해당 카테고리 뉴스가 없습니다.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="stock-news">
|
||||||
|
{activeNews.map((item) => (
|
||||||
|
<article
|
||||||
|
key={item.id ?? item.link}
|
||||||
|
className="stock-news__item"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="stock-news__title">
|
||||||
|
{item.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="stock-news__meta">
|
||||||
|
<span>
|
||||||
|
{formatDate(item.published_at)}
|
||||||
|
</span>
|
||||||
|
{item.sentiment ? (
|
||||||
|
<span className="stock-chip">
|
||||||
|
{item.sentiment}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<a
|
||||||
|
href={item.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
원문 보기
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Stock;
|
||||||
424
src/pages/stock/StockTrade.jsx
Normal file
424
src/pages/stock/StockTrade.jsx
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { createTradeOrder, getTradeBalance } from '../../api';
|
||||||
|
import './Stock.css';
|
||||||
|
|
||||||
|
const formatNumber = (value) => {
|
||||||
|
if (value === null || value === undefined || value === '') return '-';
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (Number.isNaN(numeric)) return value;
|
||||||
|
return new Intl.NumberFormat('ko-KR').format(numeric);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (value) => {
|
||||||
|
if (value === null || value === undefined || value === '') return '-';
|
||||||
|
if (typeof value === 'string' && value.includes('%')) return value;
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (Number.isNaN(numeric)) return value;
|
||||||
|
return `${numeric.toFixed(2)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickFirst = (...values) =>
|
||||||
|
values.find((value) => value !== undefined && value !== null && value !== '');
|
||||||
|
|
||||||
|
const getQty = (item) =>
|
||||||
|
pickFirst(item?.qty, item?.quantity, item?.holding, item?.hold_qty);
|
||||||
|
|
||||||
|
const getBuyPrice = (item) =>
|
||||||
|
pickFirst(
|
||||||
|
item?.buy_price,
|
||||||
|
item?.avg_price,
|
||||||
|
item?.avg,
|
||||||
|
item?.purchase_price,
|
||||||
|
item?.buyPrice,
|
||||||
|
item?.price
|
||||||
|
);
|
||||||
|
|
||||||
|
const getCurrentPrice = (item) =>
|
||||||
|
pickFirst(
|
||||||
|
item?.current_price,
|
||||||
|
item?.current,
|
||||||
|
item?.cur_price,
|
||||||
|
item?.now_price,
|
||||||
|
item?.market_price
|
||||||
|
);
|
||||||
|
|
||||||
|
const getProfitRate = (item) =>
|
||||||
|
pickFirst(
|
||||||
|
item?.profit_rate,
|
||||||
|
item?.profitRate,
|
||||||
|
item?.profit_pct,
|
||||||
|
item?.profitPercent,
|
||||||
|
item?.pnl_rate,
|
||||||
|
item?.return_rate,
|
||||||
|
item?.yield
|
||||||
|
);
|
||||||
|
|
||||||
|
const getProfitLoss = (item) =>
|
||||||
|
pickFirst(item?.profit_loss, item?.pnl, item?.profitLoss);
|
||||||
|
|
||||||
|
const toNumeric = (value) => {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const numeric = Number(String(value).replace(/[^0-9.-]/g, ''));
|
||||||
|
return Number.isNaN(numeric) ? null : numeric;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StockTrade = () => {
|
||||||
|
const [balance, setBalance] = useState(null);
|
||||||
|
const [balanceLoading, setBalanceLoading] = useState(false);
|
||||||
|
const [balanceError, setBalanceError] = useState('');
|
||||||
|
const [manualForm, setManualForm] = useState({
|
||||||
|
code: '',
|
||||||
|
qty: 1,
|
||||||
|
price: 0,
|
||||||
|
type: 'buy',
|
||||||
|
});
|
||||||
|
const [manualLoading, setManualLoading] = useState(false);
|
||||||
|
const [manualError, setManualError] = useState('');
|
||||||
|
const [manualResult, setManualResult] = useState(null);
|
||||||
|
const [kisModal, setKisModal] = useState('');
|
||||||
|
|
||||||
|
const loadBalance = async () => {
|
||||||
|
setBalanceLoading(true);
|
||||||
|
setBalanceError('');
|
||||||
|
try {
|
||||||
|
const data = await getTradeBalance();
|
||||||
|
setBalance(data);
|
||||||
|
} catch (err) {
|
||||||
|
setBalanceError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setBalanceLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitManualOrder = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setManualLoading(true);
|
||||||
|
setManualError('');
|
||||||
|
setManualResult(null);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
ticker: manualForm.code.trim(),
|
||||||
|
action: manualForm.type === 'sell' ? 'SELL' : 'BUY',
|
||||||
|
quantity: Number(manualForm.qty),
|
||||||
|
price: Number(manualForm.price),
|
||||||
|
};
|
||||||
|
const result = await createTradeOrder(payload);
|
||||||
|
setManualResult(result ?? { ok: true });
|
||||||
|
if (result?.kis_result !== undefined) {
|
||||||
|
const message =
|
||||||
|
typeof result.kis_result === 'string'
|
||||||
|
? result.kis_result
|
||||||
|
: JSON.stringify(result.kis_result, null, 2);
|
||||||
|
setKisModal(message);
|
||||||
|
}
|
||||||
|
await loadBalance();
|
||||||
|
} catch (err) {
|
||||||
|
setManualError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setManualLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadBalance();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const holdings = useMemo(() => {
|
||||||
|
if (!balance) return [];
|
||||||
|
if (Array.isArray(balance.holdings)) return balance.holdings;
|
||||||
|
if (Array.isArray(balance.positions)) return balance.positions;
|
||||||
|
if (Array.isArray(balance.items)) return balance.items;
|
||||||
|
return [];
|
||||||
|
}, [balance]);
|
||||||
|
const summary = balance?.summary ?? {};
|
||||||
|
const totalEval =
|
||||||
|
summary.total_eval ?? balance?.total_eval ?? balance?.total_value;
|
||||||
|
const deposit =
|
||||||
|
summary.deposit ?? balance?.deposit ?? balance?.available_cash;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stock">
|
||||||
|
<header className="stock-header">
|
||||||
|
<div>
|
||||||
|
<p className="stock-kicker">거래 데스크</p>
|
||||||
|
<h1>주식 거래</h1>
|
||||||
|
<p className="stock-sub">
|
||||||
|
연결된 계좌 잔고를 확인하고 필요한 주문을 요청하세요.
|
||||||
|
</p>
|
||||||
|
<div className="stock-actions">
|
||||||
|
<Link className="button ghost" to="/stock">
|
||||||
|
주식 랩으로 돌아가기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-card">
|
||||||
|
<p className="stock-card__title">계좌 요약</p>
|
||||||
|
<div className="stock-status">
|
||||||
|
<div>
|
||||||
|
<span>총 평가금액</span>
|
||||||
|
<strong>{formatNumber(totalEval)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>예수금</span>
|
||||||
|
<strong>{formatNumber(deposit)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>보유 종목</span>
|
||||||
|
<strong>{holdings.length}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{summary.note ? (
|
||||||
|
<p className="stock-status__note">{summary.note}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{balanceError ? <p className="stock-error">{balanceError}</p> : null}
|
||||||
|
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">잔고</p>
|
||||||
|
<h3>보유 현황</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
연결 계좌의 실시간 잔고와 보유 종목을 확인합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="stock-panel__actions">
|
||||||
|
{balanceLoading ? (
|
||||||
|
<span className="stock-chip">조회 중</span>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={loadBalance}
|
||||||
|
disabled={balanceLoading}
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-balance">
|
||||||
|
<div className="stock-balance__summary">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
label: '총 평가',
|
||||||
|
value: totalEval,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '예수금',
|
||||||
|
value: deposit,
|
||||||
|
},
|
||||||
|
].map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className="stock-balance__card"
|
||||||
|
>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<strong>{formatNumber(item.value)}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{holdings.length ? (
|
||||||
|
<div className="stock-holdings">
|
||||||
|
{holdings.map((item, idx) => {
|
||||||
|
const profitLoss = getProfitLoss(item);
|
||||||
|
const profitLossNumeric = toNumeric(profitLoss);
|
||||||
|
const profitClass =
|
||||||
|
profitLossNumeric > 0
|
||||||
|
? 'is-up'
|
||||||
|
: profitLossNumeric < 0
|
||||||
|
? 'is-down'
|
||||||
|
: profitLossNumeric === 0
|
||||||
|
? 'is-flat'
|
||||||
|
: '';
|
||||||
|
const profitRate = getProfitRate(item);
|
||||||
|
const profitRateNumeric = toNumeric(profitRate);
|
||||||
|
const profitRateClass =
|
||||||
|
profitRateNumeric > 0
|
||||||
|
? 'is-up'
|
||||||
|
: profitRateNumeric < 0
|
||||||
|
? 'is-down'
|
||||||
|
: profitRateNumeric === 0
|
||||||
|
? 'is-flat'
|
||||||
|
: '';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.code ?? `${item.name}-${idx}`}
|
||||||
|
className="stock-holdings__item"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="stock-holdings__name">
|
||||||
|
{item.name ?? item.code ?? 'N/A'}
|
||||||
|
</p>
|
||||||
|
<span className="stock-holdings__code">
|
||||||
|
{item.code ?? ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>수량</span>
|
||||||
|
<strong>
|
||||||
|
{formatNumber(getQty(item))}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>매입가</span>
|
||||||
|
<strong>
|
||||||
|
{formatNumber(getBuyPrice(item))}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>현재가</span>
|
||||||
|
<strong>
|
||||||
|
{formatNumber(
|
||||||
|
getCurrentPrice(item)
|
||||||
|
)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>수익률</span>
|
||||||
|
<strong
|
||||||
|
className={`stock-profit ${profitRateClass}`}
|
||||||
|
>
|
||||||
|
{formatPercent(profitRate)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>평가손익</span>
|
||||||
|
<strong
|
||||||
|
className={`stock-profit ${profitClass}`}
|
||||||
|
>
|
||||||
|
{formatNumber(profitLoss)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="stock-empty">보유 종목이 없습니다.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">수동 주문</p>
|
||||||
|
<h3>직접 매수/매도</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
종목명 또는 종목코드를 입력하고 매수/매도 주문을
|
||||||
|
요청합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form className="stock-order" onSubmit={submitManualOrder}>
|
||||||
|
<label>
|
||||||
|
종목명/코드
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={manualForm.code}
|
||||||
|
onChange={(event) =>
|
||||||
|
setManualForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
code: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="005930 또는 삼성전자"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
매수/매도
|
||||||
|
<select
|
||||||
|
value={manualForm.type}
|
||||||
|
onChange={(event) =>
|
||||||
|
setManualForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
type: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="buy">매수</option>
|
||||||
|
<option value="sell">매도</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
수량
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={manualForm.qty}
|
||||||
|
onChange={(event) =>
|
||||||
|
setManualForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
qty: Number(event.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
금액(원)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={manualForm.price}
|
||||||
|
onChange={(event) =>
|
||||||
|
setManualForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
price: Number(event.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={manualLoading}
|
||||||
|
>
|
||||||
|
{manualLoading ? '요청 중...' : '주문 요청'}
|
||||||
|
</button>
|
||||||
|
{manualError ? (
|
||||||
|
<p className="stock-error">{manualError}</p>
|
||||||
|
) : null}
|
||||||
|
{manualResult ? (
|
||||||
|
<div className="stock-result">
|
||||||
|
<p className="stock-result__title">요청 결과</p>
|
||||||
|
<pre>
|
||||||
|
{typeof manualResult === 'string'
|
||||||
|
? manualResult
|
||||||
|
: JSON.stringify(manualResult, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{kisModal ? (
|
||||||
|
<div className="stock-modal" role="dialog" aria-modal="true">
|
||||||
|
<div
|
||||||
|
className="stock-modal__backdrop"
|
||||||
|
onClick={() => setKisModal('')}
|
||||||
|
/>
|
||||||
|
<div className="stock-modal__card">
|
||||||
|
<div className="stock-modal__head">
|
||||||
|
<h4>주문 결과</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => setKisModal('')}
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre>{kisModal}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StockTrade;
|
||||||
@@ -24,6 +24,12 @@
|
|||||||
font-size: clamp(30px, 4vw, 40px);
|
font-size: clamp(30px, 4vw, 40px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.travel-header h1 {
|
||||||
|
font-size: clamp(24px, 6vw, 32px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.travel-sub {
|
.travel-sub {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -52,11 +58,75 @@
|
|||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.travel-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.travel-albums {
|
.travel-albums {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.travel-albums.is-blurred {
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: scale(0.995);
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-albums.is-blurred * {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-map {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-map__canvas {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 520px;
|
||||||
|
border-radius: 22px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(10, 12, 20, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.travel-map__canvas {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-map__info {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(10, 12, 20, 0.75);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
align-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-map__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-map__desc {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.travel-album {
|
.travel-album {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
@@ -66,6 +136,28 @@
|
|||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.travel-album__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-load-more {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(10, 12, 20, 0.6);
|
||||||
|
color: #f8f4f0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-load-more:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.travel-album__head {
|
.travel-album__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -114,12 +206,29 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
min-height: 220px;
|
min-height: 220px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(22px) scale(0.98);
|
||||||
|
transition: opacity 0.45s ease, transform 0.45s ease;
|
||||||
|
transition-delay: var(--reveal-delay, 0ms);
|
||||||
|
will-change: opacity, transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.travel-card {
|
||||||
|
min-height: 150px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.travel-card.is-wide {
|
.travel-card.is-wide {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.travel-card[data-revealed='true'] {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
.travel-card img {
|
.travel-card img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -146,6 +255,349 @@
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.travel-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(6, 8, 12, 0.55);
|
||||||
|
backdrop-filter: blur(var(--modal-blur, 6px));
|
||||||
|
display: grid;
|
||||||
|
align-items: start;
|
||||||
|
justify-items: center;
|
||||||
|
padding: 28px 24px 24px;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__content {
|
||||||
|
position: relative;
|
||||||
|
max-width: min(1200px, 94vw);
|
||||||
|
max-height: 90vh;
|
||||||
|
background: rgba(10, 12, 20, 0.92);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 20px;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-reveal] {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px);
|
||||||
|
transition: opacity 0.5s ease, transform 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-reveal][data-revealed='true'] {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__summary {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__summary-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #f1c07a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__summary-meta {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__stage {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__controls {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__control {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-blur-slider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-blur-slider input[type='range'] {
|
||||||
|
appearance: none;
|
||||||
|
width: 140px;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 999px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-blur-slider input[type='range']::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f8f4f0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-blur-slider input[type='range']::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f8f4f0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-blur-value {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 68vh;
|
||||||
|
max-height: 68vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(8, 10, 16, 0.3);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__image {
|
||||||
|
width: min(100%, 980px);
|
||||||
|
max-height: 68vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__image.is-next {
|
||||||
|
animation: travel-slide-next 280ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__image.is-prev {
|
||||||
|
animation: travel-slide-prev 280ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__strip-wrap {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__thumb {
|
||||||
|
width: 64px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(10, 12, 20, 0.6);
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__thumb.is-active {
|
||||||
|
opacity: 1;
|
||||||
|
border-color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__strip-arrow {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(10, 12, 20, 0.7);
|
||||||
|
color: #f8f4f0;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__strip-arrow:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__meta {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__close {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(10, 12, 20, 0.8);
|
||||||
|
color: #f8f4f0;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__arrow {
|
||||||
|
pointer-events: auto;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(10, 12, 20, 0.85);
|
||||||
|
color: #f8f4f0;
|
||||||
|
font-size: 26px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__arrow.is-loading {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__arrow-icon {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__spinner {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
margin: auto;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.9);
|
||||||
|
animation: travel-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__arrow:hover {
|
||||||
|
transform: translateY(-1px) scale(1.02);
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__arrow:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.4;
|
||||||
|
transform: none;
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes travel-slide-next {
|
||||||
|
from {
|
||||||
|
transform: translateX(20px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes travel-slide-prev {
|
||||||
|
from {
|
||||||
|
transform: translateX(-20px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes travel-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__toast {
|
||||||
|
position: absolute;
|
||||||
|
left: 24px;
|
||||||
|
bottom: 20px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(10, 12, 20, 0.85);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: #f8f4f0;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.travel-modal__content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__stage {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__frame {
|
||||||
|
height: 56vh;
|
||||||
|
max-height: 56vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__image {
|
||||||
|
max-height: 56vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-modal__arrow {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
font-size: 24px;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.travel-card__meta {
|
.travel-card__meta {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -159,6 +611,10 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.travel-map {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.travel-card.is-wide {
|
.travel-card.is-wide {
|
||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
@@ -168,3 +624,12 @@
|
|||||||
height: 160px;
|
height: 160px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.travel-card,
|
||||||
|
[data-reveal] {
|
||||||
|
opacity: 1 !important;
|
||||||
|
transform: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,12 @@
|
|||||||
import React from 'react';
|
import React, { lazy } from 'react';
|
||||||
import Home from './pages/home/Home';
|
|
||||||
import Blog from './pages/blog/Blog';
|
const Home = lazy(() => import('./pages/home/Home'));
|
||||||
import Lotto from './pages/lotto/Lotto';
|
const Blog = lazy(() => import('./pages/blog/Blog'));
|
||||||
import Travel from './pages/travel/Travel';
|
const Lotto = lazy(() => import('./pages/lotto/Lotto'));
|
||||||
|
const Travel = lazy(() => import('./pages/travel/Travel'));
|
||||||
|
const Stock = lazy(() => import('./pages/stock/Stock'));
|
||||||
|
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
|
||||||
|
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
|
||||||
|
|
||||||
export const navLinks = [
|
export const navLinks = [
|
||||||
{
|
{
|
||||||
@@ -23,12 +27,24 @@ export const navLinks = [
|
|||||||
path: '/lotto',
|
path: '/lotto',
|
||||||
description: '숫자를 뽑고 통계를 확인하는 실험실',
|
description: '숫자를 뽑고 통계를 확인하는 실험실',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'stock',
|
||||||
|
label: 'Stock',
|
||||||
|
path: '/stock',
|
||||||
|
description: '아침 시장 흐름을 확인하는 주식 연구실',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'travel',
|
id: 'travel',
|
||||||
label: 'Travel',
|
label: 'Travel',
|
||||||
path: '/travel',
|
path: '/travel',
|
||||||
description: '여행에서 담은 색과 장면을 전시하는 갤러리',
|
description: '여행에서 담은 색과 장면을 전시하는 갤러리',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'lab',
|
||||||
|
label: 'Lab',
|
||||||
|
path: '/lab',
|
||||||
|
description: '실험적인 UI/UX 효과를 테스트하는 공간',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const appRoutes = [
|
export const appRoutes = [
|
||||||
@@ -44,8 +60,20 @@ export const appRoutes = [
|
|||||||
path: 'lotto',
|
path: 'lotto',
|
||||||
element: <Lotto />,
|
element: <Lotto />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'stock',
|
||||||
|
element: <Stock />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'stock/trade',
|
||||||
|
element: <StockTrade />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'travel',
|
path: 'travel',
|
||||||
element: <Travel />,
|
element: <Travel />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'lab',
|
||||||
|
element: <EffectLab />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user