feat: use material ui
This commit is contained in:
@@ -19,17 +19,17 @@ traveling-around-the-world/
|
||||
|
||||
### 技術與工具
|
||||
- Vite + React 18 + TypeScript
|
||||
- Material UI + Emotion 建構一致的 UI 與主題
|
||||
- React Router 規劃 SPA 路由
|
||||
- Zustand 追蹤 UI 狀態(訪問表單 Modal 與初始座標)
|
||||
- React Query 統一處理資料讀寫與快取
|
||||
- React Hook Form + Zod 表單驗證
|
||||
- react-leaflet + OpenStreetMap 圖資,支援地圖點擊新增足跡
|
||||
- Tailwind CSS (JIT) 打造快速 UI
|
||||
|
||||
### 模組切分
|
||||
- `features/map`:Leaflet 地圖、點擊事件、旅遊標記
|
||||
- `features/visits`:資料型別、LocalStorage Repository、React Query hooks、統計卡片與表單元件
|
||||
- `components/layout`:頁面主框架、側邊欄
|
||||
- `components/layout`:MUI AppBar + Drawer 布局、語言切換等元件
|
||||
- `state`:Zustand store 控制訪問建立/編輯的 Modal
|
||||
- `components/overlay/VisitModal`:以 Modal 呈現足跡表單,地圖右下角的 `+` 漂浮按鈕或點擊地圖即可開啟
|
||||
|
||||
|
712
client/package-lock.json
generated
712
client/package-lock.json
generated
@@ -8,7 +8,11 @@
|
||||
"name": "traveling-around-the-world-client",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@mui/icons-material": "^5.15.14",
|
||||
"@mui/material": "^5.15.14",
|
||||
"@tanstack/react-query": "^5.29.0",
|
||||
"i18next": "^23.11.5",
|
||||
"leaflet": "^1.9.4",
|
||||
@@ -54,6 +58,84 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.27.1",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
|
||||
"integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.3",
|
||||
"@babel/types": "^7.28.2",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-imports": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
|
||||
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.27.1",
|
||||
"@babel/types": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
||||
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
|
||||
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.4"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||
@@ -62,6 +144,181 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/parser": "^7.27.2",
|
||||
"@babel/types": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz",
|
||||
"integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.28.4",
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/types": "^7.28.4",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
|
||||
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin": {
|
||||
"version": "11.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
||||
"integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.16.7",
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/hash": "^0.9.2",
|
||||
"@emotion/memoize": "^0.9.0",
|
||||
"@emotion/serialize": "^1.3.3",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"convert-source-map": "^1.5.0",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"find-root": "^1.1.0",
|
||||
"source-map": "^0.5.7",
|
||||
"stylis": "4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/cache": {
|
||||
"version": "11.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
|
||||
"integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
|
||||
"dependencies": {
|
||||
"@emotion/memoize": "^0.9.0",
|
||||
"@emotion/sheet": "^1.4.0",
|
||||
"@emotion/utils": "^1.4.2",
|
||||
"@emotion/weak-memoize": "^0.4.0",
|
||||
"stylis": "4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/hash": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
||||
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="
|
||||
},
|
||||
"node_modules/@emotion/is-prop-valid": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
|
||||
"integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
|
||||
"dependencies": {
|
||||
"@emotion/memoize": "^0.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/memoize": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
|
||||
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="
|
||||
},
|
||||
"node_modules/@emotion/react": {
|
||||
"version": "11.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/cache": "^11.14.0",
|
||||
"@emotion/serialize": "^1.3.3",
|
||||
"@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
|
||||
"@emotion/utils": "^1.4.2",
|
||||
"@emotion/weak-memoize": "^0.4.0",
|
||||
"hoist-non-react-statics": "^3.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/serialize": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
|
||||
"integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
|
||||
"dependencies": {
|
||||
"@emotion/hash": "^0.9.2",
|
||||
"@emotion/memoize": "^0.9.0",
|
||||
"@emotion/unitless": "^0.10.0",
|
||||
"@emotion/utils": "^1.4.2",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/sheet": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
|
||||
"integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg=="
|
||||
},
|
||||
"node_modules/@emotion/styled": {
|
||||
"version": "11.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
|
||||
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/is-prop-valid": "^1.3.0",
|
||||
"@emotion/serialize": "^1.3.3",
|
||||
"@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
|
||||
"@emotion/utils": "^1.4.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "^11.0.0-rc.0",
|
||||
"react": ">=16.8.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/unitless": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
|
||||
"integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="
|
||||
},
|
||||
"node_modules/@emotion/use-insertion-effect-with-fallbacks": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz",
|
||||
"integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/utils": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
|
||||
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA=="
|
||||
},
|
||||
"node_modules/@emotion/weak-memoize": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
|
||||
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@@ -624,7 +881,6 @@
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
@@ -634,7 +890,6 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
@@ -642,19 +897,244 @@
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/core-downloads-tracker": {
|
||||
"version": "5.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz",
|
||||
"integrity": "sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/icons-material": {
|
||||
"version": "5.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.18.0.tgz",
|
||||
"integrity": "sha512-1s0vEZj5XFXDMmz3Arl/R7IncFqJ+WQ95LDp1roHWGDE2oCO3IS4/hmiOv1/8SD9r6B7tv9GLiqVZYHo+6PkTg==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mui/material": "^5.0.0",
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/material": {
|
||||
"version": "5.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz",
|
||||
"integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.9",
|
||||
"@mui/core-downloads-tracker": "^5.18.0",
|
||||
"@mui/system": "^5.18.0",
|
||||
"@mui/types": "~7.2.15",
|
||||
"@mui/utils": "^5.17.1",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@types/react-transition-group": "^4.4.10",
|
||||
"clsx": "^2.1.0",
|
||||
"csstype": "^3.1.3",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-is": "^19.0.0",
|
||||
"react-transition-group": "^4.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "^11.5.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@emotion/styled": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/material/node_modules/react-is": {
|
||||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz",
|
||||
"integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA=="
|
||||
},
|
||||
"node_modules/@mui/private-theming": {
|
||||
"version": "5.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz",
|
||||
"integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.9",
|
||||
"@mui/utils": "^5.17.1",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/styled-engine": {
|
||||
"version": "5.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz",
|
||||
"integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.9",
|
||||
"@emotion/cache": "^11.13.5",
|
||||
"@emotion/serialize": "^1.3.3",
|
||||
"csstype": "^3.1.3",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@emotion/styled": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/system": {
|
||||
"version": "5.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz",
|
||||
"integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.9",
|
||||
"@mui/private-theming": "^5.17.1",
|
||||
"@mui/styled-engine": "^5.18.0",
|
||||
"@mui/types": "~7.2.15",
|
||||
"@mui/utils": "^5.17.1",
|
||||
"clsx": "^2.1.0",
|
||||
"csstype": "^3.1.3",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "^11.5.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@emotion/styled": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/types": {
|
||||
"version": "7.2.24",
|
||||
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz",
|
||||
"integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/utils": {
|
||||
"version": "5.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz",
|
||||
"integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.9",
|
||||
"@mui/types": "~7.2.15",
|
||||
"@types/prop-types": "^15.7.12",
|
||||
"clsx": "^2.1.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-is": "^19.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/utils/node_modules/react-is": {
|
||||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz",
|
||||
"integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA=="
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -700,6 +1180,15 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
|
||||
@@ -1283,17 +1772,20 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/parse-json": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
||||
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="
|
||||
},
|
||||
"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==",
|
||||
"devOptional": true
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.25",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.25.tgz",
|
||||
"integrity": "sha512-oSVZmGtDPmRZtVDqvdKUi/qgCsWp5IDY29wp8na8Bj4B3cc99hfNzvNhlMkVVxctkAOGUA3Km7MMpBHAnWfcIA==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
@@ -1308,6 +1800,14 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-transition-group": {
|
||||
"version": "4.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
|
||||
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
|
||||
@@ -1825,6 +2325,39 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-macros": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
|
||||
"integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"cosmiconfig": "^7.0.0",
|
||||
"resolve": "^1.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-macros/node_modules/resolve": {
|
||||
"version": "1.22.10",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||
"integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
|
||||
"dependencies": {
|
||||
"is-core-module": "^2.16.0",
|
||||
"path-parse": "^1.0.7",
|
||||
"supports-preserve-symlinks-flag": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"resolve": "bin/resolve"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -1957,7 +2490,6 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -2043,6 +2575,14 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -2076,6 +2616,34 @@
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
||||
"integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
|
||||
"dependencies": {
|
||||
"@types/parse-json": "^4.0.0",
|
||||
"import-fresh": "^3.2.1",
|
||||
"parse-json": "^5.0.0",
|
||||
"path-type": "^4.0.0",
|
||||
"yaml": "^1.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/cosmiconfig/node_modules/yaml": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2105,8 +2673,7 @@
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"devOptional": true
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||
},
|
||||
"node_modules/data-view-buffer": {
|
||||
"version": "1.0.2",
|
||||
@@ -2163,7 +2730,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
@@ -2252,6 +2818,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -2284,6 +2859,14 @@
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-abstract": {
|
||||
"version": "1.24.0",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
|
||||
@@ -2504,7 +3087,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -2855,6 +3437,11 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-root": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
|
||||
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
@@ -2959,7 +3546,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
@@ -3262,7 +3848,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
@@ -3270,6 +3855,14 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||
"dependencies": {
|
||||
"react-is": "^16.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
@@ -3313,7 +3906,6 @@
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"parent-module": "^1.0.0",
|
||||
"resolve-from": "^4.0.0"
|
||||
@@ -3382,6 +3974,11 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="
|
||||
},
|
||||
"node_modules/is-async-function": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
|
||||
@@ -3460,7 +4057,6 @@
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
@@ -3831,12 +4427,28 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
||||
"bin": {
|
||||
"jsesc": "bin/jsesc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/json-buffer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/json-parse-even-better-errors": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
|
||||
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@@ -3906,8 +4518,7 @@
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
@@ -4005,8 +4616,7 @@
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
@@ -4070,7 +4680,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -4259,7 +4868,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"callsites": "^3.0.0"
|
||||
},
|
||||
@@ -4267,6 +4875,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-json": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.0.0",
|
||||
"error-ex": "^1.3.1",
|
||||
"json-parse-even-better-errors": "^2.3.0",
|
||||
"lines-and-columns": "^1.1.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -4297,8 +4922,7 @@
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "1.11.1",
|
||||
@@ -4320,7 +4944,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -4328,8 +4951,7 @@
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
@@ -4585,7 +5207,6 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
@@ -4683,8 +5304,7 @@
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "4.2.1",
|
||||
@@ -4729,6 +5349,21 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -4813,7 +5448,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
@@ -5140,6 +5774,14 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -5357,6 +5999,11 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/stylis": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
|
||||
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.0",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
|
||||
@@ -5430,7 +6077,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
|
@@ -22,7 +22,11 @@
|
||||
"nanoid": "^5.0.7",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"i18next": "^23.11.5",
|
||||
"react-i18next": "^14.1.1"
|
||||
"react-i18next": "^14.1.1",
|
||||
"@mui/material": "^5.15.14",
|
||||
"@mui/icons-material": "^5.15.14",
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.12",
|
||||
|
@@ -1,11 +1,13 @@
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import { VisitProvider } from '../features/visits/context/VisitProvider';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ThemeProvider, CssBaseline } from '@mui/material';
|
||||
import { PrimaryLayout } from '../components/layout/PrimaryLayout';
|
||||
import { VisitSidebar } from '../components/layout/VisitSidebar';
|
||||
import { TravelMap } from '../features/map/TravelMap';
|
||||
import { VisitModal } from '../components/overlay/VisitModal';
|
||||
import { useVisitModalStore } from '../state/useVisitModalStore';
|
||||
import { appTheme } from '../styles/theme';
|
||||
|
||||
function HomePage() {
|
||||
const openForCreate = useVisitModalStore((state) => state.openForCreate);
|
||||
@@ -32,6 +34,8 @@ function HomePage() {
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ThemeProvider theme={appTheme}>
|
||||
<CssBaseline />
|
||||
<VisitProvider mode="local">
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
@@ -40,5 +44,6 @@ export function App() {
|
||||
</BrowserRouter>
|
||||
<VisitModal />
|
||||
</VisitProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FormControl, InputLabel, MenuItem, Select } from '@mui/material';
|
||||
import { SUPPORTED_LANGUAGES, type SupportedLanguage } from '../../i18n';
|
||||
|
||||
const LABEL_LOOKUP: Record<SupportedLanguage, string> = {
|
||||
@@ -13,22 +14,24 @@ export function LanguageSwitcher() {
|
||||
SUPPORTED_LANGUAGES.find((language) => i18n.language.startsWith(language)) ?? 'zh-Hant';
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<span>{t('language.label')}</span>
|
||||
<select
|
||||
className="rounded border border-slate-300 bg-white px-2 py-1 text-sm"
|
||||
<FormControl size="small">
|
||||
<InputLabel id="language-switcher-label">{t('language.label')}</InputLabel>
|
||||
<Select
|
||||
labelId="language-switcher-label"
|
||||
label={t('language.label')}
|
||||
value={currentLanguage}
|
||||
onChange={(event) => {
|
||||
const selected = event.target.value as SupportedLanguage;
|
||||
void i18n.changeLanguage(selected);
|
||||
}}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((language) => (
|
||||
<option key={language} value={language}>
|
||||
<MenuItem key={language} value={language}>
|
||||
{t(LABEL_LOOKUP[language])}
|
||||
</option>
|
||||
</MenuItem>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
@@ -1,5 +1,20 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AppBar,
|
||||
Box,
|
||||
Divider,
|
||||
Drawer,
|
||||
IconButton,
|
||||
Link,
|
||||
Stack,
|
||||
Toolbar,
|
||||
Typography,
|
||||
useMediaQuery
|
||||
} from '@mui/material';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
|
||||
interface PrimaryLayoutProps {
|
||||
@@ -8,61 +23,99 @@ interface PrimaryLayoutProps {
|
||||
onSidebarToggle?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
const SIDEBAR_WIDTH = 320;
|
||||
|
||||
export function PrimaryLayout({ sidebar, content, onSidebarToggle }: PrimaryLayoutProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
const theme = useTheme();
|
||||
const isDesktop = useMediaQuery(theme.breakpoints.up('lg'));
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(isDesktop);
|
||||
|
||||
useEffect(() => {
|
||||
setIsSidebarOpen(isDesktop);
|
||||
}, [isDesktop]);
|
||||
|
||||
useEffect(() => {
|
||||
onSidebarToggle?.(isSidebarOpen);
|
||||
}, [isSidebarOpen, onSidebarToggle]);
|
||||
|
||||
const toggleSidebar = () => setIsSidebarOpen((prev) => !prev);
|
||||
|
||||
const toolbarOffset = { xs: '56px', sm: '64px' } as const;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<header className="border-b border-slate-200 bg-white">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
<Box sx={{ display: 'flex', minHeight: '100vh', flexDirection: 'column', bgcolor: 'background.default' }}>
|
||||
<AppBar position="fixed" color="transparent" elevation={0} sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Toolbar sx={{ maxWidth: 1200, mx: 'auto', width: '100%' }}>
|
||||
<Stack direction="row" alignItems="center" spacing={2} sx={{ flexGrow: 1 }}>
|
||||
<IconButton
|
||||
size="large"
|
||||
edge="start"
|
||||
color="primary"
|
||||
aria-label={t(isSidebarOpen ? 'sidebar.toggleClose' : 'sidebar.toggleOpen')}
|
||||
aria-controls="visit-sidebar"
|
||||
aria-expanded={isSidebarOpen}
|
||||
className="rounded bg-slate-100 p-2 text-slate-600 transition hover:bg-slate-200 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
onClick={() => setIsSidebarOpen((prev) => !prev)}
|
||||
onClick={toggleSidebar}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
<span className="block text-xl leading-none">{isSidebarOpen ? '⟨' : '☰'}</span>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t('common.appName')}</h1>
|
||||
<p className="text-sm text-slate-500">{t('common.tagline')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<nav className="flex items-center gap-3 text-sm text-slate-500">
|
||||
<a href="https://www.openstreetmap.org/" target="_blank" rel="noreferrer" className="hover:text-primary">
|
||||
{isSidebarOpen ? <ChevronLeftIcon /> : <MenuIcon />}
|
||||
</IconButton>
|
||||
<Box>
|
||||
<Typography variant="h5" fontWeight={700} color="text.primary">
|
||||
{t('common.appName')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('common.tagline')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<Stack direction="row" spacing={2} sx={{ display: { xs: 'none', sm: 'flex' } }}>
|
||||
<Link href="https://www.openstreetmap.org/" target="_blank" rel="noreferrer" underline="hover" color="text.secondary">
|
||||
{t('nav.openStreetMap')}
|
||||
</a>
|
||||
<a href="https://react-leaflet.js.org/" target="_blank" rel="noreferrer" className="hover:text-primary">
|
||||
</Link>
|
||||
<Link href="https://react-leaflet.js.org/" target="_blank" rel="noreferrer" underline="hover" color="text.secondary">
|
||||
{t('nav.reactLeaflet')}
|
||||
</a>
|
||||
</nav>
|
||||
</Link>
|
||||
</Stack>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 overflow-hidden">
|
||||
<aside
|
||||
id="visit-sidebar"
|
||||
aria-hidden={!isSidebarOpen}
|
||||
className={`flex-shrink-0 min-w-0 overflow-hidden transition-[flex-basis,max-width] duration-300 ease-in-out ${
|
||||
isSidebarOpen
|
||||
? 'basis-[25%] max-w-[25%] border-r border-slate-200'
|
||||
: 'pointer-events-none basis-0 max-w-0'
|
||||
}`}
|
||||
</Stack>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box sx={{ display: 'flex', flexGrow: 1, minHeight: 0, pt: toolbarOffset }}>
|
||||
<Drawer
|
||||
variant={isDesktop ? 'persistent' : 'temporary'}
|
||||
anchor="left"
|
||||
open={isSidebarOpen}
|
||||
onClose={toggleSidebar}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{
|
||||
width: { lg: SIDEBAR_WIDTH },
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
width: { xs: 280, sm: SIDEBAR_WIDTH },
|
||||
boxSizing: 'border-box',
|
||||
borderRight: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.default',
|
||||
top: toolbarOffset,
|
||||
height: {
|
||||
xs: `calc(100% - ${toolbarOffset.xs})`,
|
||||
sm: `calc(100% - ${toolbarOffset.sm})`
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSidebarOpen ? <div className="h-full">{sidebar}</div> : null}
|
||||
</aside>
|
||||
<section className="flex-1">{content}</section>
|
||||
</main>
|
||||
</div>
|
||||
<Box sx={{ p: 2, height: '100%', overflowY: 'auto' }}>
|
||||
{sidebar}
|
||||
</Box>
|
||||
</Drawer>
|
||||
|
||||
{isDesktop && (
|
||||
<Divider orientation="vertical" flexItem sx={{ display: isSidebarOpen ? 'block' : 'none' }} />
|
||||
)}
|
||||
|
||||
<Box component="main" sx={{ flexGrow: 1, minHeight: 0 }}>{content}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
@@ -1,4 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Card, CardContent, Stack, Typography } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { VisitList } from '../../features/visits/components/VisitList';
|
||||
import { VisitSummary } from '../../features/visits/components/VisitSummary';
|
||||
import { useVisitModalStore } from '../../state/useVisitModalStore';
|
||||
@@ -9,26 +11,33 @@ export function VisitSidebar() {
|
||||
const openForEdit = useVisitModalStore((state) => state.openForEdit);
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-col gap-4 overflow-y-auto bg-slate-50 p-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<Stack spacing={3} sx={{ height: '100%' }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t('sidebar.title')}</h2>
|
||||
<p className="text-sm text-slate-500">{t('sidebar.subtitle')}</p>
|
||||
<Typography variant="h6" fontWeight={600} color="text.primary">
|
||||
{t('sidebar.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('sidebar.subtitle')}
|
||||
</Typography>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-primary px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary/90"
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => openForCreate()}
|
||||
sx={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{t('sidebar.add')}
|
||||
</button>
|
||||
</header>
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<VisitSummary />
|
||||
|
||||
<section className="flex-1">
|
||||
<Card sx={{ flex: 1, overflow: 'hidden', display: 'flex' }}>
|
||||
<CardContent sx={{ flex: 1, overflowY: 'auto', p: 0 }}>
|
||||
<VisitList onEdit={openForEdit} />
|
||||
</section>
|
||||
</aside>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
@@ -1,4 +1,12 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useVisitModalStore } from '../../state/useVisitModalStore';
|
||||
import { VisitForm } from '../../features/visits/components/VisitForm';
|
||||
|
||||
@@ -6,29 +14,24 @@ export function VisitModal() {
|
||||
const { isOpen, close, editingVisit, initialLocation } = useVisitModalStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 px-4 py-10">
|
||||
<div className="relative w-full max-w-lg rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<header className="mb-4 flex items-start justify-between">
|
||||
<Dialog open={isOpen} onClose={close} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
<Typography variant="h6" component="span">
|
||||
{editingVisit ? t('sidebar.drawerTitleEdit') : t('sidebar.drawerTitleCreate')}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">{t('sidebar.subtitle')}</p>
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" display="block">
|
||||
{t('sidebar.subtitle')}
|
||||
</Typography>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('sidebar.close')}
|
||||
className="rounded bg-slate-100 px-2 py-1 text-sm text-slate-500 hover:bg-slate-200"
|
||||
onClick={close}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</header>
|
||||
<IconButton aria-label={t('sidebar.close')} onClick={close} edge="end">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<VisitForm editingVisit={editingVisit} initialLocation={initialLocation} onCompleted={close} onCancel={close} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
@@ -8,6 +8,8 @@ import { useVisitsQuery } from '../visits/hooks/useVisitQueries';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useResizeMap } from '../visits/hooks/useResizeMap';
|
||||
import type { Map as LeafletMap } from 'leaflet';
|
||||
import { Box, Fab, Tooltip } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
|
||||
const INITIAL_POSITION: [number, number] = [20, 0];
|
||||
const INITIAL_ZOOM = 2;
|
||||
@@ -43,7 +45,7 @@ export function TravelMap({ onTriggerCreate, sidebarOpen = true }: TravelMapProp
|
||||
useResizeMap(mapRef, [sidebarOpen]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<Box sx={{ position: 'relative', height: '100%', width: '100%' }}>
|
||||
<MapContainer
|
||||
whenCreated={(mapInstance) => {
|
||||
mapRef.current = mapInstance;
|
||||
@@ -74,14 +76,15 @@ export function TravelMap({ onTriggerCreate, sidebarOpen = true }: TravelMapProp
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('sidebar.add')}
|
||||
className="absolute bottom-5 right-5 z-10 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-2xl font-bold text-white shadow-lg transition hover:bg-primary/90"
|
||||
<Tooltip title={t('sidebar.add')} placement="left">
|
||||
<Fab
|
||||
color="primary"
|
||||
sx={{ position: 'absolute', bottom: 24, right: 24, zIndex: 1000 }}
|
||||
onClick={() => (onTriggerCreate ? onTriggerCreate() : openForCreate())}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<AddIcon />
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
@@ -2,6 +2,15 @@ import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Grid,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import type { Visit, VisitCreateInput } from '../types';
|
||||
import { useCreateVisitMutation, useUpdateVisitMutation } from '../hooks/useVisitQueries';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -171,114 +180,107 @@ export function VisitForm({ editingVisit, onCompleted, onCancel, initialLocation
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium" htmlFor="country">
|
||||
{t('form.country')}
|
||||
</label>
|
||||
<input
|
||||
id="country"
|
||||
className="rounded border border-slate-300 px-3 py-2"
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} noValidate>
|
||||
<Stack spacing={3}>
|
||||
<TextField
|
||||
label={t('form.country')}
|
||||
placeholder={t('form.countryPlaceholder')}
|
||||
{...register('country')}
|
||||
error={Boolean(errors.country)}
|
||||
helperText={errors.country ? t(errors.country.message ?? '') : ''}
|
||||
fullWidth
|
||||
/>
|
||||
{errors.country ? (
|
||||
<p className="text-sm text-rose-500">{t(errors.country.message ?? '')}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium" htmlFor="city">
|
||||
{t('form.city')}
|
||||
</label>
|
||||
<input
|
||||
id="city"
|
||||
className="rounded border border-slate-300 px-3 py-2"
|
||||
<TextField
|
||||
label={t('form.city')}
|
||||
placeholder={t('form.cityPlaceholder')}
|
||||
{...register('city')}
|
||||
error={Boolean(errors.city)}
|
||||
helperText={errors.city ? t(errors.city.message ?? '') : ''}
|
||||
fullWidth
|
||||
/>
|
||||
{errors.city ? <p className="text-sm text-rose-500">{t(errors.city.message ?? '')}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium" htmlFor="start">
|
||||
{t('form.startDate')}
|
||||
</label>
|
||||
<input id="start" type="date" className="rounded border border-slate-300 px-3 py-2" {...register('start')} />
|
||||
{errors.start ? <p className="text-sm text-rose-500">{t(errors.start.message ?? '')}</p> : null}
|
||||
</div>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
label={t('form.startDate')}
|
||||
type="date"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
{...register('start')}
|
||||
error={Boolean(errors.start)}
|
||||
helperText={errors.start ? t(errors.start.message ?? '') : ''}
|
||||
fullWidth
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
label={t('form.endDate')}
|
||||
type="date"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
{...register('end')}
|
||||
error={Boolean(errors.end)}
|
||||
helperText={errors.end ? t(errors.end.message ?? '') : ''}
|
||||
fullWidth
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium" htmlFor="end">
|
||||
{t('form.endDate')}
|
||||
</label>
|
||||
<input id="end" type="date" className="rounded border border-slate-300 px-3 py-2" {...register('end')} />
|
||||
{errors.end ? <p className="text-sm text-rose-500">{t(errors.end.message ?? '')}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium" htmlFor="notes">
|
||||
{t('form.notes')}
|
||||
</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
className="min-h-[96px] rounded border border-slate-300 px-3 py-2"
|
||||
<TextField
|
||||
label={t('form.notes')}
|
||||
placeholder={t('form.notesPlaceholder')}
|
||||
{...register('notes')}
|
||||
error={Boolean(errors.notes)}
|
||||
helperText={errors.notes ? t(errors.notes.message ?? '') : ''}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={3}
|
||||
/>
|
||||
{errors.notes ? <p className="text-sm text-rose-500">{t(errors.notes.message ?? '')}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="lat">
|
||||
{t('form.latitude')}
|
||||
</label>
|
||||
<input
|
||||
id="lat"
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
label={t('form.latitude')}
|
||||
type="number"
|
||||
step="any"
|
||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
||||
inputProps={{ step: 'any' }}
|
||||
{...register('lat', { valueAsNumber: true })}
|
||||
error={Boolean(errors.lat)}
|
||||
helperText={errors.lat ? t(errors.lat.message ?? '') : ''}
|
||||
fullWidth
|
||||
/>
|
||||
{errors.lat ? <p className="text-sm text-rose-500">{t(errors.lat.message ?? '')}</p> : null}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="lng">
|
||||
{t('form.longitude')}
|
||||
</label>
|
||||
<input
|
||||
id="lng"
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
label={t('form.longitude')}
|
||||
type="number"
|
||||
step="any"
|
||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
||||
inputProps={{ step: 'any' }}
|
||||
{...register('lng', { valueAsNumber: true })}
|
||||
error={Boolean(errors.lng)}
|
||||
helperText={errors.lng ? t(errors.lng.message ?? '') : ''}
|
||||
fullWidth
|
||||
/>
|
||||
{errors.lng ? <p className="text-sm text-rose-500">{t(errors.lng.message ?? '')}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{isGeocoding ? <p className="text-xs text-slate-500">{t('geocoding.detecting')}</p> : null}
|
||||
{isGeocoding ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('geocoding.detecting')}
|
||||
</Typography>
|
||||
) : null}
|
||||
{!isGeocoding && geocodeFailed ? (
|
||||
<p className="text-xs text-rose-500">{t('geocoding.failed')}</p>
|
||||
<Alert severity="warning" variant="outlined">
|
||||
{t('geocoding.failed')}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-slate-300 px-4 py-2"
|
||||
onClick={() => onCancel?.()}
|
||||
>
|
||||
<Stack direction="row" justifyContent="flex-end" spacing={2}>
|
||||
<Button variant="outlined" onClick={() => onCancel?.()}>
|
||||
{t('form.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="rounded bg-primary px-4 py-2 font-semibold text-white hover:bg-primary/90 disabled:opacity-60"
|
||||
>
|
||||
</Button>
|
||||
<Button type="submit" variant="contained" disabled={isSubmitting}>
|
||||
{editingVisit ? t('form.update') : t('form.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
@@ -1,4 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Card, CardActions, CardContent, CircularProgress, Stack, Typography } from '@mui/material';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useDeleteVisitMutation, useVisitsQuery } from '../hooks/useVisitQueries';
|
||||
import type { Visit } from '../types';
|
||||
|
||||
@@ -12,48 +15,58 @@ export function VisitList({ onEdit }: VisitListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-slate-500">{t('list.loading')}</p>;
|
||||
return (
|
||||
<Stack direction="row" spacing={1} alignItems="center" justifyContent="center" sx={{ py: 4 }}>
|
||||
<CircularProgress size={20} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('list.loading')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!visits.length) {
|
||||
return <p className="text-sm text-slate-500">{t('list.empty')}</p>;
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ p: 3 }}>
|
||||
{t('list.empty')}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
<Stack spacing={2} sx={{ p: 2 }}>
|
||||
{visits.map((visit) => (
|
||||
<li key={visit.id} className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="flex justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Card key={visit.id} variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h6" fontWeight={600} gutterBottom>
|
||||
{visit.location.country}
|
||||
{visit.location.city ? ` · ${visit.location.city}` : null}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
{visit.location.city ? ` · ${visit.location.city}` : ''}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{visit.date.start}
|
||||
{visit.date.end ? ` — ${visit.date.end}` : null}
|
||||
</p>
|
||||
{visit.notes ? <p className="text-sm">{visit.notes}</p> : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-slate-100 px-2 py-1 hover:bg-slate-200"
|
||||
onClick={() => onEdit(visit)}
|
||||
>
|
||||
{visit.date.end ? ` — ${visit.date.end}` : ''}
|
||||
</Typography>
|
||||
{visit.notes ? (
|
||||
<Typography variant="body2" color="text.primary" sx={{ mt: 1 }}>
|
||||
{visit.notes}
|
||||
</Typography>
|
||||
) : null}
|
||||
</CardContent>
|
||||
<CardActions sx={{ justifyContent: 'flex-end', gap: 1, px: 2, pb: 2 }}>
|
||||
<Button startIcon={<EditIcon />} size="small" onClick={() => onEdit(visit)}>
|
||||
{t('list.edit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-rose-100 px-2 py-1 text-rose-600 hover:bg-rose-200"
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<DeleteIcon />}
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => deleteMutation.mutate(visit.id)}
|
||||
>
|
||||
{t('list.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
))}
|
||||
</ul>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent, Grid, Paper, Typography } from '@mui/material';
|
||||
import { useVisitsQuery } from '../hooks/useVisitQueries';
|
||||
|
||||
export function VisitSummary() {
|
||||
@@ -15,19 +16,32 @@ export function VisitSummary() {
|
||||
};
|
||||
}, [visits]);
|
||||
|
||||
const summaryItems = [
|
||||
{ label: t('summary.totalVisits'), value: stats.totalVisits },
|
||||
{ label: t('summary.countries'), value: stats.uniqueCountries }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-white p-4 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-slate-500">{t('summary.title')}</h3>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-3 text-center">
|
||||
<div className="rounded bg-slate-100 p-3">
|
||||
<dt className="text-xs uppercase tracking-wide text-slate-500">{t('summary.totalVisits')}</dt>
|
||||
<dd className="text-xl font-bold">{stats.totalVisits}</dd>
|
||||
</div>
|
||||
<div className="rounded bg-slate-100 p-3">
|
||||
<dt className="text-xs uppercase tracking-wide text-slate-500">{t('summary.countries')}</dt>
|
||||
<dd className="text-xl font-bold">{stats.uniqueCountries}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||
{t('summary.title')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{summaryItems.map((item) => (
|
||||
<Grid item xs={6} key={item.label}>
|
||||
<Paper elevation={0} sx={{ p: 2, bgcolor: 'grey.100', textAlign: 'center' }}>
|
||||
<Typography variant="overline" display="block" color="text.secondary">
|
||||
{item.label}
|
||||
</Typography>
|
||||
<Typography variant="h5" fontWeight={700} color="text.primary">
|
||||
{item.value}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
18
client/src/styles/theme.ts
Normal file
18
client/src/styles/theme.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
export const appTheme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#0b7285'
|
||||
},
|
||||
secondary: {
|
||||
main: '#51cf66'
|
||||
},
|
||||
background: {
|
||||
default: '#f1f5f9'
|
||||
}
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 12
|
||||
}
|
||||
});
|
Reference in New Issue
Block a user