feat: use material ui

This commit is contained in:
2025-09-30 17:21:35 +08:00
parent 431a247963
commit c1fc26df03
13 changed files with 1054 additions and 281 deletions

View File

@@ -19,17 +19,17 @@ traveling-around-the-world/
### 技術與工具 ### 技術與工具
- Vite + React 18 + TypeScript - Vite + React 18 + TypeScript
- Material UI + Emotion 建構一致的 UI 與主題
- React Router 規劃 SPA 路由 - React Router 規劃 SPA 路由
- Zustand 追蹤 UI 狀態(訪問表單 Modal 與初始座標) - Zustand 追蹤 UI 狀態(訪問表單 Modal 與初始座標)
- React Query 統一處理資料讀寫與快取 - React Query 統一處理資料讀寫與快取
- React Hook Form + Zod 表單驗證 - React Hook Form + Zod 表單驗證
- react-leaflet + OpenStreetMap 圖資,支援地圖點擊新增足跡 - react-leaflet + OpenStreetMap 圖資,支援地圖點擊新增足跡
- Tailwind CSS (JIT) 打造快速 UI
### 模組切分 ### 模組切分
- `features/map`Leaflet 地圖、點擊事件、旅遊標記 - `features/map`Leaflet 地圖、點擊事件、旅遊標記
- `features/visits`資料型別、LocalStorage Repository、React Query hooks、統計卡片與表單元件 - `features/visits`資料型別、LocalStorage Repository、React Query hooks、統計卡片與表單元件
- `components/layout`頁面主框架、側邊欄 - `components/layout`MUI AppBar + Drawer 布局、語言切換等元件
- `state`Zustand store 控制訪問建立/編輯的 Modal - `state`Zustand store 控制訪問建立/編輯的 Modal
- `components/overlay/VisitModal`:以 Modal 呈現足跡表單,地圖右下角的 `+` 漂浮按鈕或點擊地圖即可開啟 - `components/overlay/VisitModal`:以 Modal 呈現足跡表單,地圖右下角的 `+` 漂浮按鈕或點擊地圖即可開啟

712
client/package-lock.json generated
View File

