mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2026-05-04 10:43:03 +00:00
sync
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
# Stage 1: Build application
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Allow environment variables to be passed at build time
|
||||
ARG REACT_APP_API_URL
|
||||
ENV REACT_APP_API_URL=$REACT_APP_API_URL
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package manifests first for optimal caching
|
||||
@@ -9,7 +13,7 @@ COPY package.json package-lock.json* ./
|
||||
# Clean cache and install dependencies
|
||||
RUN npm cache clean --force && \
|
||||
export NODE_OPTIONS="--max-old-space-size=1024" && \
|
||||
npm install --include=dev
|
||||
npm install --omit=dev --legacy-peer-deps
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
567
frontend/package-lock.json
generated
567
frontend/package-lock.json
generated
@@ -10,9 +10,20 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"axios": "^1.7.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"gpx-parse": "^0.10.4",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "14.2.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.45.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-json-view": "^1.21.3",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-select": "^5.7.4",
|
||||
"react-toastify": "^10.0.4",
|
||||
"recharts": "2.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -762,6 +773,28 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
|
||||
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.11.14",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
|
||||
@@ -1608,6 +1641,24 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
||||
"integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -1658,36 +1709,6 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.2.tgz",
|
||||
@@ -2021,6 +2042,14 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"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/scheduler": {
|
||||
"version": "0.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz",
|
||||
@@ -2755,6 +2784,11 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/asap": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="
|
||||
},
|
||||
"node_modules/ast-types-flow": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
||||
@@ -2773,8 +2807,7 @@
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
@@ -2800,6 +2833,16 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
|
||||
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
@@ -2948,6 +2991,11 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/base16": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/base16/-/base16-1.0.0.tgz",
|
||||
"integrity": "sha512-pNdYkNPiJUnEhnfXV56+sQy8+AaPcG3POZAUnwr4EeqCUZFz4u2PePbo3e5Gj4ziYPCWGUZT9RHisvJKnwFuBQ=="
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
@@ -3050,7 +3098,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -3218,6 +3265,14 @@
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"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/co": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||
@@ -3256,7 +3311,6 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
@@ -3409,6 +3463,14 @@
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
|
||||
"integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3644,6 +3706,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
@@ -3770,21 +3841,10 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-newline": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
|
||||
@@ -3858,7 +3918,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -3990,7 +4049,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -3999,7 +4057,6 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -4055,7 +4112,6 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
@@ -4067,7 +4123,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
@@ -4725,6 +4780,33 @@
|
||||
"bser": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fbemitter": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fbemitter/-/fbemitter-3.0.0.tgz",
|
||||
"integrity": "sha512-KWKaceCwKQU0+HPoop6gn4eOHk50bBv/VxjJtGMfwmJt3D29JpN4H4eisCtIPA+a8GVBam+ldMMpMjJUvpDyHw==",
|
||||
"dependencies": {
|
||||
"fbjs": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fbjs": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz",
|
||||
"integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==",
|
||||
"dependencies": {
|
||||
"cross-fetch": "^3.1.5",
|
||||
"fbjs-css-vars": "^1.0.0",
|
||||
"loose-envify": "^1.0.0",
|
||||
"object-assign": "^4.1.0",
|
||||
"promise": "^7.1.1",
|
||||
"setimmediate": "^1.0.5",
|
||||
"ua-parser-js": "^1.0.35"
|
||||
}
|
||||
},
|
||||
"node_modules/fbjs-css-vars": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz",
|
||||
"integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ=="
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||
@@ -4790,6 +4872,37 @@
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/flux": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/flux/-/flux-4.0.4.tgz",
|
||||
"integrity": "sha512-NCj3XlayA2UsapRpM7va6wU1+9rE5FIL7qoMcmxWHRzbp0yujihMBm9BBHZ1MDIk5h5o2Bl6eGiCe8rYELAmYw==",
|
||||
"dependencies": {
|
||||
"fbemitter": "^3.0.0",
|
||||
"fbjs": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^15.0.2 || ^16.0.0 || ^17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -4825,7 +4938,6 @@
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
@@ -4916,7 +5028,6 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
@@ -4949,7 +5060,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
@@ -5112,7 +5222,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -5120,6 +5229,14 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gpx-parse": {
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/gpx-parse/-/gpx-parse-0.10.4.tgz",
|
||||
"integrity": "sha512-4PUA2Hzp4m5EjDEa4YB1CKWh5Cjm1cgJuDe+nL6B77DgtFuYR7VtvBbmvbwjx6M40iA96q2BaKyD8/lraez2xw==",
|
||||
"dependencies": {
|
||||
"xml2js": "^0.4.4"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -5183,7 +5300,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -5195,7 +5311,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
@@ -7033,6 +7148,11 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||
@@ -7080,6 +7200,16 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"node_modules/lodash.curry": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.curry/-/lodash.curry-4.1.1.tgz",
|
||||
"integrity": "sha512-/u14pXGviLaweY5JI0IUzgzF2J6Ne8INyzAZjImcryjgkZ+ebruBxy2/JaOOkTqScddcYtakjhSaeemV8lR0tA=="
|
||||
},
|
||||
"node_modules/lodash.flow": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.flow/-/lodash.flow-3.5.0.tgz",
|
||||
"integrity": "sha512-ff3BX/tSioo+XojX4MOsOMhJw0nZoUEF011LX8g8d3gvjVbxd89cCio4BCXronjxcTUIJUoqKEUA+n4CqvvRPw=="
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -7140,11 +7270,15 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
|
||||
},
|
||||
"node_modules/merge-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
@@ -7177,7 +7311,6 @@
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -7186,7 +7319,6 @@
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
@@ -7334,6 +7466,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-int64": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||
@@ -7868,6 +8038,14 @@
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/promise": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
|
||||
"integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
|
||||
"dependencies": {
|
||||
"asap": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prompts": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||
@@ -7891,6 +8069,11 @@
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||
@@ -7912,6 +8095,11 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-color": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-color/-/pure-color-1.3.0.tgz",
|
||||
"integrity": "sha512-QFADYnsVoBMw1srW7OVKEYjG+MbIa49s54w1MA1EDY6r2r/sTcKKYqRX1f4GYvnXP7eN/Pe9HFcX+hwzmrXRHA=="
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||
@@ -7965,6 +8153,17 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-base16-styling": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-base16-styling/-/react-base16-styling-0.6.0.tgz",
|
||||
"integrity": "sha512-yvh/7CArceR/jNATXOKDlvTnPKPmGZz7zsenQ3jUwLzHkNUR0CvY3yGYJbWJ/nnxsL8Sgmt5cO3/SILVuPO6TQ==",
|
||||
"dependencies": {
|
||||
"base16": "^1.0.0",
|
||||
"lodash.curry": "^4.0.1",
|
||||
"lodash.flow": "^3.3.0",
|
||||
"pure-color": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
||||
@@ -7977,11 +8176,62 @@
|
||||
"react": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.62.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz",
|
||||
"integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"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=="
|
||||
},
|
||||
"node_modules/react-json-view": {
|
||||
"version": "1.21.3",
|
||||
"resolved": "https://registry.npmjs.org/react-json-view/-/react-json-view-1.21.3.tgz",
|
||||
"integrity": "sha512-13p8IREj9/x/Ye4WI/JpjhoIwuzEgUAtgJZNBJckfzJt1qyh24BdTm6UQNGnyTq9dapQdrqvquZTo3dz1X6Cjw==",
|
||||
"dependencies": {
|
||||
"flux": "^4.0.1",
|
||||
"react-base16-styling": "^0.6.0",
|
||||
"react-lifecycles-compat": "^3.0.4",
|
||||
"react-textarea-autosize": "^8.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.0 || ^16.3.0 || ^15.5.4",
|
||||
"react-dom": "^17.0.0 || ^16.3.0 || ^15.5.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-lifecycles-compat": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||
@@ -7999,6 +8249,80 @@
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
|
||||
"integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
|
||||
"integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.0",
|
||||
"react-router": "6.30.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-select": {
|
||||
"version": "5.10.2",
|
||||
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz",
|
||||
"integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.0",
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.8.1",
|
||||
"@floating-ui/dom": "^1.0.1",
|
||||
"@types/react-transition-group": "^4.4.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"prop-types": "^15.6.0",
|
||||
"react-transition-group": "^4.3.0",
|
||||
"use-isomorphic-layout-effect": "^1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-select/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/react-select/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/react-smooth": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-2.0.5.tgz",
|
||||
@@ -8013,6 +8337,34 @@
|
||||
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-textarea-autosize": {
|
||||
"version": "8.5.9",
|
||||
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz",
|
||||
"integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"use-composed-ref": "^1.3.0",
|
||||
"use-latest": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-toastify": {
|
||||
"version": "10.0.6",
|
||||
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.6.tgz",
|
||||
"integrity": "sha512-yYjp+omCDf9lhZcrZHKbSq7YMuK0zcYkDFTzfRFgTXkTFHZ1ToxwAonzA4JI5CxA91JpjFLmwEsZEgfYfOqI1A==",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz",
|
||||
@@ -8333,6 +8685,11 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
|
||||
"integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
@@ -8411,6 +8768,11 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -9208,6 +9570,31 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ua-parser-js": {
|
||||
"version": "1.0.41",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
|
||||
"integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ua-parser-js"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/faisalman"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/faisalman"
|
||||
}
|
||||
],
|
||||
"bin": {
|
||||
"ua-parser-js": "script/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||
@@ -9324,6 +9711,48 @@
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-composed-ref": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz",
|
||||
"integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-isomorphic-layout-effect": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
||||
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-latest": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz",
|
||||
"integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==",
|
||||
"dependencies": {
|
||||
"use-isomorphic-layout-effect": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||
@@ -9687,6 +10116,26 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.4.23",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
|
||||
"dependencies": {
|
||||
"sax": ">=0.6.0",
|
||||
"xmlbuilder": "~11.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
|
||||
@@ -16,10 +16,17 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"axios": "^1.7.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"@tmcw/togeojson": "^7.1.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "14.2.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.45.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-json-tree": "^0.20.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-select": "^5.7.4",
|
||||
"react-toastify": "^10.0.4",
|
||||
"recharts": "2.8.0"
|
||||
},
|
||||
|
||||
@@ -23,6 +23,12 @@ const Navigation = () => {
|
||||
>
|
||||
Plans
|
||||
</Link>
|
||||
<Link
|
||||
to="/rules"
|
||||
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md"
|
||||
>
|
||||
Rules
|
||||
</Link>
|
||||
<Link
|
||||
to="/routes"
|
||||
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md"
|
||||
|
||||
135
frontend/src/components/plans/EditWorkoutModal.jsx
Normal file
135
frontend/src/components/plans/EditWorkoutModal.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const EditWorkoutModal = ({ workout, onClose, onSave }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
type: '',
|
||||
duration_minutes: 0,
|
||||
intensity: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (workout) {
|
||||
setFormData({
|
||||
type: workout.type || '',
|
||||
duration_minutes: workout.duration_minutes || 0,
|
||||
intensity: workout.intensity || '',
|
||||
description: workout.description || ''
|
||||
});
|
||||
}
|
||||
}, [workout]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
if (!workout) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-semibold mb-4">Edit Workout</h3>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Workout Type
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="type"
|
||||
value={formData.type}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Duration (minutes)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="duration_minutes"
|
||||
value={formData.duration_minutes}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Intensity
|
||||
</label>
|
||||
<select
|
||||
name="intensity"
|
||||
value={formData.intensity}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select intensity</option>
|
||||
<option value="zone_1">Zone 1 (Easy)</option>
|
||||
<option value="zone_2">Zone 2 (Moderate)</option>
|
||||
<option value="zone_3">Zone 3 (Tempo)</option>
|
||||
<option value="zone_4">Zone 4 (Threshold)</option>
|
||||
<option value="zone_5">Zone 5 (VO2 Max)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows="3"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 text-white bg-blue-600 rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
EditWorkoutModal.propTypes = {
|
||||
workout: PropTypes.object,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onSave: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
EditWorkoutModal.defaultProps = {
|
||||
workout: null
|
||||
};
|
||||
|
||||
export default EditWorkoutModal;
|
||||
97
frontend/src/components/plans/GoalSelector.jsx
Normal file
97
frontend/src/components/plans/GoalSelector.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const predefinedGoals = [
|
||||
{ id: 'endurance', label: 'Build Endurance', description: 'Focus on longer rides at moderate intensity' },
|
||||
{ id: 'power', label: 'Increase Power', description: 'High-intensity intervals and strength training' },
|
||||
{ id: 'weight-loss', label: 'Weight Management', description: 'Calorie-burning rides with nutrition planning' },
|
||||
{ id: 'event-prep', label: 'Event Preparation', description: 'Targeted training for specific competitions' }
|
||||
];
|
||||
|
||||
const GoalSelector = ({ goals, onSelect, onNext }) => {
|
||||
const [customGoal, setCustomGoal] = useState('');
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
|
||||
const toggleGoal = (goalId) => {
|
||||
const newGoals = goals.includes(goalId)
|
||||
? goals.filter(g => g !== goalId)
|
||||
: [...goals, goalId];
|
||||
onSelect(newGoals);
|
||||
};
|
||||
|
||||
const addCustomGoal = () => {
|
||||
if (customGoal.trim()) {
|
||||
onSelect([...goals, customGoal.trim()]);
|
||||
setCustomGoal('');
|
||||
setShowCustom(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 className="text-2xl font-bold mb-6">Select Training Goals</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
||||
{predefinedGoals.map((goal) => (
|
||||
<button
|
||||
key={goal.id}
|
||||
onClick={() => toggleGoal(goal.id)}
|
||||
className={`p-4 text-left rounded-lg border-2 transition-colors ${
|
||||
goals.includes(goal.id)
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-blue-200'
|
||||
}`}
|
||||
>
|
||||
<h3 className="font-semibold mb-2">{goal.label}</h3>
|
||||
<p className="text-sm text-gray-600">{goal.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
{!showCustom ? (
|
||||
<button
|
||||
onClick={() => setShowCustom(true)}
|
||||
className="text-blue-600 hover:text-blue-700 flex items-center"
|
||||
>
|
||||
<span className="mr-2">+</span> Add Custom Goal
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customGoal}
|
||||
onChange={(e) => setCustomGoal(e.target.value)}
|
||||
placeholder="Enter custom goal"
|
||||
className="flex-1 p-2 border rounded-md"
|
||||
/>
|
||||
<button
|
||||
onClick={addCustomGoal}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={goals.length === 0}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
GoalSelector.propTypes = {
|
||||
goals: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
onNext: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default GoalSelector;
|
||||
129
frontend/src/components/plans/PlanParameters.jsx
Normal file
129
frontend/src/components/plans/PlanParameters.jsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const daysOfWeek = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
|
||||
const PlanParameters = ({ values, onChange, onBack, onNext }) => {
|
||||
const [localValues, setLocalValues] = useState(values);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValues(values);
|
||||
}, [values]);
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
const newValues = { ...localValues, [field]: value };
|
||||
setLocalValues(newValues);
|
||||
onChange(newValues);
|
||||
};
|
||||
|
||||
const toggleDay = (day) => {
|
||||
const days = localValues.availableDays.includes(day)
|
||||
? localValues.availableDays.filter(d => d !== day)
|
||||
: [...localValues.availableDays, day];
|
||||
handleChange('availableDays', days);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 className="text-2xl font-bold mb-6">Set Plan Parameters</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Duration: {localValues.duration} weeks
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="4"
|
||||
max="20"
|
||||
value={localValues.duration}
|
||||
onChange={(e) => handleChange('duration', parseInt(e.target.value))}
|
||||
className="w-full range-slider"
|
||||
/>
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>4</span>
|
||||
<span>20</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Weekly Hours: {localValues.weeklyHours}h
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="15"
|
||||
value={localValues.weeklyHours}
|
||||
onChange={(e) => handleChange('weeklyHours', parseInt(e.target.value))}
|
||||
className="w-full range-slider"
|
||||
/>
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>5</span>
|
||||
<span>15</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Difficulty Level</label>
|
||||
<select
|
||||
value={localValues.difficulty || 'intermediate'}
|
||||
onChange={(e) => handleChange('difficulty', e.target.value)}
|
||||
className="w-full p-2 border rounded-md"
|
||||
>
|
||||
<option value="beginner">Beginner</option>
|
||||
<option value="intermediate">Intermediate</option>
|
||||
<option value="advanced">Advanced</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Available Days</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{daysOfWeek.map(day => (
|
||||
<label key={day} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localValues.availableDays.includes(day)}
|
||||
onChange={() => toggleDay(day)}
|
||||
className="form-checkbox h-4 w-4 text-blue-600"
|
||||
/>
|
||||
<span className="text-sm">{day}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="bg-gray-200 text-gray-700 px-6 py-2 rounded-md hover:bg-gray-300"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={localValues.availableDays.length === 0}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PlanParameters.propTypes = {
|
||||
values: PropTypes.shape({
|
||||
duration: PropTypes.number,
|
||||
weeklyHours: PropTypes.number,
|
||||
difficulty: PropTypes.string,
|
||||
availableDays: PropTypes.arrayOf(PropTypes.string)
|
||||
}).isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onBack: PropTypes.func.isRequired,
|
||||
onNext: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default PlanParameters;
|
||||
@@ -1,93 +1,104 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import axios from 'axios';
|
||||
import WorkoutCard from './WorkoutCard';
|
||||
import EditWorkoutModal from './EditWorkoutModal';
|
||||
|
||||
const PlanTimeline = ({ planId }) => {
|
||||
const PlanTimeline = ({ plan, mode = 'view' }) => {
|
||||
const { apiKey } = useAuth();
|
||||
const [evolution, setEvolution] = useState([]);
|
||||
const [selectedVersion, setSelectedVersion] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPlan, setCurrentPlan] = useState(plan?.jsonb_plan);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedWorkout, setSelectedWorkout] = useState(null);
|
||||
const [selectedWeek, setSelectedWeek] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEvolution = async () => {
|
||||
try {
|
||||
const response = await axios.get(`/api/plans/${planId}/evolution`, {
|
||||
const handleWorkoutUpdate = async (updatedWorkout) => {
|
||||
try {
|
||||
const newPlan = { ...currentPlan };
|
||||
newPlan.weeks[selectedWeek].workouts = newPlan.weeks[selectedWeek].workouts.map(w =>
|
||||
w.day === updatedWorkout.day ? updatedWorkout : w
|
||||
);
|
||||
|
||||
if (mode === 'edit') {
|
||||
await axios.put(`/api/plans/${plan.id}`, newPlan, {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
});
|
||||
setEvolution(response.data.evolution_history);
|
||||
setSelectedVersion(response.data.current_version);
|
||||
} catch (error) {
|
||||
console.error('Error fetching plan evolution:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (planId) {
|
||||
fetchEvolution();
|
||||
|
||||
setCurrentPlan(newPlan);
|
||||
setShowEditModal(false);
|
||||
} catch (error) {
|
||||
console.error('Error updating workout:', error);
|
||||
}
|
||||
}, [planId, apiKey]);
|
||||
|
||||
if (loading) return <div>Loading plan history...</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h3 className="text-xl font-semibold mb-4">Plan Evolution</h3>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="md:w-1/3 space-y-4">
|
||||
{evolution.map((version, idx) => (
|
||||
<div
|
||||
key={version.version}
|
||||
onClick={() => setSelectedVersion(version)}
|
||||
className={`p-4 border-l-4 cursor-pointer transition-colors ${
|
||||
selectedVersion?.version === version.version
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">v{version.version}</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{formatDistanceToNow(new Date(version.created_at))} ago
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-semibold">
|
||||
{currentPlan?.plan_overview?.focus} Training Plan
|
||||
</h3>
|
||||
<div className="text-gray-600">
|
||||
{currentPlan?.plan_overview?.duration_weeks} weeks •
|
||||
{currentPlan?.plan_overview?.weekly_hours} hrs/week
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{currentPlan?.weeks?.map((week, weekIndex) => (
|
||||
<div key={weekIndex} className="border-l-2 border-blue-100 pl-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-medium">
|
||||
Week {weekIndex + 1}: {week.focus}
|
||||
</h4>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-2 w-24 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-full bg-blue-600 rounded-full"
|
||||
style={{ width: `${(weekIndex + 1) / currentPlan.plan_overview.duration_weeks * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">
|
||||
{week.workouts.length} workouts
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{version.trigger || 'Initial version'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedVersion && (
|
||||
<div className="md:w-2/3 p-4 bg-gray-50 rounded-md">
|
||||
<h4 className="font-medium mb-4">
|
||||
Version {selectedVersion.version} Details
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
<span className="font-medium">Created:</span>{' '}
|
||||
{new Date(selectedVersion.created_at).toLocaleString()}
|
||||
</p>
|
||||
{selectedVersion.changes_summary && (
|
||||
<p>
|
||||
<span className="font-medium">Changes:</span>{' '}
|
||||
{selectedVersion.changes_summary}
|
||||
</p>
|
||||
)}
|
||||
{selectedVersion.parent_plan_id && (
|
||||
<p>
|
||||
<span className="font-medium">Parent Version:</span>{' '}
|
||||
v{selectedVersion.parent_plan_id}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{week.workouts.map((workout, workoutIndex) => (
|
||||
<WorkoutCard
|
||||
key={`${weekIndex}-${workoutIndex}`}
|
||||
workout={workout}
|
||||
onEdit={() => {
|
||||
setSelectedWeek(weekIndex);
|
||||
setSelectedWorkout(workout);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
editable={mode === 'edit'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showEditModal && (
|
||||
<EditWorkoutModal
|
||||
workout={selectedWorkout}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
onSave={handleWorkoutUpdate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PlanTimeline.propTypes = {
|
||||
plan: PropTypes.shape({
|
||||
id: PropTypes.number,
|
||||
jsonb_plan: PropTypes.object
|
||||
}),
|
||||
mode: PropTypes.oneOf(['view', 'edit'])
|
||||
};
|
||||
|
||||
export default PlanTimeline;
|
||||
45
frontend/src/components/plans/WorkoutCard.jsx
Normal file
45
frontend/src/components/plans/WorkoutCard.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const WorkoutCard = ({ workout, onEdit, editable }) => {
|
||||
return (
|
||||
<div className="border rounded-lg p-4 bg-white shadow-sm">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h5 className="font-medium capitalize">{workout?.type?.replace('_', ' ') || 'Workout'}</h5>
|
||||
{editable && (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 space-y-1">
|
||||
<div>Duration: {workout?.duration_minutes || 0} minutes</div>
|
||||
<div>Intensity: {workout?.intensity || 'N/A'}</div>
|
||||
{workout?.description && (
|
||||
<div className="mt-2 text-gray-700">{workout.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
WorkoutCard.propTypes = {
|
||||
workout: PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
duration_minutes: PropTypes.number,
|
||||
intensity: PropTypes.string,
|
||||
description: PropTypes.string
|
||||
}),
|
||||
onEdit: PropTypes.func,
|
||||
editable: PropTypes.bool
|
||||
};
|
||||
|
||||
WorkoutCard.defaultProps = {
|
||||
workout: {},
|
||||
onEdit: () => {},
|
||||
editable: false
|
||||
};
|
||||
|
||||
export default WorkoutCard;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import GoalSelector from '../GoalSelector';
|
||||
|
||||
describe('GoalSelector', () => {
|
||||
const mockOnSelect = jest.fn();
|
||||
const mockOnNext = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
render(<GoalSelector goals={[]} onSelect={mockOnSelect} onNext={mockOnNext} />);
|
||||
});
|
||||
|
||||
it('allows selection of predefined goals', () => {
|
||||
const enduranceButton = screen.getByText('Build Endurance');
|
||||
fireEvent.click(enduranceButton);
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(['endurance']);
|
||||
});
|
||||
|
||||
it('handles custom goal input', () => {
|
||||
const addButton = screen.getByText('Add Custom Goal');
|
||||
fireEvent.click(addButton);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter custom goal');
|
||||
fireEvent.change(input, { target: { value: 'Custom Goal' } });
|
||||
|
||||
const addCustomButton = screen.getByText('Add');
|
||||
fireEvent.click(addCustomButton);
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(['Custom Goal']);
|
||||
});
|
||||
|
||||
it('disables next button with no goals selected', () => {
|
||||
expect(screen.getByText('Next')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
231
frontend/src/components/routes/FileUpload.jsx
Normal file
231
frontend/src/components/routes/FileUpload.jsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import LoadingSpinner from '../LoadingSpinner'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { gpx } from '@tmcw/togeojson'
|
||||
|
||||
const RouteVisualization = dynamic(() => import('./RouteVisualization'), {
|
||||
ssr: false,
|
||||
loading: () => <div className="h-64 bg-gray-100 rounded-md flex items-center justify-center">Loading map...</div>
|
||||
})
|
||||
|
||||
const FileUpload = ({ onUploadSuccess }) => {
|
||||
const { apiKey } = useAuth()
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [previewData, setPreviewData] = useState(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const handleFile = async (file) => {
|
||||
if (file.type !== 'application/gpx+xml') {
|
||||
setError('Please upload a valid GPX file')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Preview parsing
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const parser = new DOMParser()
|
||||
const gpxDoc = parser.parseFromString(e.target.result, 'text/xml')
|
||||
const geoJson = gpx(gpxDoc)
|
||||
|
||||
// Extract basic info from GeoJSON
|
||||
const name = geoJson.features[0]?.properties?.name || 'Unnamed Route'
|
||||
|
||||
// Calculate distance and elevation (simplified)
|
||||
let totalDistance = 0
|
||||
let elevationGain = 0
|
||||
let elevationLoss = 0
|
||||
let maxElevation = 0
|
||||
|
||||
// Simple calculation - in a real app you'd want more accurate distance calculation
|
||||
const coordinates = geoJson.features[0]?.geometry?.coordinates || []
|
||||
for (let i = 1; i < coordinates.length; i++) {
|
||||
const [prevLon, prevLat, prevEle] = coordinates[i-1]
|
||||
const [currLon, currLat, currEle] = coordinates[i]
|
||||
// Simple distance calculation (you might want to use a more accurate method)
|
||||
const distance = Math.sqrt(Math.pow(currLon - prevLon, 2) + Math.pow(currLat - prevLat, 2)) * 111000 // rough meters
|
||||
totalDistance += distance
|
||||
|
||||
if (prevEle && currEle) {
|
||||
const eleDiff = currEle - prevEle
|
||||
if (eleDiff > 0) {
|
||||
elevationGain += eleDiff
|
||||
} else {
|
||||
elevationLoss += Math.abs(eleDiff)
|
||||
}
|
||||
maxElevation = Math.max(maxElevation, currEle)
|
||||
}
|
||||
}
|
||||
|
||||
const avgGrade = totalDistance > 0 ? ((elevationGain / totalDistance) * 100).toFixed(1) : '0.0'
|
||||
|
||||
setPreviewData({
|
||||
name,
|
||||
distance: (totalDistance / 1000).toFixed(1) + 'km',
|
||||
elevationGain: elevationGain.toFixed(0) + 'm',
|
||||
elevationLoss: elevationLoss.toFixed(0) + 'm',
|
||||
maxElevation: maxElevation.toFixed(0) + 'm',
|
||||
avgGrade: avgGrade + '%',
|
||||
category: 'mixed',
|
||||
gpxContent: e.target.result
|
||||
})
|
||||
} catch (parseError) {
|
||||
setError('Error parsing GPX file: ' + parseError.message)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
} catch (err) {
|
||||
setError('Error parsing GPX file')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!previewData) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
const blob = new Blob([previewData.gpxContent], { type: 'application/gpx+xml' })
|
||||
formData.append('file', blob, previewData.name + '.gpx')
|
||||
|
||||
const response = await fetch('/api/routes/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-Key': apiKey
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Upload failed')
|
||||
|
||||
const result = await response.json()
|
||||
onUploadSuccess(result)
|
||||
setPreviewData(null)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Upload failed')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onDragOver = useCallback((e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const onDragLeave = useCallback((e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const onDrop = useCallback((e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFile(file)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center ${
|
||||
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
|
||||
}`}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id="gpx-upload"
|
||||
className="hidden"
|
||||
accept=".gpx,application/gpx+xml"
|
||||
onChange={(e) => e.target.files[0] && handleFile(e.target.files[0])}
|
||||
/>
|
||||
<label htmlFor="gpx-upload" className="cursor-pointer">
|
||||
<p className="text-gray-600">
|
||||
Drag and drop GPX file here or{' '}
|
||||
<span className="text-blue-600 font-medium">browse files</span>
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{previewData && (
|
||||
<div className="mt-6 bg-white p-6 rounded-lg shadow-md">
|
||||
<h3 className="text-lg font-medium mb-4">Route Preview</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Route Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={previewData.name}
|
||||
onChange={(e) => setPreviewData(prev => ({...prev, name: e.target.value}))}
|
||||
className="w-full p-2 border rounded-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||
<select
|
||||
value={previewData.category}
|
||||
onChange={(e) => setPreviewData(prev => ({...prev, category: e.target.value}))}
|
||||
className="w-full p-2 border rounded-md"
|
||||
>
|
||||
<option value="climbing">Climbing</option>
|
||||
<option value="flat">Flat</option>
|
||||
<option value="mixed">Mixed</option>
|
||||
<option value="intervals">Intervals</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Distance</label>
|
||||
<p className="p-2 bg-gray-50 rounded-md">{previewData.distance}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Elevation Gain</label>
|
||||
<p className="p-2 bg-gray-50 rounded-md">{previewData.elevationGain}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Avg Grade</label>
|
||||
<p className="p-2 bg-gray-50 rounded-md">{previewData.avgGrade}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Max Elevation</label>
|
||||
<p className="p-2 bg-gray-50 rounded-md">{previewData.maxElevation}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Elevation Loss</label>
|
||||
<p className="p-2 bg-gray-50 rounded-md">{previewData.elevationLoss}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={isLoading}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading ? 'Uploading...' : 'Confirm Upload'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-64">
|
||||
<RouteVisualization gpxData={previewData.gpxContent} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 text-red-600 bg-red-50 p-3 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileUpload
|
||||
80
frontend/src/components/routes/RouteFilter.jsx
Normal file
80
frontend/src/components/routes/RouteFilter.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const RouteFilter = ({ filters, onFilterChange }) => {
|
||||
const handleChange = (field, value) => {
|
||||
onFilterChange({
|
||||
...filters,
|
||||
[field]: value
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filters.searchQuery}
|
||||
onChange={(e) => handleChange('searchQuery', e.target.value)}
|
||||
placeholder="Search routes..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Min Distance (km)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={filters.minDistance}
|
||||
onChange={(e) => handleChange('minDistance', Number(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Max Distance (km)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={filters.maxDistance}
|
||||
onChange={(e) => handleChange('maxDistance', Number(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Difficulty
|
||||
</label>
|
||||
<select
|
||||
value={filters.difficulty}
|
||||
onChange={(e) => handleChange('difficulty', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">All Difficulties</option>
|
||||
<option value="easy">Easy</option>
|
||||
<option value="moderate">Moderate</option>
|
||||
<option value="hard">Hard</option>
|
||||
<option value="extreme">Extreme</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RouteFilter.propTypes = {
|
||||
filters: PropTypes.shape({
|
||||
searchQuery: PropTypes.string,
|
||||
minDistance: PropTypes.number,
|
||||
maxDistance: PropTypes.number,
|
||||
difficulty: PropTypes.string
|
||||
}).isRequired,
|
||||
onFilterChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default RouteFilter;
|
||||
107
frontend/src/components/routes/RouteList.jsx
Normal file
107
frontend/src/components/routes/RouteList.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import LoadingSpinner from '../LoadingSpinner'
|
||||
import { format } from 'date-fns'
|
||||
import { FaStar } from 'react-icons/fa'
|
||||
|
||||
const RouteList = ({ routes, onRouteSelect }) => {
|
||||
const { apiKey } = useAuth()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState('all')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const categories = ['all', 'climbing', 'flat', 'mixed', 'intervals']
|
||||
|
||||
const filteredRoutes = routes.filter(route => {
|
||||
const matchesSearch = route.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const matchesCategory = selectedCategory === 'all' || route.category === selectedCategory
|
||||
return matchesSearch && matchesCategory
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<div className="mb-6 space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search routes..."
|
||||
className="w-full p-2 border rounded-md"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map(category => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium ${
|
||||
selectedCategory === category
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{category.charAt(0).toUpperCase() + category.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : error ? (
|
||||
<div className="text-red-600">{error}</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredRoutes.map(route => (
|
||||
<div
|
||||
key={route.id}
|
||||
className="p-4 border rounded-md hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => onRouteSelect(route)}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="font-medium text-lg">{route.name}</h3>
|
||||
<span className="text-sm text-gray-500">
|
||||
{format(new Date(route.created_at), 'MMM d, yyyy')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4 mt-2 text-gray-600">
|
||||
<div>
|
||||
<span className="font-medium">Distance: </span>
|
||||
{(route.distance / 1000).toFixed(1)}km
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Elevation: </span>
|
||||
{route.elevation_gain}m
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Category: </span>
|
||||
{route.category}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Grade: </span>
|
||||
{route.grade_avg}%
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">Difficulty: </span>
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<FaStar
|
||||
key={index}
|
||||
className={`w-4 h-4 ${
|
||||
index < Math.round(route.difficulty_rating / 2)
|
||||
? 'text-yellow-400'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RouteList
|
||||
61
frontend/src/components/routes/RouteMetadata.jsx
Normal file
61
frontend/src/components/routes/RouteMetadata.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { FaRoute, FaMountain, FaTachometerAlt, FaStar } from 'react-icons/fa'
|
||||
|
||||
const RouteMetadata = ({ route }) => {
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h3 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<FaRoute className="text-blue-600" /> Route Details
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
|
||||
<FaMountain className="text-gray-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Elevation Gain</p>
|
||||
<p className="font-medium">{route.elevation_gain}m</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
|
||||
<FaTachometerAlt className="text-gray-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Avg Grade</p>
|
||||
<p className="font-medium">{route.grade_avg}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
|
||||
<FaStar className="text-gray-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Difficulty</p>
|
||||
<div className="flex gap-1 text-yellow-400">
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<FaStar
|
||||
key={index}
|
||||
className={`w-4 h-4 ${
|
||||
index < Math.round(route.difficulty_rating / 2)
|
||||
? 'fill-current'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 md:col-span-3 grid grid-cols-2 gap-4 mt-4">
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<p className="text-sm text-gray-500">Distance</p>
|
||||
<p className="font-medium">{(route.distance / 1000).toFixed(1)}km</p>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<p className="text-sm text-gray-500">Category</p>
|
||||
<p className="font-medium capitalize">{route.category}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RouteMetadata
|
||||
93
frontend/src/components/routes/RouteVisualization.jsx
Normal file
93
frontend/src/components/routes/RouteVisualization.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
import { MapContainer, TileLayer, Polyline, Marker, Popup } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { gpx } from '@tmcw/togeojson'
|
||||
|
||||
// Fix leaflet marker icons
|
||||
delete L.Icon.Default.prototype._getIconUrl
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png'
|
||||
})
|
||||
|
||||
const RouteVisualization = ({ gpxData }) => {
|
||||
const mapRef = useRef()
|
||||
const elevationChartRef = useRef(null)
|
||||
const [routePoints, setRoutePoints] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!gpxData) return
|
||||
|
||||
try {
|
||||
const parser = new DOMParser()
|
||||
const gpxDoc = parser.parseFromString(gpxData, 'text/xml')
|
||||
const geoJson = gpx(gpxDoc)
|
||||
|
||||
if (!geoJson.features[0]) return
|
||||
|
||||
const coordinates = geoJson.features[0].geometry.coordinates
|
||||
const points = coordinates.map(coord => [coord[1], coord[0]]) // [lat, lon]
|
||||
const bounds = L.latLngBounds(points)
|
||||
|
||||
setRoutePoints(points)
|
||||
|
||||
if (mapRef.current) {
|
||||
mapRef.current.flyToBounds(bounds, { padding: [50, 50] })
|
||||
}
|
||||
|
||||
// Plot elevation profile
|
||||
if (elevationChartRef.current) {
|
||||
const elevations = coordinates.map(coord => coord[2] || 0)
|
||||
const distances = []
|
||||
let distance = 0
|
||||
|
||||
for (let i = 1; i < coordinates.length; i++) {
|
||||
const prevPoint = L.latLng(coordinates[i-1][1], coordinates[i-1][0])
|
||||
const currPoint = L.latLng(coordinates[i][1], coordinates[i][0])
|
||||
distance += prevPoint.distanceTo(currPoint)
|
||||
distances.push(distance)
|
||||
}
|
||||
|
||||
// TODO: Integrate charting library
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing GPX data:', error)
|
||||
}
|
||||
}, [gpxData])
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative">
|
||||
<MapContainer
|
||||
center={[51.505, -0.09]}
|
||||
zoom={13}
|
||||
scrollWheelZoom={false}
|
||||
className="h-full rounded-md"
|
||||
ref={mapRef}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Polyline
|
||||
positions={routePoints}
|
||||
color="#3b82f6"
|
||||
weight={4}
|
||||
/>
|
||||
<Marker position={[51.505, -0.09]}>
|
||||
<Popup>Start/End Point</Popup>
|
||||
</Marker>
|
||||
</MapContainer>
|
||||
|
||||
<div
|
||||
ref={elevationChartRef}
|
||||
className="absolute bottom-4 left-4 right-4 h-32 bg-white/90 backdrop-blur-sm rounded-md p-4 shadow-md"
|
||||
>
|
||||
{/* Elevation chart will be rendered here */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RouteVisualization
|
||||
116
frontend/src/components/routes/SectionList.jsx
Normal file
116
frontend/src/components/routes/SectionList.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const SectionList = ({ sections, onSplit, onUpdate }) => {
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [localSections, setLocalSections] = useState(sections);
|
||||
|
||||
const surfaceTypes = ['road', 'gravel', 'mixed', 'trail'];
|
||||
const gearOptions = {
|
||||
road: ['Standard (39x25)', 'Mid-compact (36x30)', 'Compact (34x28)'],
|
||||
gravel: ['1x System', '2x Gravel', 'Adventure'],
|
||||
trail: ['MTB Wide-range', 'Fat Bike']
|
||||
};
|
||||
|
||||
const handleEdit = (sectionId) => {
|
||||
setEditing(sectionId);
|
||||
setLocalSections(sections);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate(localSections);
|
||||
setEditing(null);
|
||||
};
|
||||
|
||||
const handleChange = (sectionId, field, value) => {
|
||||
setLocalSections(prev => prev.map(section =>
|
||||
section.id === sectionId ? { ...section, [field]: value } : section
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">Route Sections</h3>
|
||||
<div className="space-x-2">
|
||||
<button
|
||||
onClick={() => onSplit([Math.floor(sections.length/2)])}
|
||||
className="bg-green-600 text-white px-3 py-1 rounded-md hover:bg-green-700"
|
||||
>
|
||||
Split Route
|
||||
</button>
|
||||
{editing && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="bg-blue-600 text-white px-3 py-1 rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Save All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localSections.map((section) => (
|
||||
<div key={section.id} className="bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h4 className="font-medium">Section {section.id}</h4>
|
||||
<button
|
||||
onClick={() => editing === section.id ? handleSave() : handleEdit(section.id)}
|
||||
className={`text-sm ${editing === section.id ? 'text-blue-600' : 'text-gray-600 hover:text-gray-800'}`}
|
||||
>
|
||||
{editing === section.id ? 'Save' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="space-y-1">
|
||||
<p>Distance: {section.distance} km</p>
|
||||
<p>Elevation: {section.elevationGain} m</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p>Max Grade: {section.maxGrade}%</p>
|
||||
<p>Surface:
|
||||
{editing === section.id ? (
|
||||
<select
|
||||
value={section.surfaceType}
|
||||
onChange={(e) => handleChange(section.id, 'surfaceType', e.target.value)}
|
||||
className="ml-2 p-1 border rounded"
|
||||
>
|
||||
{surfaceTypes.map(type => (
|
||||
<option key={type} value={type}>{type.charAt(0).toUpperCase() + type.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="ml-2 capitalize">{section.surfaceType}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing === section.id && (
|
||||
<div className="mt-3 pt-2 border-t">
|
||||
<label className="block text-sm font-medium mb-1">Gear Recommendation</label>
|
||||
<select
|
||||
value={section.gearRecommendation}
|
||||
onChange={(e) => handleChange(section.id, 'gearRecommendation', e.target.value)}
|
||||
className="w-full p-1 border rounded"
|
||||
>
|
||||
{gearOptions[section.surfaceType]?.map(gear => (
|
||||
<option key={gear} value={gear}>{gear}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SectionList.propTypes = {
|
||||
sections: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onSplit: PropTypes.func.isRequired,
|
||||
onUpdate: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default SectionList;
|
||||
104
frontend/src/components/routes/SectionManager.jsx
Normal file
104
frontend/src/components/routes/SectionManager.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button, Input, Select } from '../ui'
|
||||
import { FaPlus, FaTrash } from 'react-icons/fa'
|
||||
|
||||
const SectionManager = ({ route, onSectionsUpdate }) => {
|
||||
const [sections, setSections] = useState(route.sections || [])
|
||||
const [newSection, setNewSection] = useState({
|
||||
name: '',
|
||||
start: 0,
|
||||
end: 0,
|
||||
difficulty: 3,
|
||||
recommended_gear: 'road'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onSectionsUpdate(sections)
|
||||
}, [sections, onSectionsUpdate])
|
||||
|
||||
const addSection = () => {
|
||||
if (newSection.name && newSection.start < newSection.end) {
|
||||
setSections([...sections, {
|
||||
...newSection,
|
||||
id: Date.now().toString(),
|
||||
distance: newSection.end - newSection.start
|
||||
}])
|
||||
setNewSection({
|
||||
name: '',
|
||||
start: 0,
|
||||
end: 0,
|
||||
difficulty: 3,
|
||||
recommended_gear: 'road'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const removeSection = (sectionId) => {
|
||||
setSections(sections.filter(s => s.id !== sectionId))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 items-end">
|
||||
<Input
|
||||
label="Section Name"
|
||||
value={newSection.name}
|
||||
onChange={(e) => setNewSection({...newSection, name: e.target.value})}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
label="Start (km)"
|
||||
value={newSection.start}
|
||||
onChange={(e) => setNewSection({...newSection, start: +e.target.value})}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
label="End (km)"
|
||||
value={newSection.end}
|
||||
onChange={(e) => setNewSection({...newSection, end: +e.target.value})}
|
||||
/>
|
||||
<Select
|
||||
label="Difficulty"
|
||||
value={newSection.difficulty}
|
||||
options={[1,2,3,4,5].map(n => ({value: n, label: `${n}/5`}))}
|
||||
onChange={(e) => setNewSection({...newSection, difficulty: +e.target.value})}
|
||||
/>
|
||||
<Select
|
||||
label="Gear"
|
||||
value={newSection.recommended_gear}
|
||||
options={[
|
||||
{value: 'road', label: 'Road Bike'},
|
||||
{value: 'gravel', label: 'Gravel Bike'},
|
||||
{value: 'tt', label: 'Time Trial'},
|
||||
{value: 'climbing', label: 'Climbing Bike'}
|
||||
]}
|
||||
onChange={(e) => setNewSection({...newSection, recommended_gear: e.target.value})}
|
||||
/>
|
||||
<Button onClick={addSection} className="h-[42px]">
|
||||
<FaPlus className="mr-2" /> Add Section
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{sections.map(section => (
|
||||
<div key={section.id} className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1 grid grid-cols-4 gap-4">
|
||||
<p className="font-medium">{section.name}</p>
|
||||
<p>{section.start}km - {section.end}km</p>
|
||||
<p>Difficulty: {section.difficulty}/5</p>
|
||||
<p className="capitalize">{section.recommended_gear.replace('_', ' ')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeSection(section.id)}
|
||||
className="text-red-600 hover:text-red-700 p-2"
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SectionManager
|
||||
26
frontend/src/components/routes/__tests__/FileUpload.test.jsx
Normal file
26
frontend/src/components/routes/__tests__/FileUpload.test.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import FileUpload from '../FileUpload'
|
||||
|
||||
describe('FileUpload', () => {
|
||||
const mockFile = new File(['<gpx><trk><name>Test Route</name></trk></gpx>'], 'test.gpx', {
|
||||
type: 'application/gpx+xml'
|
||||
})
|
||||
|
||||
it('handles file upload and preview', async () => {
|
||||
const mockSuccess = jest.fn()
|
||||
render(<FileUpload onUploadSuccess={mockSuccess} />)
|
||||
|
||||
// Simulate file drop
|
||||
const dropZone = screen.getByText('Drag and drop GPX file here')
|
||||
fireEvent.dragOver(dropZone)
|
||||
fireEvent.drop(dropZone, {
|
||||
dataTransfer: { files: [mockFile] }
|
||||
})
|
||||
|
||||
// Check preview
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Route')).toBeInTheDocument()
|
||||
expect(screen.getByText('Confirm Upload')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
41
frontend/src/components/routes/__tests__/RouteList.test.jsx
Normal file
41
frontend/src/components/routes/__tests__/RouteList.test.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import RouteList from '../RouteList'
|
||||
|
||||
const mockRoutes = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Mountain Loop',
|
||||
distance: 45000,
|
||||
elevation_gain: 800,
|
||||
category: 'climbing'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Lakeside Ride',
|
||||
distance: 25000,
|
||||
elevation_gain: 200,
|
||||
category: 'flat'
|
||||
}
|
||||
]
|
||||
|
||||
describe('RouteList', () => {
|
||||
it('displays routes and handles filtering', () => {
|
||||
render(<RouteList routes={mockRoutes} />)
|
||||
|
||||
// Check initial render
|
||||
expect(screen.getByText('Mountain Loop')).toBeInTheDocument()
|
||||
expect(screen.getByText('Lakeside Ride')).toBeInTheDocument()
|
||||
|
||||
// Test search
|
||||
fireEvent.change(screen.getByPlaceholderText('Search routes...'), {
|
||||
target: { value: 'mountain' }
|
||||
})
|
||||
expect(screen.getByText('Mountain Loop')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Lakeside Ride')).not.toBeInTheDocument()
|
||||
|
||||
// Test category filter
|
||||
fireEvent.click(screen.getByText('Flat'))
|
||||
expect(screen.queryByText('Mountain Loop')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Lakeside Ride')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import RouteMetadata from '../RouteMetadata'
|
||||
import { AuthProvider } from '../../../context/AuthContext'
|
||||
|
||||
const mockRoute = {
|
||||
id: 1,
|
||||
name: 'Test Route',
|
||||
description: 'Initial description',
|
||||
category: 'mixed'
|
||||
}
|
||||
|
||||
const Wrapper = ({ children }) => (
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
)
|
||||
|
||||
describe('RouteMetadata', () => {
|
||||
it('handles editing and updating route details', async () => {
|
||||
const mockUpdate = jest.fn()
|
||||
render(<RouteMetadata route={mockRoute} onUpdate={mockUpdate} />, { wrapper: Wrapper })
|
||||
|
||||
// Test initial view mode
|
||||
expect(screen.getByText('Test Route')).toBeInTheDocument()
|
||||
expect(screen.getByText('Initial description')).toBeInTheDocument()
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(screen.getByText('Edit'))
|
||||
|
||||
// Verify form fields
|
||||
const nameInput = screen.getByDisplayValue('Test Route')
|
||||
const descInput = screen.getByDisplayValue('Initial description')
|
||||
const categorySelect = screen.getByDisplayValue('Mixed')
|
||||
|
||||
// Make changes
|
||||
fireEvent.change(nameInput, { target: { value: 'Updated Route' } })
|
||||
fireEvent.change(descInput, { target: { value: 'New description' } })
|
||||
fireEvent.change(categorySelect, { target: { value: 'climbing' } })
|
||||
|
||||
// Save changes
|
||||
fireEvent.click(screen.getByText('Save'))
|
||||
|
||||
// Verify update was called
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'Updated Route',
|
||||
description: 'New description',
|
||||
category: 'climbing'
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import RouteVisualization from '../RouteVisualization'
|
||||
|
||||
const mockGPX = `
|
||||
<gpx>
|
||||
<trk>
|
||||
<trkseg>
|
||||
<trkpt lat="37.7749" lon="-122.4194"><ele>50</ele></trkpt>
|
||||
<trkpt lat="37.7859" lon="-122.4294"><ele>60</ele></trkpt>
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>
|
||||
`
|
||||
|
||||
describe('RouteVisualization', () => {
|
||||
it('renders map with GPX track', () => {
|
||||
render(<RouteVisualization gpxData={mockGPX} />)
|
||||
|
||||
// Check map container is rendered
|
||||
expect(screen.getByRole('presentation')).toBeInTheDocument()
|
||||
|
||||
// Check if polyline is created with coordinates
|
||||
const path = document.querySelector('.leaflet-overlay-pane path')
|
||||
expect(path).toHaveAttribute('d', expect.stringContaining('M37.7749 -122.4194L37.7859 -122.4294'))
|
||||
})
|
||||
})
|
||||
121
frontend/src/components/rules/RuleEditor.jsx
Normal file
121
frontend/src/components/rules/RuleEditor.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { parseRule } from '../../services/ruleService';
|
||||
|
||||
const RuleEditor = ({ value, onChange, onParse }) => {
|
||||
const [charCount, setCharCount] = useState(0);
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [showTemplates, setShowTemplates] = useState(false);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
const textarea = document.getElementById('ruleEditor');
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
}, [value]);
|
||||
|
||||
// Enhanced validation
|
||||
useEffect(() => {
|
||||
const count = value.length;
|
||||
setCharCount(count);
|
||||
|
||||
const hasRequiredKeywords = /(maximum|minimum|at least|no more than)/i.test(value);
|
||||
const hasNumbersWithUnits = /\d+\s+(rides?|hours?|days?|weeks?)/i.test(value);
|
||||
const hasConstraints = /(between|recovery|interval|duration)/i.test(value);
|
||||
|
||||
setIsValid(
|
||||
count <= 5000 &&
|
||||
count >= 10 &&
|
||||
hasRequiredKeywords &&
|
||||
hasNumbersWithUnits &&
|
||||
hasConstraints
|
||||
);
|
||||
}, [value]);
|
||||
|
||||
const handleParse = async () => {
|
||||
try {
|
||||
const { data } = await parseRule(value);
|
||||
onParse(data.jsonRules);
|
||||
} catch (err) {
|
||||
console.error('Parsing failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const templateSuggestions = [
|
||||
'Maximum 4 rides per week with at least one rest day between hard workouts',
|
||||
'Long rides limited to 3 hours maximum during weekdays',
|
||||
'No outdoor rides when temperature drops below 0°C',
|
||||
'Interval sessions limited to twice weekly with 48h recovery'
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-4 bg-white shadow-sm">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold">Natural Language Editor</h2>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowTemplates(!showTemplates)}
|
||||
className="px-3 py-1 bg-blue-100 text-blue-800 rounded-lg hover:bg-blue-200"
|
||||
>
|
||||
Templates
|
||||
</button>
|
||||
{showTemplates && (
|
||||
<div className="absolute right-0 mt-2 w-64 bg-white border rounded-lg shadow-lg z-10">
|
||||
{templateSuggestions.map((template, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-3 hover:bg-gray-50 cursor-pointer border-b"
|
||||
onClick={() => {
|
||||
onChange(template);
|
||||
setShowTemplates(false);
|
||||
}}
|
||||
>
|
||||
{template}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id="ruleEditor"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`w-full p-3 border rounded-lg focus:ring-2 ${
|
||||
isValid ? 'focus:ring-blue-500' : 'focus:ring-red-500'
|
||||
}`}
|
||||
placeholder="Enter your training rules in natural language..."
|
||||
rows="5"
|
||||
/>
|
||||
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
{charCount}/5000 characters •{' '}
|
||||
<span className={isValid ? 'text-green-600' : 'text-red-600'}>
|
||||
{isValid ? 'Valid' : 'Invalid input'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleParse}
|
||||
disabled={!isValid}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
isValid
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Parse Rules
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RuleEditor.propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onParse: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default RuleEditor;
|
||||
46
frontend/src/components/rules/RulePreview.jsx
Normal file
46
frontend/src/components/rules/RulePreview.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { JSONTree } from 'react-json-tree';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const RulePreview = ({ rules, onSave, isSaving }) => {
|
||||
return (
|
||||
<div className="border rounded-lg p-4 bg-white shadow-sm">
|
||||
<h2 className="text-xl font-semibold mb-4">Rule Configuration Preview</h2>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden mb-4">
|
||||
{rules ? (
|
||||
<JSONTree
|
||||
data={rules}
|
||||
theme="harmonic"
|
||||
hideRoot={false}
|
||||
shouldExpandNodeInitially={() => true}
|
||||
style={{ padding: '1rem' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-gray-500">
|
||||
Parsed rules will appear here...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={!rules || isSaving}
|
||||
className={`w-full py-2 rounded-lg font-medium ${
|
||||
rules && !isSaving
|
||||
? 'bg-green-600 text-white hover:bg-green-700'
|
||||
: 'bg-gray-200 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Rule Set'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RulePreview.propTypes = {
|
||||
rules: PropTypes.object,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
isSaving: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default RulePreview;
|
||||
113
frontend/src/components/rules/RulesList.jsx
Normal file
113
frontend/src/components/rules/RulesList.jsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const RulesList = ({ ruleSets, onSelect }) => {
|
||||
const [selectedSet, setSelectedSet] = useState(null);
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-4 bg-white shadow-sm">
|
||||
<h2 className="text-xl font-semibold mb-4">Saved Rule Sets</h2>
|
||||
|
||||
{ruleSets.length === 0 ? (
|
||||
<div className="text-gray-500 text-center py-4">
|
||||
No rule sets saved yet. Create one using the editor above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left border-b">
|
||||
<th className="pb-2">Name</th>
|
||||
<th className="pb-2">Version</th>
|
||||
<th className="pb-2">Status</th>
|
||||
<th className="pb-2">Created</th>
|
||||
<th className="pb-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ruleSets.map((set) => (
|
||||
<tr key={set.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3">{set.name || 'Untitled Rules'}</td>
|
||||
<td className="py-3">v{set.version}</td>
|
||||
<td className="py-3">
|
||||
<span className={`px-2 py-1 rounded-full text-sm ${
|
||||
set.active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{set.active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
{format(new Date(set.created_at), 'MMM dd, yyyy')}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedSet(set);
|
||||
onSelect(set);
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
View/Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Version History Modal */}
|
||||
{selectedSet && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{selectedSet.name} Version History
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setSelectedSet(null)}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{selectedSet.history?.map((version) => (
|
||||
<div
|
||||
key={version.version}
|
||||
className="p-3 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<span className="font-medium">v{version.version}</span>
|
||||
<span className="text-sm text-gray-500 ml-2">
|
||||
{format(new Date(version.created_at), 'MMM dd, yyyy HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onSelect(version)}
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RulesList.propTypes = {
|
||||
ruleSets: PropTypes.array.isRequired,
|
||||
onSelect: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default RulesList;
|
||||
45
frontend/src/components/rules/__tests__/RuleEditor.test.jsx
Normal file
45
frontend/src/components/rules/__tests__/RuleEditor.test.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import RuleEditor from '../RuleEditor';
|
||||
|
||||
describe('RuleEditor', () => {
|
||||
const mockOnChange = jest.fn();
|
||||
const mockOnParse = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders editor with basic functionality', () => {
|
||||
render(<RuleEditor value="" onChange={mockOnChange} onParse={mockOnParse} />);
|
||||
|
||||
expect(screen.getByText('Natural Language Editor')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Enter your training rules in natural language...')).toBeInTheDocument();
|
||||
expect(screen.getByText('Parse Rules')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows character count and validation status', () => {
|
||||
const { rerender } = render(
|
||||
<RuleEditor value="Valid rule text" onChange={mockOnChange} onParse={mockOnParse} />
|
||||
);
|
||||
|
||||
expect(screen.getByText(/0\/5000 characters/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Valid')).toBeInTheDocument();
|
||||
|
||||
rerender(<RuleEditor value="Short" onChange={mockOnChange} onParse={mockOnParse} />);
|
||||
expect(screen.getByText('Invalid input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows template suggestions when clicked', async () => {
|
||||
render(<RuleEditor value="" onChange={mockOnChange} onParse={mockOnParse} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Templates'));
|
||||
expect(screen.getByText('Maximum 4 rides per week with at least one rest day between hard workouts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('triggers parse on button click', () => {
|
||||
render(<RuleEditor value="Valid rule text" onChange={mockOnChange} onParse={mockOnParse} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Parse Rules'));
|
||||
expect(mockOnParse).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
20
frontend/src/components/rules/__tests__/RulePreview.test.jsx
Normal file
20
frontend/src/components/rules/__tests__/RulePreview.test.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import RulePreview from '../RulePreview';
|
||||
|
||||
describe('RulePreview', () => {
|
||||
const mockRules = {
|
||||
maxRides: 4,
|
||||
minRestDays: 1
|
||||
};
|
||||
|
||||
test('renders preview with rules', () => {
|
||||
render(<RulePreview rules={mockRules} />);
|
||||
expect(screen.getByText('Rule Configuration Preview')).toBeInTheDocument();
|
||||
expect(screen.getByText('maxRides')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows placeholder when no rules', () => {
|
||||
render(<RulePreview rules={null} />);
|
||||
expect(screen.getByText('Parsed rules will appear here...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
32
frontend/src/components/rules/__tests__/RulesList.test.jsx
Normal file
32
frontend/src/components/rules/__tests__/RulesList.test.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import RulesList from '../RulesList';
|
||||
|
||||
const mockRuleSets = [{
|
||||
id: 1,
|
||||
name: 'Winter Rules',
|
||||
version: 2,
|
||||
active: true,
|
||||
created_at: '2024-01-15T11:00:00Z',
|
||||
history: [
|
||||
{ version: 1, created_at: '2024-01-01T09:00:00Z' },
|
||||
{ version: 2, created_at: '2024-01-15T11:00:00Z' }
|
||||
]
|
||||
}];
|
||||
|
||||
describe('RulesList', () => {
|
||||
test('renders rule sets table', () => {
|
||||
render(<RulesList ruleSets={mockRuleSets} />);
|
||||
|
||||
expect(screen.getByText('Winter Rules')).toBeInTheDocument();
|
||||
expect(screen.getByText('v2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows version history modal', () => {
|
||||
render(<RulesList ruleSets={mockRuleSets} />);
|
||||
|
||||
fireEvent.click(screen.getByText('View/Edit'));
|
||||
expect(screen.getByText('Winter Rules Version History')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/v\d/)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
38
frontend/src/components/ui/ProgressTracker.jsx
Normal file
38
frontend/src/components/ui/ProgressTracker.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const ProgressTracker = ({ currentStep, totalSteps }) => {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
{[...Array(totalSteps)].map((_, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
index + 1 <= currentStep
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-600'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
{index < totalSteps - 1 && (
|
||||
<div className={`w-16 h-1 ${
|
||||
index + 1 < currentStep
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 text-center">
|
||||
Step {currentStep} of {totalSteps}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProgressTracker.propTypes = {
|
||||
currentStep: PropTypes.number.isRequired,
|
||||
totalSteps: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
export default ProgressTracker;
|
||||
85
frontend/src/pages/PlanGeneration.jsx
Normal file
85
frontend/src/pages/PlanGeneration.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import GoalSelector from '../components/plans/GoalSelector';
|
||||
import PlanParameters from '../components/plans/PlanParameters';
|
||||
import { generatePlan } from '../services/planService';
|
||||
import ProgressTracker from '../components/ui/ProgressTracker';
|
||||
|
||||
const PlanGeneration = () => {
|
||||
const { apiKey } = useAuth();
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(1);
|
||||
const [goals, setGoals] = useState([]);
|
||||
const [rules, setRules] = useState([]);
|
||||
const [params, setParams] = useState({
|
||||
duration: 4,
|
||||
weeklyHours: 8,
|
||||
availableDays: []
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleGenerate = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const plan = await generatePlan(apiKey, {
|
||||
goals,
|
||||
ruleIds: rules,
|
||||
...params
|
||||
});
|
||||
router.push(`/plans/${plan.id}/preview`);
|
||||
} catch (err) {
|
||||
setError('Failed to generate plan. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<ProgressTracker currentStep={step} totalSteps={3} />
|
||||
|
||||
{step === 1 && (
|
||||
<GoalSelector
|
||||
goals={goals}
|
||||
onSelect={setGoals}
|
||||
onNext={() => setStep(2)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<PlanParameters
|
||||
values={params}
|
||||
onChange={setParams}
|
||||
onBack={() => setStep(1)}
|
||||
onNext={() => setStep(3)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 className="text-2xl font-bold mb-4">Review and Generate</h2>
|
||||
<div className="mb-6">
|
||||
<h3 className="font-semibold mb-2">Selected Goals:</h3>
|
||||
<ul className="list-disc pl-5">
|
||||
{goals.map((goal, index) => (
|
||||
<li key={index}>{goal}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
{loading ? 'Generating...' : 'Generate Plan'}
|
||||
</button>
|
||||
{error && <p className="text-red-500 mt-2">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanGeneration;
|
||||
@@ -1,25 +1,72 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import FileUpload from '../components/routes/FileUpload';
|
||||
import RouteList from '../components/routes/RouteList';
|
||||
import RouteFilter from '../components/routes/RouteFilter';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
|
||||
const RoutesPage = () => {
|
||||
const { apiKey } = useAuth();
|
||||
|
||||
// Handle build-time case where apiKey is undefined
|
||||
if (typeof window === 'undefined') {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-8">Routes</h1>
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<p className="text-gray-600">Loading route management...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const [routes, setRoutes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [filters, setFilters] = useState({
|
||||
searchQuery: '',
|
||||
minDistance: 0,
|
||||
maxDistance: 500,
|
||||
difficulty: 'all',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRoutes = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/routes', {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch routes');
|
||||
|
||||
const data = await response.json();
|
||||
setRoutes(data.routes);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRoutes();
|
||||
}, [apiKey]);
|
||||
|
||||
const filteredRoutes = routes.filter(route => {
|
||||
const matchesSearch = route.name.toLowerCase().includes(filters.searchQuery.toLowerCase());
|
||||
const matchesDistance = route.distance >= filters.minDistance &&
|
||||
route.distance <= filters.maxDistance;
|
||||
const matchesDifficulty = filters.difficulty === 'all' ||
|
||||
route.difficulty === filters.difficulty;
|
||||
return matchesSearch && matchesDistance && matchesDifficulty;
|
||||
});
|
||||
|
||||
const handleUploadSuccess = (newRoute) => {
|
||||
setRoutes(prev => [...prev, newRoute]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-8">Routes</h1>
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<p className="text-gray-600">Route management will be displayed here</p>
|
||||
<div className="space-y-8">
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<RouteFilter filters={filters} onFilterChange={setFilters} />
|
||||
<FileUpload onUploadSuccess={handleUploadSuccess} />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<LoadingSpinner />
|
||||
) : error ? (
|
||||
<div className="text-red-600 bg-red-50 p-4 rounded-md">{error}</div>
|
||||
) : (
|
||||
<RouteList routes={filteredRoutes} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
85
frontend/src/pages/Rules.jsx
Normal file
85
frontend/src/pages/Rules.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import RuleEditor from '../components/rules/RuleEditor';
|
||||
import RulePreview from '../components/rules/RulePreview';
|
||||
import RulesList from '../components/rules/RulesList';
|
||||
import { getRuleSets, createRuleSet, parseRule } from '../services/ruleService';
|
||||
|
||||
const RulesPage = () => {
|
||||
const [ruleText, setRuleText] = useState('');
|
||||
const [parsedRules, setParsedRules] = useState(null);
|
||||
const [ruleSets, setRuleSets] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Load initial rule sets
|
||||
useEffect(() => {
|
||||
const loadRuleSets = async () => {
|
||||
try {
|
||||
const { data } = await getRuleSets();
|
||||
setRuleSets(data);
|
||||
} catch (err) {
|
||||
setError('Failed to load rule sets');
|
||||
}
|
||||
};
|
||||
loadRuleSets();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await createRuleSet({
|
||||
naturalLanguage: ruleText,
|
||||
jsonRules: parsedRules
|
||||
});
|
||||
setRuleText('');
|
||||
setParsedRules(null);
|
||||
// Refresh rule sets list
|
||||
const { data } = await getRuleSets();
|
||||
setRuleSets(data);
|
||||
} catch (err) {
|
||||
setError('Failed to save rule set');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6">Training Rules Management</h1>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-6 mb-8">
|
||||
<div className="flex-1">
|
||||
<RuleEditor
|
||||
value={ruleText}
|
||||
onChange={setRuleText}
|
||||
onParse={setParsedRules}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<RulePreview
|
||||
rules={parsedRules}
|
||||
onSave={handleSave}
|
||||
isSaving={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RulesList
|
||||
ruleSets={ruleSets}
|
||||
onSelect={(set) => {
|
||||
setRuleText(set.naturalLanguage);
|
||||
setParsedRules(set.jsonRules);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RulesPage;
|
||||
@@ -6,7 +6,9 @@ export default function Home() {
|
||||
useEffect(() => {
|
||||
const checkBackendHealth = async () => {
|
||||
try {
|
||||
const response = await fetch('http://backend:8000/health');
|
||||
// Use the API URL from environment variables
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
const response = await fetch(`${apiUrl}/health`);
|
||||
const data = await response.json();
|
||||
setHealthStatus(data.status);
|
||||
} catch (error) {
|
||||
|
||||
47
frontend/src/services/planService.js
Normal file
47
frontend/src/services/planService.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const generatePlan = async (apiKey, planData) => {
|
||||
try {
|
||||
const response = await axios.post('/api/plans/generate', planData, {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.error || 'Failed to generate plan');
|
||||
}
|
||||
};
|
||||
|
||||
export const getPlanPreview = async (apiKey, planId) => {
|
||||
try {
|
||||
const response = await axios.get(`/api/plans/${planId}/preview`, {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.error || 'Failed to load plan preview');
|
||||
}
|
||||
};
|
||||
|
||||
export const pollPlanStatus = async (apiKey, planId, interval = 2000) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const response = await axios.get(`/api/plans/${planId}/status`, {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
});
|
||||
|
||||
if (response.data.status === 'completed') {
|
||||
resolve(response.data.plan);
|
||||
} else if (response.data.status === 'failed') {
|
||||
reject(new Error('Plan generation failed'));
|
||||
} else {
|
||||
setTimeout(checkStatus, interval);
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
await checkStatus();
|
||||
});
|
||||
};
|
||||
64
frontend/src/services/routeService.js
Normal file
64
frontend/src/services/routeService.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import axios from 'axios';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api'
|
||||
});
|
||||
|
||||
// Request interceptor to add API key header
|
||||
api.interceptors.request.use(config => {
|
||||
const { apiKey } = useAuth.getState();
|
||||
if (apiKey) {
|
||||
config.headers['X-API-Key'] = apiKey;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
export const uploadRoute = async (formData) => {
|
||||
try {
|
||||
const response = await api.post('/routes/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.error || 'Failed to upload route');
|
||||
}
|
||||
};
|
||||
|
||||
export const getRouteDetails = async (routeId) => {
|
||||
try {
|
||||
const response = await api.get(`/routes/${routeId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.error || 'Failed to fetch route details');
|
||||
}
|
||||
};
|
||||
|
||||
export const saveRouteMetadata = async (routeId, metadata) => {
|
||||
try {
|
||||
const response = await api.put(`/routes/${routeId}/metadata`, metadata);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.error || 'Failed to save metadata');
|
||||
}
|
||||
};
|
||||
|
||||
export const saveRouteSections = async (routeId, sections) => {
|
||||
try {
|
||||
const response = await api.post(`/routes/${routeId}/sections`, { sections });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.error || 'Failed to save sections');
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteRoute = async (routeId) => {
|
||||
try {
|
||||
const response = await api.delete(`/routes/${routeId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.error || 'Failed to delete route');
|
||||
}
|
||||
};
|
||||
55
frontend/src/services/ruleService.js
Normal file
55
frontend/src/services/ruleService.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL + '/rules';
|
||||
const API_KEY = process.env.REACT_APP_API_KEY;
|
||||
|
||||
const apiClient = axios.create({
|
||||
headers: {
|
||||
'X-API-Key': API_KEY,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
export const getRuleSets = async () => {
|
||||
try {
|
||||
const response = await apiClient.get(API_URL);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to fetch rule sets');
|
||||
}
|
||||
};
|
||||
|
||||
export const createRuleSet = async (ruleData) => {
|
||||
try {
|
||||
const response = await apiClient.post(API_URL, ruleData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Failed to create rule set');
|
||||
}
|
||||
};
|
||||
|
||||
export const updateRuleSet = async (id, ruleData) => {
|
||||
try {
|
||||
const response = await apiClient.put(`${API_URL}/${id}`, ruleData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Failed to update rule set');
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteRuleSet = async (id) => {
|
||||
try {
|
||||
await apiClient.delete(`${API_URL}/${id}`);
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Failed to delete rule set');
|
||||
}
|
||||
};
|
||||
|
||||
export const parseRule = async (text) => {
|
||||
try {
|
||||
const response = await apiClient.post(`${API_URL}/parse`, { text });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Failed to parse rules');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user