@@ -8,7 +8,11 @@
"name": "traveling-around-the-world-client", "name": "traveling-around-the-world-client",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@hookform/resolvers": "^3.3.4", "@hookform/resolvers": "^3.3.4",
"@mui/icons-material": "^5.15.14",
"@mui/material": "^5.15.14",
"@tanstack/react-query": "^5.29.0", "@tanstack/react-query": "^5.29.0",
"i18next": "^23.11.5", "i18next": "^23.11.5",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
@@ -54,6 +58,84 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/@babel/runtime": {
"version": "7.28.4", "version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
@@ -62,6 +144,181 @@
"node": ">=6.9.0" "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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -624,7 +881,6 @@
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24" "@jridgewell/trace-mapping": "^0.3.24"
@@ -634,7 +890,6 @@
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
} }
@@ -642,19 +897,244 @@
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
"dev": true
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31", "version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"dependencies": { "dependencies": {
"@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -700,6 +1180,15 @@
"node": ">=14" "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": { "node_modules/@react-leaflet/core": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
@@ -1283,17 +1772,20 @@
"undici-types": "~6.21.0" "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": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="
"devOptional": true
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.25", "version": "18.3.25",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.25.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.25.tgz",
"integrity": "sha512-oSVZmGtDPmRZtVDqvdKUi/qgCsWp5IDY29wp8na8Bj4B3cc99hfNzvNhlMkVVxctkAOGUA3Km7MMpBHAnWfcIA==", "integrity": "sha512-oSVZmGtDPmRZtVDqvdKUi/qgCsWp5IDY29wp8na8Bj4B3cc99hfNzvNhlMkVVxctkAOGUA3Km7MMpBHAnWfcIA==",
"devOptional": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.0.2" "csstype": "^3.0.2"
@@ -1308,6 +1800,14 @@
"@types/react": "^18.0.0" "@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": { "node_modules/@types/semver": {
"version": "7.7.1", "version": "7.7.1",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
@@ -1825,6 +2325,39 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1957,7 +2490,6 @@
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"dev": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -2043,6 +2575,14 @@
"node": ">= 6" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2076,6 +2616,34 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2105,8 +2673,7 @@
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
"devOptional": true
}, },
"node_modules/data-view-buffer": { "node_modules/data-view-buffer": {
"version": "1.0.2", "version": "1.0.2",
@@ -2163,7 +2730,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
}, },
@@ -2252,6 +2818,15 @@
"node": ">=6.0.0" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2284,6 +2859,14 @@
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true "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": { "node_modules/es-abstract": {
"version": "1.24.0", "version": "1.24.0",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
@@ -2504,7 +3087,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -2855,6 +3437,11 @@
"node": ">=8" "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": { "node_modules/find-up": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -2959,7 +3546,6 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
@@ -3262,7 +3848,6 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
}, },
@@ -3270,6 +3855,14 @@
"node": ">= 0.4" "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": { "node_modules/html-parse-stringify": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
@@ -3313,7 +3906,6 @@
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dev": true,
"dependencies": { "dependencies": {
"parent-module": "^1.0.0", "parent-module": "^1.0.0",
"resolve-from": "^4.0.0" "resolve-from": "^4.0.0"
@@ -3382,6 +3974,11 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/is-async-function": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
@@ -3460,7 +4057,6 @@
"version": "2.16.1", "version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"dev": true,
"dependencies": { "dependencies": {
"hasown": "^2.0.2" "hasown": "^2.0.2"
}, },
@@ -3831,12 +4427,28 @@
"js-yaml": "bin/js-yaml.js" "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": { "node_modules/json-buffer": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"dev": true "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": { "node_modules/json-schema-traverse": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -3906,8 +4518,7 @@
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
"dev": true
}, },
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
@@ -4005,8 +4616,7 @@
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
"dev": true
}, },
"node_modules/mz": { "node_modules/mz": {
"version": "2.7.0", "version": "2.7.0",
@@ -4070,7 +4680,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -4259,7 +4868,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"dev": true,
"dependencies": { "dependencies": {
"callsites": "^3.0.0" "callsites": "^3.0.0"
}, },
@@ -4267,6 +4875,23 @@
"node": ">=6" "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": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -4297,8 +4922,7 @@
"node_modules/path-parse": { "node_modules/path-parse": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
"dev": true
}, },
"node_modules/path-scurry": { "node_modules/path-scurry": {
"version": "1.11.1", "version": "1.11.1",
@@ -4320,7 +4944,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -4328,8 +4951,7 @@
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
"dev": true
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.1",
@@ -4585,7 +5207,6 @@
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
"object-assign": "^4.1.1", "object-assign": "^4.1.1",
@@ -4683,8 +5304,7 @@
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
"dev": true
}, },
"node_modules/react-leaflet": { "node_modules/react-leaflet": {
"version": "4.2.1", "version": "4.2.1",
@@ -4729,6 +5349,21 @@
"react-dom": ">=16.8" "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": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -4813,7 +5448,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true,
"engines": { "engines": {
"node": ">=4" "node": ">=4"
} }
@@ -5140,6 +5774,14 @@
"node": ">=8" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "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" "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": { "node_modules/sucrase": {
"version": "3.35.0", "version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
@@ -5430,7 +6077,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
}, },

View File

@@ -22,7 +22,11 @@
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"@hookform/resolvers": "^3.3.4", "@hookform/resolvers": "^3.3.4",
"i18next": "^23.11.5", "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": { "devDependencies": {
"@types/leaflet": "^1.9.12", "@types/leaflet": "^1.9.12",

View File

@@ -1,11 +1,13 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom'; import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { VisitProvider } from '../features/visits/context/VisitProvider'; import { VisitProvider } from '../features/visits/context/VisitProvider';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { ThemeProvider, CssBaseline } from '@mui/material';
import { PrimaryLayout } from '../components/layout/PrimaryLayout'; import { PrimaryLayout } from '../components/layout/PrimaryLayout';
import { VisitSidebar } from '../components/layout/VisitSidebar'; import { VisitSidebar } from '../components/layout/VisitSidebar';
import { TravelMap } from '../features/map/TravelMap'; import { TravelMap } from '../features/map/TravelMap';
import { VisitModal } from '../components/overlay/VisitModal'; import { VisitModal } from '../components/overlay/VisitModal';
import { useVisitModalStore } from '../state/useVisitModalStore'; import { useVisitModalStore } from '../state/useVisitModalStore';
import { appTheme } from '../styles/theme';
function HomePage() { function HomePage() {
const openForCreate = useVisitModalStore((state) => state.openForCreate); const openForCreate = useVisitModalStore((state) => state.openForCreate);
@@ -32,13 +34,16 @@ function HomePage() {
export function App() { export function App() {
return ( return (
<VisitProvider mode="local"> <ThemeProvider theme={appTheme}>
<BrowserRouter> <CssBaseline />
<Routes> <VisitProvider mode="local">
<Route path="/" element={<HomePage />} /> <BrowserRouter>
</Routes> <Routes>
</BrowserRouter> <Route path="/" element={<HomePage />} />
<VisitModal /> </Routes>
</VisitProvider> </BrowserRouter>
<VisitModal />
</VisitProvider>
</ThemeProvider>
); );
} }

View File

@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FormControl, InputLabel, MenuItem, Select } from '@mui/material';
import { SUPPORTED_LANGUAGES, type SupportedLanguage } from '../../i18n'; import { SUPPORTED_LANGUAGES, type SupportedLanguage } from '../../i18n';
const LABEL_LOOKUP: Record<SupportedLanguage, string> = { const LABEL_LOOKUP: Record<SupportedLanguage, string> = {
@@ -13,22 +14,24 @@ export function LanguageSwitcher() {
SUPPORTED_LANGUAGES.find((language) => i18n.language.startsWith(language)) ?? 'zh-Hant'; SUPPORTED_LANGUAGES.find((language) => i18n.language.startsWith(language)) ?? 'zh-Hant';
return ( return (
<label className="flex items-center gap-2 text-sm text-slate-500"> <FormControl size="small">
<span>{t('language.label')}</span> <InputLabel id="language-switcher-label">{t('language.label')}</InputLabel>
<select <Select
className="rounded border border-slate-300 bg-white px-2 py-1 text-sm" labelId="language-switcher-label"
label={t('language.label')}
value={currentLanguage} value={currentLanguage}
onChange={(event) => { onChange={(event) => {
const selected = event.target.value as SupportedLanguage; const selected = event.target.value as SupportedLanguage;
void i18n.changeLanguage(selected); void i18n.changeLanguage(selected);
}} }}
sx={{ minWidth: 140 }}
> >
{SUPPORTED_LANGUAGES.map((language) => ( {SUPPORTED_LANGUAGES.map((language) => (
<option key={language} value={language}> <MenuItem key={language} value={language}>
{t(LABEL_LOOKUP[language])} {t(LABEL_LOOKUP[language])}
</option> </MenuItem>
))} ))}
</select> </Select>
</label> </FormControl>
); );
} }

View File

@@ -1,5 +1,20 @@
import { useEffect, useState, type ReactNode } from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; 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'; import { LanguageSwitcher } from './LanguageSwitcher';
interface PrimaryLayoutProps { interface PrimaryLayoutProps {
@@ -8,61 +23,99 @@ interface PrimaryLayoutProps {
onSidebarToggle?: (isOpen: boolean) => void; onSidebarToggle?: (isOpen: boolean) => void;
} }
const SIDEBAR_WIDTH = 320;
export function PrimaryLayout({ sidebar, content, onSidebarToggle }: PrimaryLayoutProps) { export function PrimaryLayout({ sidebar, content, onSidebarToggle }: PrimaryLayoutProps) {
const { t } = useTranslation(); 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(() => { useEffect(() => {
onSidebarToggle?.(isSidebarOpen); onSidebarToggle?.(isSidebarOpen);
}, [isSidebarOpen, onSidebarToggle]); }, [isSidebarOpen, onSidebarToggle]);
const toggleSidebar = () => setIsSidebarOpen((prev) => !prev);
const toolbarOffset = { xs: '56px', sm: '64px' } as const;
return ( return (
<div className="flex h-screen flex-col"> <Box sx={{ display: 'flex', minHeight: '100vh', flexDirection: 'column', bgcolor: 'background.default' }}>
<header className="border-b border-slate-200 bg-white"> <AppBar position="fixed" color="transparent" elevation={0} sx={{ borderBottom: 1, borderColor: 'divider' }}>
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4"> <Toolbar sx={{ maxWidth: 1200, mx: 'auto', width: '100%' }}>
<div className="flex items-center gap-3"> <Stack direction="row" alignItems="center" spacing={2} sx={{ flexGrow: 1 }}>
<button <IconButton
type="button" size="large"
edge="start"
color="primary"
aria-label={t(isSidebarOpen ? 'sidebar.toggleClose' : 'sidebar.toggleOpen')} aria-label={t(isSidebarOpen ? 'sidebar.toggleClose' : 'sidebar.toggleOpen')}
aria-controls="visit-sidebar" onClick={toggleSidebar}
aria-expanded={isSidebarOpen} sx={{ mr: 1 }}
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)}
> >
<span className="block text-xl leading-none">{isSidebarOpen ? '⟨' : '☰'}</span> {isSidebarOpen ? <ChevronLeftIcon /> : <MenuIcon />}
</button> </IconButton>
<div> <Box>
<h1 className="text-2xl font-bold">{t('common.appName')}</h1> <Typography variant="h5" fontWeight={700} color="text.primary">
<p className="text-sm text-slate-500">{t('common.tagline')}</p> {t('common.appName')}
</div> </Typography>
</div> <Typography variant="body2" color="text.secondary">
<div className="flex items-center gap-4"> {t('common.tagline')}
<nav className="flex items-center gap-3 text-sm text-slate-500"> </Typography>
<a href="https://www.openstreetmap.org/" target="_blank" rel="noreferrer" className="hover:text-primary"> </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')} {t('nav.openStreetMap')}
</a> </Link>
<a href="https://react-leaflet.js.org/" target="_blank" rel="noreferrer" className="hover:text-primary"> <Link href="https://react-leaflet.js.org/" target="_blank" rel="noreferrer" underline="hover" color="text.secondary">
{t('nav.reactLeaflet')} {t('nav.reactLeaflet')}
</a> </Link>
</nav> </Stack>
<LanguageSwitcher /> <LanguageSwitcher />
</div> </Stack>
</div> </Toolbar>
</header> </AppBar>
<main className="flex flex-1 overflow-hidden">
<aside <Box sx={{ display: 'flex', flexGrow: 1, minHeight: 0, pt: toolbarOffset }}>
id="visit-sidebar" <Drawer
aria-hidden={!isSidebarOpen} variant={isDesktop ? 'persistent' : 'temporary'}
className={`flex-shrink-0 min-w-0 overflow-hidden transition-[flex-basis,max-width] duration-300 ease-in-out ${ anchor="left"
isSidebarOpen open={isSidebarOpen}
? 'basis-[25%] max-w-[25%] border-r border-slate-200' onClose={toggleSidebar}
: 'pointer-events-none basis-0 max-w-0' 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} <Box sx={{ p: 2, height: '100%', overflowY: 'auto' }}>
</aside> {sidebar}
<section className="flex-1">{content}</section> </Box>
</main> </Drawer>
</div>
{isDesktop && (
<Divider orientation="vertical" flexItem sx={{ display: isSidebarOpen ? 'block' : 'none' }} />
)}
<Box component="main" sx={{ flexGrow: 1, minHeight: 0 }}>{content}</Box>
</Box>
</Box>
); );
} }

View File

@@ -1,4 +1,6 @@
import { useTranslation } from 'react-i18next'; 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 { VisitList } from '../../features/visits/components/VisitList';
import { VisitSummary } from '../../features/visits/components/VisitSummary'; import { VisitSummary } from '../../features/visits/components/VisitSummary';
import { useVisitModalStore } from '../../state/useVisitModalStore'; import { useVisitModalStore } from '../../state/useVisitModalStore';
@@ -9,26 +11,33 @@ export function VisitSidebar() {
const openForEdit = useVisitModalStore((state) => state.openForEdit); const openForEdit = useVisitModalStore((state) => state.openForEdit);
return ( return (
<aside className="flex h-full w-full flex-col gap-4 overflow-y-auto bg-slate-50 p-4"> <Stack spacing={3} sx={{ height: '100%' }}>
<header className="flex items-center justify-between"> <Stack direction="row" justifyContent="space-between" alignItems="center">
<div> <div>
<h2 className="text-xl font-semibold">{t('sidebar.title')}</h2> <Typography variant="h6" fontWeight={600} color="text.primary">
<p className="text-sm text-slate-500">{t('sidebar.subtitle')}</p> {t('sidebar.title')}
</Typography>
<Typography variant="body2" color="text.secondary">
{t('sidebar.subtitle')}
</Typography>
</div> </div>
<button <Button
type="button" variant="contained"
className="rounded bg-primary px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary/90" startIcon={<AddIcon />}
onClick={() => openForCreate()} onClick={() => openForCreate()}
sx={{ whiteSpace: 'nowrap' }}
> >
{t('sidebar.add')} {t('sidebar.add')}
</button> </Button>
</header> </Stack>
<VisitSummary /> <VisitSummary />
<section className="flex-1"> <Card sx={{ flex: 1, overflow: 'hidden', display: 'flex' }}>
<VisitList onEdit={openForEdit} /> <CardContent sx={{ flex: 1, overflowY: 'auto', p: 0 }}>
</section> <VisitList onEdit={openForEdit} />
</aside> </CardContent>
</Card>
</Stack>
); );
} }

View File

@@ -1,4 +1,12 @@
import { useTranslation } from 'react-i18next'; 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 { useVisitModalStore } from '../../state/useVisitModalStore';
import { VisitForm } from '../../features/visits/components/VisitForm'; import { VisitForm } from '../../features/visits/components/VisitForm';
@@ -6,29 +14,24 @@ export function VisitModal() {
const { isOpen, close, editingVisit, initialLocation } = useVisitModalStore(); const { isOpen, close, editingVisit, initialLocation } = useVisitModalStore();
const { t } = useTranslation(); const { t } = useTranslation();
if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 px-4 py-10"> <Dialog open={isOpen} onClose={close} maxWidth="sm" fullWidth>
<div className="relative w-full max-w-lg rounded-2xl bg-white p-6 shadow-2xl"> <DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<header className="mb-4 flex items-start justify-between"> <div>
<div> <Typography variant="h6" component="span">
<h2 className="text-xl font-semibold"> {editingVisit ? t('sidebar.drawerTitleEdit') : t('sidebar.drawerTitleCreate')}
{editingVisit ? t('sidebar.drawerTitleEdit') : t('sidebar.drawerTitleCreate')} </Typography>
</h2> <Typography variant="body2" color="text.secondary" display="block">
<p className="text-sm text-slate-500">{t('sidebar.subtitle')}</p> {t('sidebar.subtitle')}
</div> </Typography>
<button </div>
type="button" <IconButton aria-label={t('sidebar.close')} onClick={close} edge="end">
aria-label={t('sidebar.close')} <CloseIcon />
className="rounded bg-slate-100 px-2 py-1 text-sm text-slate-500 hover:bg-slate-200" </IconButton>
onClick={close} </DialogTitle>
> <DialogContent dividers>
X
</button>
</header>
<VisitForm editingVisit={editingVisit} initialLocation={initialLocation} onCompleted={close} onCancel={close} /> <VisitForm editingVisit={editingVisit} initialLocation={initialLocation} onCompleted={close} onCancel={close} />
</div> </DialogContent>
</div> </Dialog>
); );
} }

View File

@@ -8,6 +8,8 @@ import { useVisitsQuery } from '../visits/hooks/useVisitQueries';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useResizeMap } from '../visits/hooks/useResizeMap'; import { useResizeMap } from '../visits/hooks/useResizeMap';
import type { Map as LeafletMap } from 'leaflet'; 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_POSITION: [number, number] = [20, 0];
const INITIAL_ZOOM = 2; const INITIAL_ZOOM = 2;
@@ -43,7 +45,7 @@ export function TravelMap({ onTriggerCreate, sidebarOpen = true }: TravelMapProp
useResizeMap(mapRef, [sidebarOpen]); useResizeMap(mapRef, [sidebarOpen]);
return ( return (
<div className="relative h-full w-full"> <Box sx={{ position: 'relative', height: '100%', width: '100%' }}>
<MapContainer <MapContainer
whenCreated={(mapInstance) => { whenCreated={(mapInstance) => {
mapRef.current = mapInstance; mapRef.current = mapInstance;
@@ -74,14 +76,15 @@ export function TravelMap({ onTriggerCreate, sidebarOpen = true }: TravelMapProp
</Marker> </Marker>
))} ))}
</MapContainer> </MapContainer>
<button <Tooltip title={t('sidebar.add')} placement="left">
type="button" <Fab
aria-label={t('sidebar.add')} color="primary"
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" sx={{ position: 'absolute', bottom: 24, right: 24, zIndex: 1000 }}
onClick={() => (onTriggerCreate ? onTriggerCreate() : openForCreate())} onClick={() => (onTriggerCreate ? onTriggerCreate() : openForCreate())}
> >
+ <AddIcon />
</button> </Fab>
</div> </Tooltip>
</Box>
); );
} }

View File

@@ -2,6 +2,15 @@ import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/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 type { Visit, VisitCreateInput } from '../types';
import { useCreateVisitMutation, useUpdateVisitMutation } from '../hooks/useVisitQueries'; import { useCreateVisitMutation, useUpdateVisitMutation } from '../hooks/useVisitQueries';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -171,114 +180,107 @@ export function VisitForm({ editingVisit, onCompleted, onCancel, initialLocation
} }
return ( return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <Box component="form" onSubmit={handleSubmit(onSubmit)} noValidate>
<div className="grid gap-2"> <Stack spacing={3}>
<label className="text-sm font-medium" htmlFor="country"> <TextField
{t('form.country')} label={t('form.country')}
</label>
<input
id="country"
className="rounded border border-slate-300 px-3 py-2"
placeholder={t('form.countryPlaceholder')} placeholder={t('form.countryPlaceholder')}
{...register('country')} {...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"> <TextField
<label className="text-sm font-medium" htmlFor="city"> label={t('form.city')}
{t('form.city')}
</label>
<input
id="city"
className="rounded border border-slate-300 px-3 py-2"
placeholder={t('form.cityPlaceholder')} placeholder={t('form.cityPlaceholder')}
{...register('city')} {...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"> <Grid container spacing={2}>
<label className="text-sm font-medium" htmlFor="start"> <Grid item xs={12} md={6}>
{t('form.startDate')} <TextField
</label> label={t('form.startDate')}
<input id="start" type="date" className="rounded border border-slate-300 px-3 py-2" {...register('start')} /> type="date"
{errors.start ? <p className="text-sm text-rose-500">{t(errors.start.message ?? '')}</p> : null} InputLabelProps={{ shrink: true }}
</div> {...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"> <TextField
<label className="text-sm font-medium" htmlFor="end"> label={t('form.notes')}
{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"
placeholder={t('form.notesPlaceholder')} placeholder={t('form.notesPlaceholder')}
{...register('notes')} {...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"> <Grid container spacing={2}>
<div> <Grid item xs={12} md={6}>
<label className="text-sm font-medium" htmlFor="lat"> <TextField
{t('form.latitude')} label={t('form.latitude')}
</label> type="number"
<input inputProps={{ step: 'any' }}
id="lat" {...register('lat', { valueAsNumber: true })}
type="number" error={Boolean(errors.lat)}
step="any" helperText={errors.lat ? t(errors.lat.message ?? '') : ''}
className="w-full rounded border border-slate-300 px-3 py-2" fullWidth
{...register('lat', { valueAsNumber: true })} />
/> </Grid>
{errors.lat ? <p className="text-sm text-rose-500">{t(errors.lat.message ?? '')}</p> : null} <Grid item xs={12} md={6}>
</div> <TextField
<div> label={t('form.longitude')}
<label className="text-sm font-medium" htmlFor="lng"> type="number"
{t('form.longitude')} inputProps={{ step: 'any' }}
</label> {...register('lng', { valueAsNumber: true })}
<input error={Boolean(errors.lng)}
id="lng" helperText={errors.lng ? t(errors.lng.message ?? '') : ''}
type="number" fullWidth
step="any" />
className="w-full rounded border border-slate-300 px-3 py-2" </Grid>
{...register('lng', { valueAsNumber: true })} </Grid>
/>
{errors.lng ? <p className="text-sm text-rose-500">{t(errors.lng.message ?? '')}</p> : null}
</div>
</div>
{isGeocoding ? <p className="text-xs text-slate-500">{t('geocoding.detecting')}</p> : null} {isGeocoding ? (
{!isGeocoding && geocodeFailed ? ( <Typography variant="caption" color="text.secondary">
<p className="text-xs text-rose-500">{t('geocoding.failed')}</p> {t('geocoding.detecting')}
) : null} </Typography>
) : null}
{!isGeocoding && geocodeFailed ? (
<Alert severity="warning" variant="outlined">
{t('geocoding.failed')}
</Alert>
) : null}
<div className="flex justify-end gap-2"> <Stack direction="row" justifyContent="flex-end" spacing={2}>
<button <Button variant="outlined" onClick={() => onCancel?.()}>
type="button" {t('form.cancel')}
className="rounded border border-slate-300 px-4 py-2" </Button>
onClick={() => onCancel?.()} <Button type="submit" variant="contained" disabled={isSubmitting}>
> {editingVisit ? t('form.update') : t('form.create')}
{t('form.cancel')} </Button>
</button> </Stack>
<button </Stack>
type="submit" </Box>
disabled={isSubmitting}
className="rounded bg-primary px-4 py-2 font-semibold text-white hover:bg-primary/90 disabled:opacity-60"
>
{editingVisit ? t('form.update') : t('form.create')}
</button>
</div>
</form>
); );
} }

View File

@@ -1,4 +1,7 @@
import { useTranslation } from 'react-i18next'; 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 { useDeleteVisitMutation, useVisitsQuery } from '../hooks/useVisitQueries';
import type { Visit } from '../types'; import type { Visit } from '../types';
@@ -12,48 +15,58 @@ export function VisitList({ onEdit }: VisitListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
if (isLoading) { 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) { 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 ( return (
<ul className="space-y-3"> <Stack spacing={2} sx={{ p: 2 }}>
{visits.map((visit) => ( {visits.map((visit) => (
<li key={visit.id} className="rounded-lg bg-white p-3 shadow-sm"> <Card key={visit.id} variant="outlined">
<div className="flex justify-between gap-3"> <CardContent>
<div className="space-y-1"> <Typography variant="h6" fontWeight={600} gutterBottom>
<h3 className="text-lg font-semibold"> {visit.location.country}
{visit.location.country} {visit.location.city ? ` · ${visit.location.city}` : ''}
{visit.location.city ? ` · ${visit.location.city}` : null} </Typography>
</h3> <Typography variant="body2" color="text.secondary">
<p className="text-sm text-slate-500"> {visit.date.start}
{visit.date.start} {visit.date.end ? `${visit.date.end}` : ''}
{visit.date.end ? `${visit.date.end}` : null} </Typography>
</p> {visit.notes ? (
{visit.notes ? <p className="text-sm">{visit.notes}</p> : null} <Typography variant="body2" color="text.primary" sx={{ mt: 1 }}>
</div> {visit.notes}
<div className="flex shrink-0 flex-col items-end gap-2 text-sm"> </Typography>
<button ) : null}
type="button" </CardContent>
className="rounded bg-slate-100 px-2 py-1 hover:bg-slate-200" <CardActions sx={{ justifyContent: 'flex-end', gap: 1, px: 2, pb: 2 }}>
onClick={() => onEdit(visit)} <Button startIcon={<EditIcon />} size="small" onClick={() => onEdit(visit)}>
> {t('list.edit')}
{t('list.edit')} </Button>
</button> <Button
<button startIcon={<DeleteIcon />}
type="button" size="small"
className="rounded bg-rose-100 px-2 py-1 text-rose-600 hover:bg-rose-200" color="error"
onClick={() => deleteMutation.mutate(visit.id)} onClick={() => deleteMutation.mutate(visit.id)}
> >
{t('list.delete')} {t('list.delete')}
</button> </Button>
</div> </CardActions>
</div> </Card>
</li>
))} ))}
</ul> </Stack>
); );
} }

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card, CardContent, Grid, Paper, Typography } from '@mui/material';
import { useVisitsQuery } from '../hooks/useVisitQueries'; import { useVisitsQuery } from '../hooks/useVisitQueries';
export function VisitSummary() { export function VisitSummary() {
@@ -15,19 +16,32 @@ export function VisitSummary() {
}; };
}, [visits]); }, [visits]);
const summaryItems = [
{ label: t('summary.totalVisits'), value: stats.totalVisits },
{ label: t('summary.countries'), value: stats.uniqueCountries }
];
return ( return (
<div className="rounded-lg bg-white p-4 shadow-sm"> <Card variant="outlined">
<h3 className="text-sm font-semibold text-slate-500">{t('summary.title')}</h3> <CardContent>
<dl className="mt-3 grid grid-cols-2 gap-3 text-center"> <Typography variant="subtitle2" color="text.secondary" gutterBottom>
<div className="rounded bg-slate-100 p-3"> {t('summary.title')}
<dt className="text-xs uppercase tracking-wide text-slate-500">{t('summary.totalVisits')}</dt> </Typography>
<dd className="text-xl font-bold">{stats.totalVisits}</dd> <Grid container spacing={2}>
</div> {summaryItems.map((item) => (
<div className="rounded bg-slate-100 p-3"> <Grid item xs={6} key={item.label}>
<dt className="text-xs uppercase tracking-wide text-slate-500">{t('summary.countries')}</dt> <Paper elevation={0} sx={{ p: 2, bgcolor: 'grey.100', textAlign: 'center' }}>
<dd className="text-xl font-bold">{stats.uniqueCountries}</dd> <Typography variant="overline" display="block" color="text.secondary">
</div> {item.label}
</dl> </Typography>
</div> <Typography variant="h5" fontWeight={700} color="text.primary">
{item.value}
</Typography>
</Paper>
</Grid>
))}
</Grid>
</CardContent>
</Card>
); );
} }

View 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
}
});