Merge branch 'master' of ssh://stash.dyson.global.corp:7999/~thowitz/tremor-tracker
249
package-lock.json
generated
@ -16,6 +16,7 @@
|
|||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
"csv-parse": "^5.6.0",
|
"csv-parse": "^5.6.0",
|
||||||
"csv-parser": "^3.2.0",
|
"csv-parser": "^3.2.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"easy-peasy": "^6.1.0",
|
"easy-peasy": "^6.1.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
@ -27,9 +28,11 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mapbox-gl": "^3.10.0",
|
"mapbox-gl": "^3.10.0",
|
||||||
"next": "^15.1.7",
|
"next": "^15.1.7",
|
||||||
|
"next-auth": "^4.24.11",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
"prisma": "^6.4.1",
|
"prisma": "^6.4.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
"react-datepicker": "^8.4.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
@ -246,6 +249,59 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@floating-ui/core": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/utils": "^0.2.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/dom": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/core": "^1.7.0",
|
||||||
|
"@floating-ui/utils": "^0.2.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/react": {
|
||||||
|
"version": "0.27.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.9.tgz",
|
||||||
|
"integrity": "sha512-Y0aCJBNtfVF6ikI1kVzA0WzSAhVBz79vFWOhvb5MLCRNODZ1ylGSLTuncchR7JsLyn9QzV6JD44DyZhhOtvpRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/react-dom": "^2.1.2",
|
||||||
|
"@floating-ui/utils": "^0.2.9",
|
||||||
|
"tabbable": "^6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17.0.0",
|
||||||
|
"react-dom": ">=17.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/react-dom": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/dom": "^1.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/utils": {
|
||||||
|
"version": "0.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
|
||||||
|
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@ -1015,6 +1071,15 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@panva/hkdf": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
@ -2493,6 +2558,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color": {
|
"node_modules/color": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||||
@ -2783,6 +2857,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
@ -5596,6 +5680,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-auth": {
|
||||||
|
"version": "4.24.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz",
|
||||||
|
"integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@panva/hkdf": "^1.0.2",
|
||||||
|
"cookie": "^0.7.0",
|
||||||
|
"jose": "^4.15.5",
|
||||||
|
"oauth": "^0.9.15",
|
||||||
|
"openid-client": "^5.4.0",
|
||||||
|
"preact": "^10.6.3",
|
||||||
|
"preact-render-to-string": "^5.1.19",
|
||||||
|
"uuid": "^8.3.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@auth/core": "0.34.2",
|
||||||
|
"next": "^12.2.5 || ^13 || ^14 || ^15",
|
||||||
|
"nodemailer": "^6.6.5",
|
||||||
|
"react": "^17.0.2 || ^18 || ^19",
|
||||||
|
"react-dom": "^17.0.2 || ^18 || ^19"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@auth/core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"nodemailer": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/next-auth/node_modules/jose": {
|
||||||
|
"version": "4.15.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||||
|
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next/node_modules/postcss": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
@ -5634,6 +5759,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oauth": {
|
||||||
|
"version": "0.9.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
|
||||||
|
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@ -5766,6 +5897,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oidc-token-hash": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^10.13.0 || >=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@ -5787,6 +5927,51 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openid-client": {
|
||||||
|
"version": "5.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||||
|
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^4.15.9",
|
||||||
|
"lru-cache": "^6.0.0",
|
||||||
|
"object-hash": "^2.2.0",
|
||||||
|
"oidc-token-hash": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client/node_modules/jose": {
|
||||||
|
"version": "4.15.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||||
|
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client/node_modules/lru-cache": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client/node_modules/object-hash": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@ -6138,6 +6323,28 @@
|
|||||||
"integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==",
|
"integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.26.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.26.8.tgz",
|
||||||
|
"integrity": "sha512-1nMfdFjucm5hKvq0IClqZwK4FJkGXhRrQstOQ3P4vp8HxKrJEMFcY6RdBRVTdfQS/UlnX6gfbPuTvaqx/bDoeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/preact-render-to-string": {
|
||||||
|
"version": "5.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
|
||||||
|
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pretty-format": "^3.8.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@ -6148,6 +6355,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pretty-format": {
|
||||||
|
"version": "3.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
|
||||||
|
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/prisma": {
|
"node_modules/prisma": {
|
||||||
"version": "6.8.2",
|
"version": "6.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.8.2.tgz",
|
||||||
@ -6324,6 +6537,21 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-datepicker": {
|
||||||
|
"version": "8.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.4.0.tgz",
|
||||||
|
"integrity": "sha512-6nPDnj8vektWCIOy9ArS3avus9Ndsyz5XgFCJ7nBxXASSpBdSL6lG9jzNNmViPOAOPh6T5oJyGaXuMirBLECag==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/react": "^0.27.3",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.1.0",
|
"version": "19.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||||
@ -7344,6 +7572,12 @@
|
|||||||
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tabbable": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.17",
|
"version": "3.4.17",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
||||||
@ -7832,6 +8066,15 @@
|
|||||||
"integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
|
"integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "8.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
@ -8068,6 +8311,12 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/yallist": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.0",
|
"version": "2.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
"csv-parse": "^5.6.0",
|
"csv-parse": "^5.6.0",
|
||||||
"csv-parser": "^3.2.0",
|
"csv-parser": "^3.2.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"easy-peasy": "^6.1.0",
|
"easy-peasy": "^6.1.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
@ -30,9 +31,11 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mapbox-gl": "^3.10.0",
|
"mapbox-gl": "^3.10.0",
|
||||||
"next": "^15.1.7",
|
"next": "^15.1.7",
|
||||||
|
"next-auth": "^4.24.11",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
"prisma": "^6.4.1",
|
"prisma": "^6.4.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
"react-datepicker": "^8.4.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
|
|||||||
BIN
public/Athena.PNG
Normal file
|
After Width: | Height: | Size: 589 KiB |
BIN
public/StuartEnthusiast.PNG
Normal file
|
After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 15 KiB |
BIN
public/crack1.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
public/crack2.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/lava.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/observe.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
public/pulsatingMap.jpg
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
public/stuart.PNG
Normal file
|
After Width: | Height: | Size: 673 KiB |
BIN
public/team.PNG
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
@ -1,386 +1,569 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useRef, useState } from "react";
|
import React, { useRef, useState, useEffect } from "react";
|
||||||
|
import { useStoreState } from "@hooks/store";
|
||||||
|
|
||||||
|
// --- Types and labels ---
|
||||||
type Role = "ADMIN" | "GUEST" | "SCIENTIST";
|
type Role = "ADMIN" | "GUEST" | "SCIENTIST";
|
||||||
const roleLabels: Record<Role, string> = {
|
const roleLabels: Record<Role, string> = {
|
||||||
ADMIN: "Admin",
|
ADMIN: "Admin",
|
||||||
GUEST: "Guest",
|
GUEST: "Guest",
|
||||||
SCIENTIST: "Scientist",
|
SCIENTIST: "Scientist",
|
||||||
};
|
};
|
||||||
type User = {
|
type User = {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: Role;
|
role: Role;
|
||||||
password: string;
|
createdAt: string;
|
||||||
createdAt: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// todo add fulfilling of requests
|
|
||||||
// todo create api route to get users, with auth for only admin
|
|
||||||
// todo add management of only junior scientists if senior scientist
|
|
||||||
// todo (optional) add display of each user's previous orders when selecting them
|
|
||||||
const initialUsers: User[] = [
|
const initialUsers: User[] = [
|
||||||
{ email: "john@example.com", name: "John Doe", role: "ADMIN", password: "secret1", createdAt: "2024-06-21T09:15:01Z", id: 1 },
|
{
|
||||||
{ email: "jane@example.com", name: "Jane Smith", role: "GUEST", password: "secret2", createdAt: "2024-06-21T10:01:09Z", id: 2 },
|
email: "users-loading@admin.api",
|
||||||
{
|
name: "Loading Users",
|
||||||
email: "bob@example.com",
|
role: "ADMIN",
|
||||||
name: "Bob Brown",
|
createdAt: "Check admin api and frontend",
|
||||||
role: "SCIENTIST",
|
id: 0,
|
||||||
password: "secret3",
|
},
|
||||||
createdAt: "2024-06-21T12:13:45Z",
|
|
||||||
id: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
email: "alice@example.com",
|
|
||||||
name: "Alice Johnson",
|
|
||||||
role: "GUEST",
|
|
||||||
password: "secret4",
|
|
||||||
createdAt: "2024-06-20T18:43:20Z",
|
|
||||||
id: 4,
|
|
||||||
},
|
|
||||||
{ email: "eve@example.com", name: "Eve Black", role: "ADMIN", password: "secret5", createdAt: "2024-06-20T19:37:10Z", id: 5 },
|
|
||||||
{ email: "dave@example.com", name: "Dave Clark", role: "GUEST", password: "pw", createdAt: "2024-06-19T08:39:10Z", id: 6 },
|
|
||||||
{ email: "fred@example.com", name: "Fred Fox", role: "GUEST", password: "pw", createdAt: "2024-06-19T09:11:52Z", id: 7 },
|
|
||||||
{ email: "ginny@example.com", name: "Ginny Hall", role: "SCIENTIST", password: "pw", createdAt: "2024-06-17T14:56:27Z", id: 8 },
|
|
||||||
{ email: "harry@example.com", name: "Harry Lee", role: "ADMIN", password: "pw", createdAt: "2024-06-16T19:28:11Z", id: 9 },
|
|
||||||
{ email: "ivy@example.com", name: "Ivy Volt", role: "ADMIN", password: "pw", createdAt: "2024-06-15T21:04:05Z", id: 10 },
|
|
||||||
{ email: "kate@example.com", name: "Kate Moss", role: "SCIENTIST", password: "pw", createdAt: "2024-06-14T11:16:35Z", id: 11 },
|
|
||||||
{ email: "leo@example.com", name: "Leo Garrison", role: "GUEST", password: "pw", createdAt: "2024-06-12T08:02:51Z", id: 12 },
|
|
||||||
{ email: "isaac@example.com", name: "Isaac Yang", role: "GUEST", password: "pw", createdAt: "2024-06-12T15:43:29Z", id: 13 },
|
|
||||||
];
|
];
|
||||||
const sortFields = [
|
const sortFields = [
|
||||||
// Sort box options
|
{ label: "Name", value: "name" },
|
||||||
{ label: "Name", value: "name" },
|
{ label: "Email", value: "email" },
|
||||||
{ label: "Email", value: "email" },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type SortField = (typeof sortFields)[number]["value"];
|
type SortField = (typeof sortFields)[number]["value"];
|
||||||
type SortDir = "asc" | "desc";
|
type SortDir = "asc" | "desc";
|
||||||
const dirLabels: Record<SortDir, string> = { asc: "ascending", desc: "descending" };
|
const dirLabels: Record<SortDir, string> = { asc: "ascending", desc: "descending" };
|
||||||
const fieldLabels: Record<SortField, string> = { name: "Name", email: "Email" };
|
const fieldLabels: Record<SortField, string> = { name: "Name", email: "Email" };
|
||||||
|
|
||||||
|
// =========== THE PAGE =============
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const [users, setUsers] = useState<User[]>(initialUsers);
|
// ---- All hooks at the top!
|
||||||
const [selectedEmail, setSelectedEmail] = useState<string | null>(null);
|
const user = useStoreState((state) => state.user);
|
||||||
|
|
||||||
// Local edit state for SCIENTIST form
|
const [selectedEmail, setSelectedEmail] = useState<string | null>(null);
|
||||||
const [editUser, setEditUser] = useState<User | null>(null);
|
const [users, setUsers] = useState<User[]>(initialUsers);
|
||||||
// Reset editUser when the selected user changes
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
React.useEffect(() => {
|
const [addForm, setAddForm] = useState<{ name: string; email: string; role: Role; password: string }>({
|
||||||
if (!selectedEmail) setEditUser(null);
|
name: "",
|
||||||
else {
|
email: "",
|
||||||
const user = users.find((u) => u.email === selectedEmail);
|
role: "SCIENTIST",
|
||||||
setEditUser(user ? { ...user } : null);
|
password: "",
|
||||||
}
|
});
|
||||||
}, [selectedEmail, users]);
|
const [addError, setAddError] = useState<string | null>(null);
|
||||||
|
const [addLoading, setAddLoading] = useState(false);
|
||||||
|
const [editUser, setEditUser] = useState<User | null>(null);
|
||||||
|
const [searchField, setSearchField] = useState<"name" | "email">("name");
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [roleFilter, setRoleFilter] = useState<Role | "all">("all");
|
||||||
|
const [sortField, setSortField] = useState<SortField>("name");
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||||
|
const [newPassword, setNewPassword] = useState<string>("");
|
||||||
|
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
|
||||||
|
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
|
||||||
|
const filterDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [showEmailTooltip, setShowEmailTooltip] = useState(false);
|
||||||
|
|
||||||
// Search/filter/sort state
|
useEffect(() => {
|
||||||
const [searchField, setSearchField] = useState<"name" | "email">("name");
|
async function fetchUsers() {
|
||||||
const [searchText, setSearchText] = useState("");
|
try {
|
||||||
const [roleFilter, setRoleFilter] = useState<Role | "all">("all");
|
const res = await fetch("/api/admin");
|
||||||
const [sortField, setSortField] = useState<SortField>("name");
|
if (!res.ok) throw new Error("Failed to fetch");
|
||||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
const data = await res.json();
|
||||||
// Dropdown states
|
setUsers(data.users);
|
||||||
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
|
} catch (err) {
|
||||||
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
|
console.error("Error fetching users:", err);
|
||||||
const filterDropdownRef = useRef<HTMLDivElement>(null);
|
}
|
||||||
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
}
|
||||||
|
fetchUsers();
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
filterDropdownRef.current &&
|
||||||
|
!filterDropdownRef.current.contains(e.target as Node)
|
||||||
|
)
|
||||||
|
setFilterDropdownOpen(false);
|
||||||
|
if (
|
||||||
|
sortDropdownRef.current &&
|
||||||
|
!sortDropdownRef.current.contains(e.target as Node)
|
||||||
|
)
|
||||||
|
setSortDropdownOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedEmail) setEditUser(null);
|
||||||
|
else {
|
||||||
|
const user = users.find((u) => u.email === selectedEmail);
|
||||||
|
setEditUser(user ? { ...user } : null);
|
||||||
|
}
|
||||||
|
}, [selectedEmail, users]);
|
||||||
|
// Filtering, searching, sorting logic
|
||||||
|
const filteredUsers = users.filter(
|
||||||
|
(user) => roleFilter === "all" || user.role === roleFilter
|
||||||
|
);
|
||||||
|
const searchedUsers = filteredUsers.filter((user) =>
|
||||||
|
user[searchField].toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
const sortedUsers = [...searchedUsers].sort((a, b) => {
|
||||||
|
let cmp = a[sortField].localeCompare(b[sortField]);
|
||||||
|
return sortDir === "asc" ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
async function handleAddUser(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setAddError(null);
|
||||||
|
if (!addForm.name || !addForm.email || !addForm.password) {
|
||||||
|
setAddError("All fields are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setAddLoading(true);
|
||||||
|
const res = await fetch("/api/admin", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(addForm),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data?.error || "Failed to add user");
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setUsers((prev) => [...prev, data.user]);
|
||||||
|
setAddOpen(false);
|
||||||
|
setAddForm({ name: "", email: "", role: "SCIENTIST", password: "" });
|
||||||
|
} catch (err: any) {
|
||||||
|
setAddError(err?.message || "Unknown error");
|
||||||
|
} finally {
|
||||||
|
setAddLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleEditChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||||
|
) => {
|
||||||
|
if (!editUser) return;
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setEditUser((prev) => (prev ? { ...prev, [name]: value } : null));
|
||||||
|
};
|
||||||
|
const handlePasswordChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||||
|
) => {
|
||||||
|
if (!editUser) return;
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setEditUser((prev) => (prev ? { ...prev, [name]: value } : null));
|
||||||
|
};
|
||||||
|
const selectedUser = users.find((u) => u.email === selectedEmail);
|
||||||
|
const isEditChanged = React.useMemo(() => {
|
||||||
|
if (!editUser || !selectedUser) return false;
|
||||||
|
return (
|
||||||
|
editUser.name !== selectedUser.name ||
|
||||||
|
editUser.role !== selectedUser.role ||
|
||||||
|
newPassword
|
||||||
|
);
|
||||||
|
}, [editUser, selectedUser, newPassword]);
|
||||||
|
async function updateUserOnServer(user: User, password: string) {
|
||||||
|
const body: any = {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
};
|
||||||
|
if (password.trim() !== "") {
|
||||||
|
body.password = password;
|
||||||
|
}
|
||||||
|
const res = await fetch("/api/admin", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to update user");
|
||||||
|
const data = await res.json();
|
||||||
|
return data.user as User;
|
||||||
|
}
|
||||||
|
const handleUpdate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editUser) return;
|
||||||
|
try {
|
||||||
|
const updated = await updateUserOnServer(editUser, newPassword);
|
||||||
|
setUsers((prev) =>
|
||||||
|
prev.map((u) => (u.id === updated.id ? { ...updated } : u))
|
||||||
|
);
|
||||||
|
setNewPassword("");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update user:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!selectedUser) return;
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Are you sure you want to delete "${selectedUser.name}"? This cannot be undone.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id: selectedUser.id }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data?.error || "Failed to delete user");
|
||||||
|
setUsers((prev) =>
|
||||||
|
prev.filter((u) => u.email !== selectedUser.email)
|
||||||
|
);
|
||||||
|
setSelectedEmail(null);
|
||||||
|
setEditUser(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err?.message || "Delete failed!");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const allRoles: Role[] = ["ADMIN", "GUEST", "SCIENTIST"];
|
||||||
|
|
||||||
React.useEffect(() => {
|
// --- ADMIN ONLY:
|
||||||
const handleClick = (e: MouseEvent) => {
|
if (!user || user.role !== "ADMIN") {
|
||||||
if (filterDropdownRef.current && !filterDropdownRef.current.contains(e.target as Node)) setFilterDropdownOpen(false);
|
return (
|
||||||
if (sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as Node)) setSortDropdownOpen(false);
|
<div className="flex items-center justify-center min-h-[70vh] flex-col">
|
||||||
};
|
<h1 className="text-2xl font-bold text-red-500 mb-4">
|
||||||
document.addEventListener("mousedown", handleClick);
|
Unauthorized Access
|
||||||
return () => document.removeEventListener("mousedown", handleClick);
|
</h1>
|
||||||
}, []);
|
<div className="text-gray-600">You do not have access to this page.</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Filtering, searching, sorting logic
|
// --- Render admin UI
|
||||||
const filteredUsers = users.filter((user) => roleFilter === "all" || user.role === roleFilter);
|
return (
|
||||||
const searchedUsers = filteredUsers.filter((user) => user[searchField].toLowerCase().includes(searchText.toLowerCase()));
|
<div className="flex flex-col h-full">
|
||||||
const sortedUsers = [...searchedUsers].sort((a, b) => {
|
<div className="flex h-full overflow-hidden bg-gray-50">
|
||||||
let cmp = a[sortField].localeCompare(b[sortField]);
|
{/* SIDEBAR */}
|
||||||
return sortDir === "asc" ? cmp : -cmp;
|
<div className="w-80 h-full border-r border-neutral-200 bg-neutral-100 flex flex-col rounded-l-xl shadow-sm">
|
||||||
});
|
<div className="p-4 flex flex-col h-full">
|
||||||
|
{/* Search, filter, sort controls ... (your code unchanged) */}
|
||||||
// Form input change handler
|
<div className="mb-3 flex gap-2">
|
||||||
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
<input
|
||||||
if (!editUser) return;
|
className="flex-1 border rounded-lg px-2 py-1 text-sm"
|
||||||
const { name, value } = e.target;
|
placeholder={`Search by ${searchField}`}
|
||||||
setEditUser((prev) => (prev ? { ...prev, [name]: value } : null));
|
value={searchText}
|
||||||
};
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
/>
|
||||||
// Update button logic (compare original selectedUser and editUser)
|
<button
|
||||||
const selectedUser = users.find((u) => u.email === selectedEmail);
|
type="button"
|
||||||
const isEditChanged = React.useMemo(() => {
|
className="border rounded-lg px-2 py-1 text-sm bg-white hover:bg-neutral-100 transition font-semibold"
|
||||||
if (!editUser || !selectedUser) return false;
|
style={{ width: "80px" }}
|
||||||
// Compare primitive fields
|
onClick={() => setSearchField((field) => (field === "name" ? "email" : "name"))}
|
||||||
return (
|
title={`Switch to searching by ${searchField === "name" ? "Email" : "Name"}`}
|
||||||
editUser.name !== selectedUser.name || editUser.role !== selectedUser.role || editUser.password !== selectedUser.password
|
>
|
||||||
);
|
{searchField === "name" ? "Email" : "Name"}
|
||||||
}, [editUser, selectedUser]);
|
</button>
|
||||||
|
</div>
|
||||||
// Update/save changes
|
<div className="flex gap-2 items-center mb-2">
|
||||||
const handleUpdate = (e: React.FormEvent) => {
|
{/* Filter */}
|
||||||
e.preventDefault();
|
<div className="relative" ref={filterDropdownRef}>
|
||||||
if (!editUser) return;
|
<button
|
||||||
setUsers((prev) => prev.map((u) => (u.email === editUser.email ? { ...editUser } : u)));
|
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
|
||||||
// todo create receiving api route
|
${roleFilter !== "all"
|
||||||
// todo send to api route
|
? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700"
|
||||||
// After successful update, update selectedUser local state
|
: "bg-white text-gray-700 border hover:bg-neutral-200"
|
||||||
// (editUser will auto-sync due to useEffect on users)
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Delete user logic
|
|
||||||
const handleDelete = () => {
|
|
||||||
if (!selectedUser) return;
|
|
||||||
if (!window.confirm(`Are you sure you want to delete "${selectedUser.name}"? This cannot be undone.`)) return;
|
|
||||||
setUsers((prev) => prev.filter((u) => u.email !== selectedUser.email));
|
|
||||||
setSelectedEmail(null);
|
|
||||||
setEditUser(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const allRoles: Role[] = ["ADMIN", "GUEST", "SCIENTIST"];
|
|
||||||
|
|
||||||
// Tooltip handling for email field
|
|
||||||
const [showEmailTooltip, setShowEmailTooltip] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="flex h-full overflow-hidden bg-gray-50">
|
|
||||||
{/* SIDEBAR */}
|
|
||||||
<div className="w-80 h-full border-r border-neutral-200 bg-neutral-100 flex flex-col rounded-l-xl shadow-sm">
|
|
||||||
<div className="p-4 flex flex-col h-full">
|
|
||||||
{/* Search Bar */}
|
|
||||||
<div className="mb-3 flex gap-2">
|
|
||||||
<input
|
|
||||||
className="flex-1 border rounded-lg px-2 py-1 text-sm"
|
|
||||||
placeholder={`Search by ${searchField}`}
|
|
||||||
value={searchText}
|
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="border rounded-lg px-2 py-1 text-sm bg-white hover:bg-neutral-100 transition font-semibold"
|
|
||||||
style={{ width: "80px" }} // fixed width, adjust as needed
|
|
||||||
onClick={() => setSearchField((field) => (field === "name" ? "email" : "name"))}
|
|
||||||
title={`Switch to searching by ${searchField === "name" ? "Email" : "Name"}`}
|
|
||||||
>
|
|
||||||
{searchField === "name" ? "Email" : "Name"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/* Filter and Sort Buttons */}
|
|
||||||
<div className="flex gap-2 items-center mb-2">
|
|
||||||
{/* Filter */}
|
|
||||||
<div className="relative" ref={filterDropdownRef}>
|
|
||||||
<button
|
|
||||||
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
|
|
||||||
${
|
|
||||||
roleFilter !== "all"
|
|
||||||
? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700"
|
|
||||||
: "bg-white text-gray-700 border hover:bg-neutral-200"
|
|
||||||
}
|
|
||||||
`}
|
`}
|
||||||
onClick={() => setFilterDropdownOpen((v) => !v)}
|
onClick={() => setFilterDropdownOpen((v) => !v)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Filter{" "}
|
Filter{" "}
|
||||||
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{filterDropdownOpen && (
|
{filterDropdownOpen && (
|
||||||
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 py-1">
|
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 py-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRoleFilter("all");
|
setRoleFilter("all");
|
||||||
setFilterDropdownOpen(false);
|
setFilterDropdownOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||||
${roleFilter === "all" ? "font-bold text-blue-600" : ""}`}
|
${roleFilter === "all" ? "font-bold text-blue-600" : ""}`}
|
||||||
>
|
>
|
||||||
All
|
All
|
||||||
</button>
|
</button>
|
||||||
{allRoles.map((role) => (
|
{allRoles.map((role) => (
|
||||||
<button
|
<button
|
||||||
key={role}
|
key={role}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRoleFilter(role);
|
setRoleFilter(role);
|
||||||
setFilterDropdownOpen(false);
|
setFilterDropdownOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||||
${roleFilter === role ? "font-bold text-blue-600" : ""}`}
|
${roleFilter === role ? "font-bold text-blue-600" : ""}`}
|
||||||
>
|
>
|
||||||
{roleLabels[role]}
|
{roleLabels[role]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Sort */}
|
{/* Sort */}
|
||||||
<div className="relative" ref={sortDropdownRef}>
|
<div className="relative" ref={sortDropdownRef}>
|
||||||
<button
|
<button
|
||||||
className="px-3 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
|
className="px-3 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
|
||||||
onClick={() => setSortDropdownOpen((v) => !v)}
|
onClick={() => setSortDropdownOpen((v) => !v)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Sort{" "}
|
Sort{" "}
|
||||||
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{sortDropdownOpen && (
|
{sortDropdownOpen && (
|
||||||
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 ">
|
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 ">
|
||||||
{sortFields.map((opt) => (
|
{sortFields.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSortField(opt.value);
|
setSortField(opt.value);
|
||||||
setSortDropdownOpen(false);
|
setSortDropdownOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
className={`w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||||
${sortField === opt.value ? "font-bold text-blue-600" : ""}`}
|
${sortField === opt.value ? "font-bold text-blue-600" : ""}`}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Asc/Desc Toggle */}
|
{/* Asc/Desc Toggle */}
|
||||||
<button
|
<button
|
||||||
className="ml-2 px-2 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
|
className="ml-2 px-2 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
|
||||||
onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}
|
onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}
|
||||||
title={sortDir === "asc" ? "Ascending" : "Descending"}
|
title={sortDir === "asc" ? "Ascending" : "Descending"}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{sortDir === "asc" ? "↑" : "↓"}
|
{sortDir === "asc" ? "↑" : "↓"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
{/* ADD BUTTON */}
|
||||||
{/* Sort status text */}
|
<button
|
||||||
<small className="text-xs text-gray-500 mb-2 px-1">
|
className="ml-2 px-2 py-1 rounded-lg bg-green-600 hover:bg-green-700 text-white font-bold flex items-center shadow transition duration-150"
|
||||||
Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
|
type="button"
|
||||||
</small>
|
style={{ minWidth: 36, minHeight: 36 }}
|
||||||
{/* USERS LIST: full height, scrollable */}
|
onClick={() => setAddOpen(true)}
|
||||||
<ul className="overflow-y-auto flex-1 pr-1">
|
disabled={addOpen}
|
||||||
{sortedUsers.map((user) => (
|
title="Add user"
|
||||||
<li
|
>
|
||||||
key={user.email}
|
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2.2} viewBox="0 0 24 24">
|
||||||
onClick={() => setSelectedEmail(user.email)}
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m7-7H5" />
|
||||||
className={`rounded-lg cursor-pointer border
|
</svg>
|
||||||
${selectedEmail === user.email ? "bg-blue-100 border-blue-400" : "hover:bg-gray-200 border-transparent"}
|
</button>
|
||||||
transition px-2 py-1 mb-1`}
|
</div>
|
||||||
>
|
<small className="text-xs text-gray-500 mb-2 px-1">
|
||||||
<div className="flex items-center justify-between">
|
Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
|
||||||
<span className="text-sm font-medium truncate">{user.name}</span>
|
</small>
|
||||||
<span className="ml-1 text-xs px-2 py-0.5 rounded-lg bg-gray-200 text-gray-700">{roleLabels[user.role]}</span>
|
{/* USERS LIST */}
|
||||||
</div>
|
<ul className="overflow-y-auto flex-1 pr-1">
|
||||||
<div className="flex items-center justify-between mt-0.5">
|
{sortedUsers.map((user) => (
|
||||||
<span className="text-xs text-gray-600 truncate">{user.email}</span>
|
<li
|
||||||
</div>
|
key={user.email}
|
||||||
</li>
|
onClick={() => setSelectedEmail(user.email)}
|
||||||
))}
|
className={`rounded-lg cursor-pointer border
|
||||||
{sortedUsers.length === 0 && <li className="text-gray-400 text-center py-6">No users found.</li>}
|
${selectedEmail === user.email ? "bg-blue-100 border-blue-400" : "hover:bg-gray-200 border-transparent"}
|
||||||
</ul>
|
transition px-2 py-1 mb-1`}
|
||||||
</div>
|
>
|
||||||
</div>
|
<div className="flex items-center justify-between">
|
||||||
{/* MAIN PANEL */}
|
<span className="text-sm font-medium truncate">{user.name}</span>
|
||||||
<div className="flex-1 p-24 bg-white overflow-y-auto">
|
<span className="ml-1 text-xs px-2 py-0.5 rounded-lg bg-gray-200 text-gray-700">{roleLabels[user.role]}</span>
|
||||||
{editUser ? (
|
</div>
|
||||||
<div className="max-w-lg mx-auto bg-white p-6 rounded-lg shadow">
|
<div className="flex items-center justify-between mt-0.5">
|
||||||
<h2 className="text-lg font-bold mb-6">Edit User</h2>
|
<span className="text-xs text-gray-600 truncate">{user.email}</span>
|
||||||
<form className="space-y-4" onSubmit={handleUpdate}>
|
</div>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
</li>
|
||||||
<label className="text-sm font-medium text-gray-700">Account Creation Time:</label>
|
))}
|
||||||
<span className="text-sm text-gray-500">{editUser.createdAt}</span>
|
{sortedUsers.length === 0 && <li className="text-gray-400 text-center py-6">No users found.</li>}
|
||||||
</div>
|
</ul>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
</div>
|
||||||
<label className="text-sm font-medium text-gray-700">Account ID Number:</label>
|
</div>
|
||||||
<span className="text-sm text-gray-500">{editUser.id}</span>
|
{/* MAIN PANEL */}
|
||||||
</div>
|
<div className="flex-1 p-24 bg-white overflow-y-auto">
|
||||||
<div className="relative">
|
{/* Add User Modal */}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email (unique):</label>
|
{addOpen && (
|
||||||
<input
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-30">
|
||||||
className="w-full border px-3 py-2 rounded-lg outline-none bg-gray-100 cursor-not-allowed"
|
<div className="bg-white rounded-lg shadow-lg p-8 max-w-sm w-full relative">
|
||||||
type="email"
|
<h3 className="text-lg font-bold mb-4">Add New User</h3>
|
||||||
name="email"
|
<form onSubmit={handleAddUser} className="space-y-3">
|
||||||
value={editUser.email}
|
<div>
|
||||||
readOnly
|
<label className="block text-sm font-medium mb-1">Email</label>
|
||||||
onMouseEnter={() => setShowEmailTooltip(true)}
|
<input
|
||||||
onMouseLeave={() => setShowEmailTooltip(false)}
|
className="w-full border px-2 py-1 rounded-lg"
|
||||||
/>
|
type="email"
|
||||||
{/* Custom tooltip */}
|
required
|
||||||
{showEmailTooltip && (
|
value={addForm.email}
|
||||||
<div className="absolute left-0 top-full mt-1 z-10 w-max max-w-xs bg-gray-800 text-gray-100 text-xs px-3 py-2 rounded-md shadow-lg border border-gray-700">
|
onChange={e => setAddForm(f => ({ ...f, email: e.target.value }))}
|
||||||
This field cannot be changed. <br />
|
/>
|
||||||
To change the email, delete and re-add the user.
|
</div>
|
||||||
</div>
|
<div>
|
||||||
)}
|
<label className="block text-sm font-medium mb-1">Name</label>
|
||||||
</div>
|
<input
|
||||||
<div>
|
className="w-full border px-2 py-1 rounded-lg"
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name:</label>
|
type="text"
|
||||||
<input
|
required
|
||||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
value={addForm.name}
|
||||||
type="text"
|
onChange={e => setAddForm(f => ({ ...f, name: e.target.value }))}
|
||||||
name="name"
|
/>
|
||||||
value={editUser.name}
|
</div>
|
||||||
onChange={handleEditChange}
|
<div>
|
||||||
/>
|
<label className="block text-sm font-medium mb-1">Role</label>
|
||||||
</div>
|
<select
|
||||||
<div>
|
className="w-full border px-2 py-1 rounded-lg"
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Role:</label>
|
value={addForm.role}
|
||||||
<select
|
onChange={e => setAddForm(f => ({ ...f, role: e.target.value as Role }))}
|
||||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
>
|
||||||
name="role"
|
{allRoles.map(role => (
|
||||||
value={editUser.role}
|
<option value={role} key={role}>{roleLabels[role]}</option>
|
||||||
onChange={handleEditChange}
|
))}
|
||||||
>
|
</select>
|
||||||
{allRoles.map((role) => (
|
</div>
|
||||||
<option key={role} value={role}>
|
<div>
|
||||||
{roleLabels[role]}
|
<label className="block text-sm font-medium mb-1">Password</label>
|
||||||
</option>
|
<input
|
||||||
))}
|
className="w-full border px-2 py-1 rounded-lg"
|
||||||
</select>
|
type="text"
|
||||||
</div>
|
required
|
||||||
<div>
|
value={addForm.password}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password:</label>
|
onChange={e => setAddForm(f => ({ ...f, password: e.target.value }))}
|
||||||
<input
|
/>
|
||||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
</div>
|
||||||
type="text"
|
{addError && <div className="text-red-600 text-xs">{addError}</div>}
|
||||||
name="password"
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
value={editUser.password}
|
<button
|
||||||
onChange={handleEditChange}
|
type="button"
|
||||||
/>
|
className="px-3 py-1 rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 transition"
|
||||||
</div>
|
onClick={() => setAddOpen(false)}
|
||||||
<div className="flex gap-2 justify-end pt-6">
|
disabled={addLoading}
|
||||||
<button
|
>
|
||||||
type="button"
|
Cancel
|
||||||
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white font-semibold rounded-lg shadow transition"
|
</button>
|
||||||
onClick={handleDelete}
|
<button
|
||||||
>
|
type="submit"
|
||||||
Delete
|
className={`px-3 py-1 rounded-lg text-white font-semibold ${addLoading ? "bg-green-400" : "bg-green-600 hover:bg-green-700"}`}
|
||||||
</button>
|
disabled={addLoading}
|
||||||
<button
|
>
|
||||||
type="submit"
|
{addLoading ? "Adding..." : "Add"}
|
||||||
className={`px-4 py-2 rounded-lg font-semibold transition
|
</button>
|
||||||
${
|
</div>
|
||||||
isEditChanged
|
</form>
|
||||||
? "bg-blue-600 hover:bg-blue-700 text-white shadow"
|
</div>
|
||||||
: "bg-gray-300 text-gray-500 cursor-not-allowed"
|
</div>
|
||||||
}`}
|
)}
|
||||||
disabled={!isEditChanged}
|
|
||||||
>
|
{/* Edit User Panel */}
|
||||||
Update
|
{editUser ? (
|
||||||
</button>
|
<div className="max-w-lg mx-auto bg-white p-6 rounded-lg shadow">
|
||||||
</div>
|
<h2 className="text-lg font-bold mb-6">Edit User</h2>
|
||||||
</form>
|
<form className="space-y-4" onSubmit={handleUpdate}>
|
||||||
</div>
|
<div className="flex items-center gap-2 mb-2">
|
||||||
) : (
|
<label className="text-sm font-medium text-gray-700">
|
||||||
<div className="text-center text-gray-400 mt-16 text-lg">Select a user...</div>
|
Account Creation Time:
|
||||||
)}
|
</label>
|
||||||
</div>
|
<span className="text-sm text-gray-500">{editUser.createdAt}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-2 mb-2">
|
||||||
);
|
<label className="text-sm font-medium text-gray-700">
|
||||||
|
Account ID Number:
|
||||||
|
</label>
|
||||||
|
<span className="text-sm text-gray-500">{editUser.id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Email (unique):
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="w-full border px-3 py-2 rounded-lg outline-none bg-gray-100 cursor-not-allowed"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
value={editUser.email}
|
||||||
|
readOnly
|
||||||
|
onMouseEnter={() => setShowEmailTooltip(true)}
|
||||||
|
onMouseLeave={() => setShowEmailTooltip(false)}
|
||||||
|
/>
|
||||||
|
{showEmailTooltip && (
|
||||||
|
<div className="absolute left-0 top-full mt-1 z-10 w-max max-w-xs bg-gray-800 text-gray-100 text-xs px-3 py-2 rounded-md shadow-lg border border-gray-700">
|
||||||
|
This field cannot be changed. <br />
|
||||||
|
To change the email, delete and re-add the user.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Name:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
value={editUser.name}
|
||||||
|
onChange={handleEditChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Role:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
|
name="role"
|
||||||
|
value={editUser.role}
|
||||||
|
onChange={handleEditChange}
|
||||||
|
>
|
||||||
|
{allRoles.map((role) => (
|
||||||
|
<option key={role} value={role}>
|
||||||
|
{roleLabels[role]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Password:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
|
type="text"
|
||||||
|
name="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end pt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white font-semibold rounded-lg shadow transition"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`px-4 py-2 rounded-lg font-semibold transition
|
||||||
|
${
|
||||||
|
isEditChanged
|
||||||
|
? "bg-blue-600 hover:bg-blue-700 text-white shadow"
|
||||||
|
: "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||||
|
}`}
|
||||||
|
disabled={!isEditChanged}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-400 mt-16 text-lg">
|
||||||
|
Select a user...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
126
src/app/api/admin/route.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { prisma } from "@utils/prisma";
|
||||||
|
import { env } from "@utils/env";
|
||||||
|
import { verifyJwt } from "@utils/verifyJwt";
|
||||||
|
import bcryptjs from "bcryptjs";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Helper
|
||||||
|
async function getUserFromRequest() {
|
||||||
|
const cookieStore = cookies();
|
||||||
|
const token = (await cookieStore).get("jwt")?.value;
|
||||||
|
if (!token) return null;
|
||||||
|
const payload = await verifyJwt({ token, secret: env.JWT_SECRET_KEY });
|
||||||
|
if (!payload?.userId) return null;
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: payload.userId as number },
|
||||||
|
select: { id: true, role: true },
|
||||||
|
});
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getUserFromRequest();
|
||||||
|
if (!user || user.role !== "ADMIN") {
|
||||||
|
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
select: { id: true, email: true, name: true, role: true, createdAt: true },
|
||||||
|
});
|
||||||
|
const cleanedUsers = users.map(u => ({
|
||||||
|
...u,
|
||||||
|
createdAt: u.createdAt instanceof Date ? u.createdAt.toISOString() : u.createdAt,
|
||||||
|
}));
|
||||||
|
return NextResponse.json({ users: cleanedUsers }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching users:", error);
|
||||||
|
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const user = await getUserFromRequest();
|
||||||
|
if (!user || user.role !== "ADMIN") {
|
||||||
|
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const body = await request.json();
|
||||||
|
const { id, name, role, password } = body;
|
||||||
|
const updateData: any = { name, role };
|
||||||
|
if (typeof password === "string" && password.trim() !== "") {
|
||||||
|
updateData.passwordHash = await bcryptjs.hash(password, 10);
|
||||||
|
}
|
||||||
|
const updated = await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ user: updated }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update error:", error);
|
||||||
|
return NextResponse.json({ error: "Update failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const user = await getUserFromRequest();
|
||||||
|
if (!user || user.role !== "ADMIN") {
|
||||||
|
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const body = await request.json();
|
||||||
|
// Validate input (simple for demo, use zod or similar in prod)
|
||||||
|
const schema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
role: z.enum(["ADMIN", "SCIENTIST", "GUEST"]),
|
||||||
|
password: z.string().min(6)
|
||||||
|
});
|
||||||
|
const { email, name, role, password } = schema.parse(body);
|
||||||
|
|
||||||
|
// Check uniqueness
|
||||||
|
const exists = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (exists) {
|
||||||
|
return NextResponse.json({ error: "Email already exists" }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcryptjs.hash(password, 10);
|
||||||
|
const created = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
passwordHash,
|
||||||
|
},
|
||||||
|
select: { id: true, email: true, name: true, role: true, createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ user: { ...created, createdAt: created.createdAt instanceof Date ? created.createdAt.toISOString() : created.createdAt } }, { status: 201 });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Create user error:", error);
|
||||||
|
return NextResponse.json({ error: error?.message ?? "Failed to create user" }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const user = await getUserFromRequest();
|
||||||
|
if (!user || user.role !== "ADMIN") {
|
||||||
|
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const body = await request.json();
|
||||||
|
const { id } = body;
|
||||||
|
if (typeof id !== "number" || isNaN(id)) {
|
||||||
|
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
await prisma.user.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
return NextResponse.json({ success: true }, { status: 200 });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Delete error:", error);
|
||||||
|
return NextResponse.json({ error: error.message || "Delete failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/app/api/earthquakes/log/route.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@utils/prisma";
|
||||||
|
|
||||||
|
// Generates code using only the country, and highest id in DB for numbering
|
||||||
|
async function generateEarthquakeCode(type: string, country: string) {
|
||||||
|
const typeLetter = type.trim().charAt(0).toUpperCase();
|
||||||
|
// Remove non-alphanumeric for the country part
|
||||||
|
const countrySlug = (country || "Unknown").replace(/[^\w]/gi, "");
|
||||||
|
// Use highest DB id to find the latest added earthquake's code number
|
||||||
|
const last = await prisma.earthquake.findFirst({
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
select: { code: true }
|
||||||
|
});
|
||||||
|
let num = 10000;
|
||||||
|
if (last?.code) {
|
||||||
|
const parts = last.code.split("-");
|
||||||
|
const lastNum = parseInt(parts[parts.length - 1], 10);
|
||||||
|
if (!isNaN(lastNum)) num = lastNum + 1;
|
||||||
|
}
|
||||||
|
return `E${typeLetter}-${countrySlug}-${num.toString().padStart(5, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { date, magnitude, type, location, latitude, longitude, depth, country } = body;
|
||||||
|
const creatorId = 1;
|
||||||
|
if (!date || !magnitude || !type || !location || !latitude || !longitude || !depth || !country) {
|
||||||
|
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (+magnitude > 10) {
|
||||||
|
return NextResponse.json({ error: "Magnitude cannot exceed 10" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const code = await generateEarthquakeCode(type, country);
|
||||||
|
const eq = await prisma.earthquake.create({
|
||||||
|
data: {
|
||||||
|
date: new Date(date),
|
||||||
|
code,
|
||||||
|
magnitude: +magnitude,
|
||||||
|
type,
|
||||||
|
location, // "city, country"
|
||||||
|
latitude: +latitude,
|
||||||
|
longitude: +longitude,
|
||||||
|
depth,
|
||||||
|
creatorId,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return NextResponse.json({ id: eq.id, code }, { status: 201 });
|
||||||
|
} catch (e: any) {
|
||||||
|
return NextResponse.json({ error: e.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/app/api/earthquakes/search/route.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { query } = await req.json();
|
||||||
|
|
||||||
|
// Nothing to search
|
||||||
|
if (!query || typeof query !== "string" || query.trim().length === 0) {
|
||||||
|
// Return recent earthquakes if no search string
|
||||||
|
const earthquakes = await prisma.earthquake.findMany({
|
||||||
|
orderBy: { date: "desc" },
|
||||||
|
take: 30,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ earthquakes });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple search: code, location, magnitude (add more fields as desired)
|
||||||
|
const q = query.trim();
|
||||||
|
|
||||||
|
const earthquakes = await prisma.earthquake.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ code: { contains: q, } },
|
||||||
|
{ location: { contains: q, } },
|
||||||
|
{
|
||||||
|
magnitude: Number.isNaN(Number(q))
|
||||||
|
? undefined
|
||||||
|
: Number(q),
|
||||||
|
},
|
||||||
|
// optionally add more fields
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { date: "desc" },
|
||||||
|
take: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ earthquakes });
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Earthquake search error:", e);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to search earthquakes." },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/app/api/management/request/route.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@utils/prisma";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const { requestType, requestingUserId, scientistId, comment } = await req.json();
|
||||||
|
const request = await prisma.request.create({
|
||||||
|
data: {
|
||||||
|
requestType,
|
||||||
|
requestingUser: { connect: { id: requestingUserId } },
|
||||||
|
outcome: "IN_PROGRESS",
|
||||||
|
// Optionally you can connect to Scientist via an inline relation if you have a foreign key
|
||||||
|
// If the model has comment or details fields, add it!
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ request }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Request create error:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to create request" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/app/api/management/route.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@utils/prisma";
|
||||||
|
|
||||||
|
// GET all scientists (with user, superior.user, subordinates)
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const scientists = await prisma.scientist.findMany({
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
superior: { include: { user: true } },
|
||||||
|
subordinates: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ scientists }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching scientists:", error);
|
||||||
|
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CREATE scientist
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const { name, level, userId, superiorId } = await req.json();
|
||||||
|
const scientist = await prisma.scientist.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
level,
|
||||||
|
user: { connect: { id: userId } },
|
||||||
|
superior: superiorId ? { connect: { id: superiorId } } : undefined,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
superior: { include: { user: true } },
|
||||||
|
subordinates: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ scientist }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Scientist create error:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to create scientist" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPDATE scientist
|
||||||
|
export async function PUT(req: Request) {
|
||||||
|
try {
|
||||||
|
const { id, name, level, userId, superiorId } = await req.json();
|
||||||
|
const updatedScientist = await prisma.scientist.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
level,
|
||||||
|
user: { connect: { id: userId } },
|
||||||
|
superior: superiorId ? { connect: { id: superiorId } } : { disconnect: true },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
superior: { include: { user: true } },
|
||||||
|
subordinates: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ scientist: updatedScientist }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update error:", error);
|
||||||
|
return NextResponse.json({ error: "Update failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE scientist
|
||||||
|
export async function DELETE(req: Request) {
|
||||||
|
try {
|
||||||
|
const { id } = await req.json();
|
||||||
|
await prisma.scientist.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ success: true }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Delete error:", error);
|
||||||
|
return NextResponse.json({ error: "Delete failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/app/api/observatories/log/route.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@utils/prisma";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const {
|
||||||
|
name, location, latitude, longitude, dateEstablished,
|
||||||
|
dateClosed, isFunctional
|
||||||
|
} = body;
|
||||||
|
const creatorId = 1; // (Set per logged-in user if desired)
|
||||||
|
if (
|
||||||
|
!name || !location || latitude == null || longitude == null ||
|
||||||
|
!dateEstablished || isFunctional == null
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const created = await prisma.observatory.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
location,
|
||||||
|
latitude: +latitude,
|
||||||
|
longitude: +longitude,
|
||||||
|
dateEstablished: new Date(dateEstablished),
|
||||||
|
isFunctional,
|
||||||
|
seismicSensorOnline: false, // You could add this to modal if you want
|
||||||
|
creatorId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return NextResponse.json({ id: created.id, name: created.name }, { status: 201 });
|
||||||
|
} catch (e: any) {
|
||||||
|
return NextResponse.json({ error: e.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/app/api/users/all/route.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@utils/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
include: {
|
||||||
|
scientist: true, // So you know if the user already has a scientist
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return NextResponse.json({ users }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching all users:", error);
|
||||||
|
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,28 +1,150 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import React, { useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import BottomFooter from "@components/BottomFooter";
|
import BottomFooter from "@components/BottomFooter";
|
||||||
|
|
||||||
const ContactUs = () => {
|
const ContactUs = () => {
|
||||||
const [formData, setFormData] = useState({
|
// Form/modal
|
||||||
name: "",
|
const [formData, setFormData] = useState({ name: "", email: "", message: "" });
|
||||||
email: "",
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
message: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleChange = (e: { target: { name: any; value: any } }) => {
|
// 1. Lava (Instagram): state & timer
|
||||||
|
const [lavaActive, setLavaActive] = useState(false);
|
||||||
|
const lavaTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// 2. Pulsating Map (Facebook): state & timer
|
||||||
|
const [pulsatingActive, setPulsatingActive] = useState(false);
|
||||||
|
const pulsatingTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// 3. Shake (LinkedIn): state & timer
|
||||||
|
const [shaking, setShaking] = useState(false);
|
||||||
|
const shakeTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// 4. Crack & Collapse (X): state & timer
|
||||||
|
const [showCracks, setShowCracks] = useState(false);
|
||||||
|
const [collapse, setCollapse] = useState(false);
|
||||||
|
const crackTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// Lava flood handler (top-down flood)
|
||||||
|
const handleInstagramClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLavaActive(true);
|
||||||
|
if (lavaTimeout.current) clearTimeout(lavaTimeout.current);
|
||||||
|
lavaTimeout.current = setTimeout(() => setLavaActive(false), 2000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Pulsating Map handler
|
||||||
|
const handleFacebookClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPulsatingActive(true);
|
||||||
|
if (pulsatingTimeout.current) clearTimeout(pulsatingTimeout.current);
|
||||||
|
pulsatingTimeout.current = setTimeout(() => setPulsatingActive(false), 2000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// LinkedIn shake handler
|
||||||
|
const handleLinkedInClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (shaking) return;
|
||||||
|
setShaking(true);
|
||||||
|
const body = document.body;
|
||||||
|
body.classList.remove("shake-screen");
|
||||||
|
void body.offsetWidth;
|
||||||
|
body.classList.add("shake-screen");
|
||||||
|
if (shakeTimeout.current) clearTimeout(shakeTimeout.current);
|
||||||
|
shakeTimeout.current = setTimeout(() => {
|
||||||
|
setShaking(false);
|
||||||
|
body.classList.remove("shake-screen");
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
[shaking]
|
||||||
|
);
|
||||||
|
|
||||||
|
// X (crack and collapse) handler
|
||||||
|
const handleXClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowCracks(true);
|
||||||
|
if (crackTimeout.current) clearTimeout(crackTimeout.current);
|
||||||
|
crackTimeout.current = setTimeout(() => {
|
||||||
|
setCollapse(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowCracks(false);
|
||||||
|
setCollapse(false);
|
||||||
|
}, 1500);
|
||||||
|
}, 1000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clean up timeouts and shake class
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (lavaTimeout.current) clearTimeout(lavaTimeout.current);
|
||||||
|
if (pulsatingTimeout.current) clearTimeout(pulsatingTimeout.current);
|
||||||
|
if (shakeTimeout.current) clearTimeout(shakeTimeout.current);
|
||||||
|
if (crackTimeout.current) clearTimeout(crackTimeout.current);
|
||||||
|
document.body.classList.remove("shake-screen");
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Form handlers
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||||
};
|
};
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
const handleSubmit = (e: { preventDefault: () => void }) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
console.log("Form submitted with data:", formData);
|
console.log("Form submitted with data:", formData);
|
||||||
alert("Thank you for reaching out! We will get back to you soon.");
|
setIsModalOpen(true);
|
||||||
setFormData({ name: "", email: "", message: "" });
|
setFormData({ name: "", email: "", message: "" });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full relative text-white border border-black overflow-hidden">
|
<div className="min-h-screen relative text-white border border-black ">
|
||||||
|
{/* Lava Flood Overlay */}
|
||||||
|
{lavaActive && (
|
||||||
|
<div className="lava-flood-overlay lava-active fixed inset-0 z-[60] flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
src="/lava.jpg"
|
||||||
|
alt="Lava flood"
|
||||||
|
draggable={false}
|
||||||
|
className="w-full min-h-screen object-cover opacity-80 pointer-events-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Pulsating Overlay */}
|
||||||
|
{pulsatingActive && (
|
||||||
|
<div className="pulsating-map-overlay pulsating-active fixed inset-0 z-[60] flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
src="/pulsatingMap.jpg"
|
||||||
|
alt="Pulsating Map"
|
||||||
|
draggable={false}
|
||||||
|
className="w-full min-h-screen object-cover opacity-80 pointer-events-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Crack & Collapse Overlay */}
|
||||||
|
{(showCracks || collapse) && (
|
||||||
|
<div
|
||||||
|
className={`crack-overlay fixed inset-0 z-[60] flex items-center justify-center${collapse ? " crack-collapse" : ""}`}
|
||||||
|
>
|
||||||
|
<img className="crack crack1 absolute w-3/4" src="/crack1.png" alt="" />
|
||||||
|
<img className="crack crack2 absolute w-3/4" src="/crack2.png" alt="" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Modal: Submit Success */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60">
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-8 max-w-sm w-full text-center">
|
||||||
|
<h2 className="text-2xl font-bold mb-4 text-neutral-800">Thank You!</h2>
|
||||||
|
<p className="text-neutral-700 mb-6">Thank you for submitting a message. We will be responding via our email.</p>
|
||||||
|
<button
|
||||||
|
className="bg-blue-600 text-white px-5 py-2 rounded-lg font-medium hover:bg-blue-700 transition"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Image
|
<Image
|
||||||
height={5000}
|
height={5000}
|
||||||
width={5000}
|
width={5000}
|
||||||
@ -30,9 +152,8 @@ const ContactUs = () => {
|
|||||||
className="border border-neutral-300 absolute z-10 -top-20"
|
className="border border-neutral-300 absolute z-10 -top-20"
|
||||||
src="/tsunamiWaves.jpg"
|
src="/tsunamiWaves.jpg"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Overlay for readability */}
|
{/* Overlay for readability */}
|
||||||
<div className="absolute overflow-hidden w-full h-full bg-gradient-to-b from-black/80 via-black/40 to-black/20 flex flex-col items-center z-20">
|
<div className="relative w-full min-h-screen bg-gradient-to-b from-black/80 via-black/40 to-black/20 flex flex-col items-center z-20">
|
||||||
{/* Container */}
|
{/* Container */}
|
||||||
<div className="max-w-4xl mx-auto p-5 mt-20">
|
<div className="max-w-4xl mx-auto p-5 mt-20">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -41,8 +162,6 @@ const ContactUs = () => {
|
|||||||
Have questions or concerns about earthquakes, observatories or artefacts? Contact us via phone, email, social media or
|
Have questions or concerns about earthquakes, observatories or artefacts? Contact us via phone, email, social media or
|
||||||
using the form below with the relevant contact details.
|
using the form below with the relevant contact details.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Content Section */}
|
|
||||||
<div className="flex flex-col md:flex-row gap-6">
|
<div className="flex flex-col md:flex-row gap-6">
|
||||||
{/* Contact Form Section */}
|
{/* Contact Form Section */}
|
||||||
<div className="flex-1 bg-white bg-opacity-90 text-neutral-800 rounded-lg shadow-lg p-6">
|
<div className="flex-1 bg-white bg-opacity-90 text-neutral-800 rounded-lg shadow-lg p-6">
|
||||||
@ -62,7 +181,6 @@ const ContactUs = () => {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label htmlFor="email" className="block text-neutral-700 font-medium mb-2">
|
<label htmlFor="email" className="block text-neutral-700 font-medium mb-2">
|
||||||
Email
|
Email
|
||||||
@ -78,7 +196,6 @@ const ContactUs = () => {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label htmlFor="message" className="block text-neutral-700 font-medium mb-2">
|
<label htmlFor="message" className="block text-neutral-700 font-medium mb-2">
|
||||||
Message
|
Message
|
||||||
@ -95,7 +212,6 @@ const ContactUs = () => {
|
|||||||
style={{ resize: "none" }}
|
style={{ resize: "none" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 transition duration-200"
|
className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 transition duration-200"
|
||||||
@ -104,7 +220,6 @@ const ContactUs = () => {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact Details Section */}
|
{/* Contact Details Section */}
|
||||||
<div className="w-[45%] bg-white bg-opacity-90 text-neutral-800 rounded-lg shadow-lg p-6">
|
<div className="w-[45%] bg-white bg-opacity-90 text-neutral-800 rounded-lg shadow-lg p-6">
|
||||||
<h2 className="text-2xl font-bold text-neutral-800 mb-4">Get in Touch</h2>
|
<h2 className="text-2xl font-bold text-neutral-800 mb-4">Get in Touch</h2>
|
||||||
@ -120,23 +235,51 @@ const ContactUs = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-neutral-700 font-bold font-large">Address</h3>
|
<h3 className="text-neutral-700 font-bold font-large">Address</h3>
|
||||||
<p className="text-neutral-600 font-medium">1 Swentown Row, Greenwich, London, SE3 0FQ</p>
|
<p className="text-neutral-600 font-medium">1 Sweentown Row, Greenwich, London, SE3 0FQ</p>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-bold text-neutral-800 mb-4 mt-6">Follow Us</h2>
|
<h2 className="text-xl font-bold text-neutral-800 mb-4 mt-6">Follow Us</h2>
|
||||||
<div className="flex justify-around items-center">
|
<div className="flex justify-around items-center">
|
||||||
<a href="#" className="w-20 h-20 text-blue-600 hover:text-blue-800 transition duration-200">
|
{/* Instagram: Lava Flood */}
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="w-20 h-20 text-blue-600 hover:text-blue-800 transition duration-200"
|
||||||
|
aria-label="Instagram"
|
||||||
|
onClick={handleInstagramClick}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
<span className="sr-only">Instagram</span>
|
<span className="sr-only">Instagram</span>
|
||||||
<Image height={200} width={200} alt="Logo" className="z-10" src="/insta.webp" />
|
<Image height={200} width={200} alt="Logo" className="z-10" src="/insta.webp" />
|
||||||
</a>
|
</a>
|
||||||
<a href="#" className="w-20 h-20 text-blue-600 hover:text-blue-800 transition duration-200">
|
{/* Facebook: Pulsating Map */}
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="w-20 h-20 text-blue-600 hover:text-blue-800 transition duration-200"
|
||||||
|
aria-label="Facebook"
|
||||||
|
onClick={handleFacebookClick}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
<span className="sr-only">Facebook</span>
|
<span className="sr-only">Facebook</span>
|
||||||
<Image height={200} width={200} alt="Logo" className="z-10" src="/facebook.webp" />
|
<Image height={200} width={200} alt="Logo" className="z-10" src="/facebook.webp" />
|
||||||
</a>
|
</a>
|
||||||
<a href="#" className="w-20 h-20 p-4 text-blue-600 hover:text-blue-800 transition duration-200">
|
{/* X: Crack & Collapse */}
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="w-20 h-20 p-4 text-blue-600 hover:text-blue-800 transition duration-200"
|
||||||
|
aria-label="X"
|
||||||
|
onClick={handleXClick}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
<span className="sr-only">X</span>
|
<span className="sr-only">X</span>
|
||||||
<Image height={200} width={200} alt="Logo" className="z-10 rounded-lg" src="/x_logo.jpg" />
|
<Image height={200} width={200} alt="Logo" className="z-10 rounded-lg" src="/x_logo.jpg" />
|
||||||
</a>
|
</a>
|
||||||
<a href="#" className="w-20 h-20 flex items-center text-blue-600 hover:text-blue-800 transition duration-200">
|
{/* LinkedIn: Shake */}
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="w-20 h-20 flex items-center text-blue-600 hover:text-blue-800 transition duration-200"
|
||||||
|
aria-label="LinkedIn"
|
||||||
|
onClick={handleLinkedInClick}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
<span className="sr-only">LinkedIn</span>
|
<span className="sr-only">LinkedIn</span>
|
||||||
<Image height={200} width={200} alt="Logo" className="z-10" src="/linkedIn.png" />
|
<Image height={200} width={200} alt="Logo" className="z-10" src="/linkedIn.png" />
|
||||||
</a>
|
</a>
|
||||||
@ -145,7 +288,7 @@ const ContactUs = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<BottomFooter />
|
<BottomFooter />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import Map from "@components/Map";
|
import Map from "@components/Map";
|
||||||
@ -8,152 +7,133 @@ import { createPoster } from "@utils/axiosHelpers";
|
|||||||
import { Earthquake } from "@prismaclient";
|
import { Earthquake } from "@prismaclient";
|
||||||
import { getRelativeDate } from "@utils/formatters";
|
import { getRelativeDate } from "@utils/formatters";
|
||||||
import GeologicalEvent from "@appTypes/Event";
|
import GeologicalEvent from "@appTypes/Event";
|
||||||
import axios from "axios";
|
import EarthquakeSearchModal from "@components/EarthquakeSearchModal";
|
||||||
|
import EarthquakeLogModal from "@components/EarthquakeLogModal"; // If you use a separate log modal
|
||||||
|
import { useStoreState } from "@hooks/store";
|
||||||
|
|
||||||
// todo (optional) add in filtering of map earthquakes
|
// Optional: "No Access Modal" - as in your original
|
||||||
|
function NoAccessModal({ open, onClose }) {
|
||||||
// --- SEARCH MODAL COMPONENT ---
|
if (!open) return null;
|
||||||
function EarthquakeSearchModal({ open, onClose, onSelect }) {
|
return (
|
||||||
const [search, setSearch] = useState("");
|
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
|
||||||
const [results, setResults] = useState([]);
|
<div className="bg-white rounded-lg shadow-lg p-8 max-w-xs w-full text-center relative">
|
||||||
const [loading, setLoading] = useState(false);
|
<button
|
||||||
|
onClick={onClose}
|
||||||
const handleSearch = async (e) => {
|
className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg"
|
||||||
e.preventDefault();
|
aria-label="Close"
|
||||||
setLoading(true);
|
>
|
||||||
setResults([]);
|
×
|
||||||
try {
|
</button>
|
||||||
const res = await axios.post("/api/earthquakes/search", { query: search });
|
<h2 className="font-bold text-xl mb-4">Access Denied</h2>
|
||||||
setResults(res.data.earthquakes || []);
|
<p className="text-gray-600 mb-3">
|
||||||
} catch (e) {
|
Sorry, you do not have access rights to Log an Earthquake. Please Log in here, or contact an Admin if you believe this is a mistake
|
||||||
alert("Failed to search.");
|
</p>
|
||||||
}
|
<button
|
||||||
setLoading(false);
|
onClick={onClose}
|
||||||
};
|
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 mt-2"
|
||||||
|
>OK</button>
|
||||||
if (!open) return null;
|
</div>
|
||||||
return (
|
</div>
|
||||||
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
|
);
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6 max-w-lg w-full relative">
|
|
||||||
<button onClick={onClose} className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg">
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
<h2 className="font-bold text-xl mb-4">Search Earthquakes</h2>
|
|
||||||
<form onSubmit={handleSearch} className="flex gap-2 mb-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g. Mexico, EV-7.4-Mexico-00035"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
className="flex-grow px-3 py-2 border rounded"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
|
|
||||||
{loading ? "Searching..." : "Search"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<div>
|
|
||||||
{results.length === 0 && !loading && search !== "" && <p className="text-gray-400 text-sm">No results found.</p>}
|
|
||||||
<ul>
|
|
||||||
{results.map((eq) => (
|
|
||||||
<li
|
|
||||||
key={eq.id}
|
|
||||||
className="border-b py-2 cursor-pointer hover:bg-gray-50 flex items-center justify-between"
|
|
||||||
onClick={() => {
|
|
||||||
onSelect(eq);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<strong>{eq.code}</strong> <span>{eq.location}</span>{" "}
|
|
||||||
<span className="text-xs text-gray-500">{new Date(eq.date).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`rounded-full px-2 py-1 ml-2 text-white font-semibold ${
|
|
||||||
eq.magnitude >= 7 ? "bg-red-500" : eq.magnitude >= 6 ? "bg-orange-400" : "bg-yellow-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{eq.magnitude}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- MAIN PAGE COMPONENT ---
|
|
||||||
export default function Earthquakes() {
|
export default function Earthquakes() {
|
||||||
const [selectedEventId, setSelectedEventId] = useState("");
|
const [selectedEventId, setSelectedEventId] = useState("");
|
||||||
const [hoveredEventId, setHoveredEventId] = useState("");
|
const [hoveredEventId, setHoveredEventId] = useState("");
|
||||||
|
const [searchModalOpen, setSearchModalOpen] = useState(false);
|
||||||
|
const [logModalOpen, setLogModalOpen] = useState(false);
|
||||||
|
const [noAccessModalOpen, setNoAccessModalOpen] = useState(false);
|
||||||
|
|
||||||
// Search modal state
|
// Your user/role logic
|
||||||
const [searchModalOpen, setSearchModalOpen] = useState(false);
|
const user = useStoreState((state) => state.user);
|
||||||
|
const role: "GUEST" | "SCIENTIST" | "ADMIN" = user?.role ?? "GUEST";
|
||||||
|
const canLogEarthquake = role === "SCIENTIST" || role === "ADMIN";
|
||||||
|
|
||||||
// Fetch recent earthquakes as before
|
// Fetch earthquakes (10 days recent)
|
||||||
const { data, error, isLoading } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 5 }));
|
const { data, error, isLoading, mutate } = useSWR(
|
||||||
|
"/api/earthquakes",
|
||||||
|
createPoster({ rangeDaysPrev: 10 })
|
||||||
|
);
|
||||||
|
|
||||||
// Prepare events for maps/sidebar
|
// Shape for Map/Sidebar
|
||||||
const earthquakeEvents = useMemo(
|
const earthquakeEvents = useMemo(
|
||||||
() =>
|
() =>
|
||||||
data && data.earthquakes
|
data && data.earthquakes
|
||||||
? data.earthquakes
|
? data.earthquakes
|
||||||
.map(
|
.map(
|
||||||
(x: Earthquake): GeologicalEvent => ({
|
(x: Earthquake): GeologicalEvent => ({
|
||||||
id: x.code,
|
id: x.code,
|
||||||
title: `Earthquake in ${x.code.split("-")[2]}`,
|
title: `Earthquake in ${x.location || (x.code && x.code.split("-")[2])}`,
|
||||||
magnitude: x.magnitude,
|
magnitude: x.magnitude,
|
||||||
longitude: x.longitude,
|
longitude: x.longitude,
|
||||||
latitude: x.latitude,
|
latitude: x.latitude,
|
||||||
text1: "",
|
text1: "",
|
||||||
text2: getRelativeDate(x.date),
|
text2: getRelativeDate(x.date),
|
||||||
date: x.date,
|
date: x.date,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.sort((a: GeologicalEvent, b: GeologicalEvent) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
.sort(
|
||||||
: [],
|
(a: GeologicalEvent, b: GeologicalEvent) =>
|
||||||
[data]
|
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
);
|
)
|
||||||
|
: [],
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
|
||||||
// Optional: show details of selected search result (not implemented here)
|
// Handler for log
|
||||||
// const [selectedSearchResult, setSelectedSearchResult] = useState(null);
|
const handleLogClick = () => {
|
||||||
|
if (canLogEarthquake) {
|
||||||
|
setLogModalOpen(true);
|
||||||
|
} else {
|
||||||
|
setNoAccessModalOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden">
|
<div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden">
|
||||||
<div className="flex-grow h-full">
|
<div className="flex-grow h-full">
|
||||||
<Map
|
<Map
|
||||||
events={earthquakeEvents}
|
events={earthquakeEvents}
|
||||||
selectedEventId={selectedEventId}
|
selectedEventId={selectedEventId}
|
||||||
setSelectedEventId={setSelectedEventId}
|
setSelectedEventId={setSelectedEventId}
|
||||||
hoveredEventId={hoveredEventId}
|
hoveredEventId={hoveredEventId}
|
||||||
setHoveredEventId={setHoveredEventId}
|
setHoveredEventId={setHoveredEventId}
|
||||||
mapType="Earthquakes"
|
mapType="Earthquakes"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
logTitle="Log an Earthquake"
|
logTitle="Log an Earthquake"
|
||||||
logSubtitle="Record new earthquakes - time/date, location, magnitude, observatory and scientists"
|
logSubtitle="Record new earthquakes - time/date, location, magnitude, observatory and scientists"
|
||||||
recentsTitle="Recent Earthquakes"
|
recentsTitle="Recent Earthquakes"
|
||||||
events={earthquakeEvents}
|
events={earthquakeEvents}
|
||||||
selectedEventId={selectedEventId}
|
selectedEventId={selectedEventId}
|
||||||
setSelectedEventId={setSelectedEventId}
|
setSelectedEventId={setSelectedEventId}
|
||||||
hoveredEventId={hoveredEventId}
|
hoveredEventId={hoveredEventId}
|
||||||
setHoveredEventId={setHoveredEventId}
|
setHoveredEventId={setHoveredEventId}
|
||||||
button1Name="Log an Earthquake"
|
button1Name="Log an Earthquake"
|
||||||
button2Name="Search Earthquakes"
|
button2Name="Search Earthquakes"
|
||||||
onButton2Click={() => setSearchModalOpen(true)} // <-- important!
|
onButton1Click={handleLogClick}
|
||||||
/>
|
onButton2Click={() => setSearchModalOpen(true)}
|
||||||
<EarthquakeSearchModal
|
button1Disabled={!canLogEarthquake}
|
||||||
open={searchModalOpen}
|
/>
|
||||||
onClose={() => setSearchModalOpen(false)}
|
{/* ---- SEARCH MODAL ---- */}
|
||||||
onSelect={(eq) => {
|
<EarthquakeSearchModal
|
||||||
setSelectedEventId(eq.code); // select on map/sidebar
|
open={searchModalOpen}
|
||||||
// setSelectedSearchResult(eq); // you can use this if you want to show detail modal
|
onClose={() => setSearchModalOpen(false)}
|
||||||
}}
|
onSelect={(eq) => setSelectedEventId(eq.code)}
|
||||||
/>
|
/>
|
||||||
</div>
|
{/* ---- LOGGING MODAL ---- */}
|
||||||
);
|
<EarthquakeLogModal
|
||||||
|
open={logModalOpen}
|
||||||
|
onClose={() => setLogModalOpen(false)}
|
||||||
|
onSuccess={() => mutate()}
|
||||||
|
/>
|
||||||
|
{/* ---- NO ACCESS ---- */}
|
||||||
|
<NoAccessModal
|
||||||
|
open={noAccessModalOpen}
|
||||||
|
onClose={() => setNoAccessModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@ -1,25 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { prisma } from "@utils/prisma";
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
try {
|
|
||||||
const { query } = await req.json();
|
|
||||||
|
|
||||||
// Find earthquakes where either code or location matches (case-insensitive)
|
|
||||||
const earthquakes = await prisma.earthquake.findMany({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ code: { contains: query, mode: "insensitive" } },
|
|
||||||
{ location: { contains: query, mode: "insensitive" } }
|
|
||||||
],
|
|
||||||
},
|
|
||||||
orderBy: { date: "desc" },
|
|
||||||
take: 20, // limit results
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ earthquakes, message: "Success" });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error in earthquake search", error);
|
|
||||||
return NextResponse.json({ message: "Internal Server Error" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -65,3 +65,166 @@ body {
|
|||||||
color: #111;
|
color: #111;
|
||||||
/* or black */
|
/* or black */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- LAVA FLOOD OVERLAY ---- */
|
||||||
|
.lava-flood-overlay {
|
||||||
|
pointer-events: none;
|
||||||
|
position: fixed;
|
||||||
|
top: -100vh;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
transition: top 0.9s cubic-bezier(.6, 0, .2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lava-flood-overlay.lava-active {
|
||||||
|
top: 0;
|
||||||
|
transition: top 0.33s cubic-bezier(.6, 0, .2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lava-flood-overlay img,
|
||||||
|
.lava-gradient {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
object-fit: cover;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
filter: brightness(1.15) saturate(1.8) drop-shadow(0 0 80px #ff5500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- INSANE SCREEN SHAKE (LinkedIn) ---- */
|
||||||
|
@keyframes supershake {
|
||||||
|
0% {
|
||||||
|
transform: translate(0, 0) rotate(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
5% {
|
||||||
|
transform: translate(-20px, 5px) rotate(-2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
10% {
|
||||||
|
transform: translate(18px, -8px) rotate(2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
15% {
|
||||||
|
transform: translate(-22px, 8px) rotate(-4deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
20% {
|
||||||
|
transform: translate(22px, -2px) rotate(4deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
25% {
|
||||||
|
transform: translate(-18px, 12px) rotate(-2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
30% {
|
||||||
|
transform: translate(18px, -10px) rotate(2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
35% {
|
||||||
|
transform: translate(-22px, 14px) rotate(-4deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
40% {
|
||||||
|
transform: translate(22px, -12px) rotate(4deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
45% {
|
||||||
|
transform: translate(-18px, 8px) rotate(-2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translate(18px, -14px) rotate(4deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
55% {
|
||||||
|
transform: translate(-22px, 12px) rotate(-4deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
60% {
|
||||||
|
transform: translate(22px, -8px) rotate(2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
65% {
|
||||||
|
transform: translate(-18px, 10px) rotate(-2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
70% {
|
||||||
|
transform: translate(18px, -12px) rotate(2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
75% {
|
||||||
|
transform: translate(-22px, 14px) rotate(-4deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
80% {
|
||||||
|
transform: translate(22px, -10px) rotate(4deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
85% {
|
||||||
|
transform: translate(-18px, 8px) rotate(-2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
90% {
|
||||||
|
transform: translate(18px, -14px) rotate(2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
95% {
|
||||||
|
transform: translate(-20px, 5px) rotate(-2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translate(0, 0) rotate(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shake-screen {
|
||||||
|
animation: supershake 1s cubic-bezier(.36, .07, .19, .97) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- CRACK + COLLAPSE OVERLAY (X icon) ---- */
|
||||||
|
.crack-overlay {
|
||||||
|
pointer-events: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 9999;
|
||||||
|
transition: transform 1.1s cubic-bezier(.65, .05, .45, 1), opacity 0.5s;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crack {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crack1 {
|
||||||
|
width: 35vw;
|
||||||
|
left: 10vw;
|
||||||
|
top: 22vh;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crack2 {
|
||||||
|
width: 32vw;
|
||||||
|
right: 12vw;
|
||||||
|
top: 42vh;
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: rotate(-8deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add more .crackN classes if using more cracks */
|
||||||
|
|
||||||
|
/* Collapse falling effect */
|
||||||
|
.crack-collapse {
|
||||||
|
transform: perspective(900px) rotateX(75deg) translateY(80vh) scale(0.9);
|
||||||
|
opacity: 0;
|
||||||
|
transition: transform 1.1s cubic-bezier(.65, .05, .45, 1), opacity 0.6s;
|
||||||
|
}
|
||||||
@ -55,7 +55,7 @@ export default function LearnPage() {
|
|||||||
<li>First aid kit and emergency medication</li>
|
<li>First aid kit and emergency medication</li>
|
||||||
<li>Food (non-perishable)</li>
|
<li>Food (non-perishable)</li>
|
||||||
<li>Bottled water</li>
|
<li>Bottled water</li>
|
||||||
<li>Torch (flashlight)</li>
|
<li>Torch</li>
|
||||||
<li>Satellite phone</li>
|
<li>Satellite phone</li>
|
||||||
<li>Warm clothing and blankets</li>
|
<li>Warm clothing and blankets</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -1,70 +1,116 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
import Sidebar from "@/components/Sidebar";
|
import Sidebar from "@/components/Sidebar";
|
||||||
import Map from "@components/Map";
|
import Map from "@components/Map";
|
||||||
|
import LogObservatoryModal from "@/components/LogObservatoryModal"; // Adjust if your path is different
|
||||||
import { fetcher } from "@utils/axiosHelpers";
|
import { fetcher } from "@utils/axiosHelpers";
|
||||||
import { Observatory } from "@prismaclient";
|
import { Observatory } from "@prismaclient";
|
||||||
import { getRelativeDate } from "@utils/formatters";
|
import { getRelativeDate } from "@utils/formatters";
|
||||||
import GeologicalEvent from "@appTypes/Event";
|
import GeologicalEvent from "@appTypes/Event";
|
||||||
|
import { useStoreState } from "@hooks/store";
|
||||||
|
|
||||||
// todo add in showing of observatory stats when searching
|
function NoAccessModal({ open, onClose }) {
|
||||||
// todo add in deleting observatory when searching
|
if (!open) return null;
|
||||||
// todo add in changing colour of observatory icons if non-functional
|
return (
|
||||||
|
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-8 max-w-xs w-full text-center relative">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg"
|
||||||
|
aria-label="Close"
|
||||||
|
>×</button>
|
||||||
|
<h2 className="font-bold text-xl mb-4">No Access</h2>
|
||||||
|
<p className="text-gray-600 mb-3">Sorry, You do not have access rights, please log in or contact an Admin.</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 mt-2"
|
||||||
|
>OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Observatories() {
|
export default function Observatories() {
|
||||||
const [selectedEventId, setSelectedEventId] = useState("");
|
const [selectedEventId, setSelectedEventId] = useState("");
|
||||||
const [hoveredEventId, setHoveredEventId] = useState("");
|
const [hoveredEventId, setHoveredEventId] = useState("");
|
||||||
const { data, error, isLoading } = useSWR("/api/observatories", fetcher);
|
const [logModalOpen, setLogModalOpen] = useState(false);
|
||||||
|
const [noAccessModalOpen, setNoAccessModalOpen] = useState(false);
|
||||||
|
|
||||||
// todo add in earthquake events
|
const user = useStoreState((state) => state.user);
|
||||||
const observatoryEvents = useMemo(
|
const role: "GUEST" | "SCIENTIST" | "ADMIN" = user?.role ?? "GUEST";
|
||||||
() =>
|
const canLogObservatory = role === "SCIENTIST" || role === "ADMIN";
|
||||||
data && data.observatories
|
|
||||||
? data.observatories
|
|
||||||
.map(
|
|
||||||
(x: Observatory): GeologicalEvent => ({
|
|
||||||
id: x.id.toString(),
|
|
||||||
title: `New Observatory - ${x.name}`,
|
|
||||||
longitude: x.longitude,
|
|
||||||
latitude: x.latitude,
|
|
||||||
text1: "",
|
|
||||||
text2: getRelativeDate(x.dateEstablished),
|
|
||||||
date: x.dateEstablished,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.sort((a: GeologicalEvent, b: GeologicalEvent) => new Date(b.date).getTime() - new Date(a.date).getTime()) // Remove Date conversion
|
|
||||||
: [],
|
|
||||||
[data]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
const { data, error, isLoading, mutate } = useSWR(
|
||||||
<div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden">
|
"/api/observatories",
|
||||||
<div className="flex-grow h-full">
|
fetcher
|
||||||
<Map
|
);
|
||||||
events={observatoryEvents}
|
|
||||||
selectedEventId={selectedEventId}
|
const observatoryEvents = useMemo(
|
||||||
setSelectedEventId={setSelectedEventId}
|
() =>
|
||||||
hoveredEventId={hoveredEventId}
|
data && data.observatories
|
||||||
setHoveredEventId={setHoveredEventId}
|
? data.observatories
|
||||||
mapType="observatories"
|
.map((x: Observatory): GeologicalEvent & { isFunctional: boolean } => ({
|
||||||
/>
|
id: x.id.toString(),
|
||||||
</div>
|
title: ` ${x.name}`,
|
||||||
<Sidebar
|
longitude: x.longitude,
|
||||||
logTitle="Observatory Mapping"
|
latitude: x.latitude,
|
||||||
logSubtitle="Record and search observatories - time/date set-up, location, scientists and recent earthquakes"
|
isFunctional: x.isFunctional, // <-- include this!
|
||||||
recentsTitle="Observatory Events"
|
text1: "",
|
||||||
events={observatoryEvents}
|
text2: getRelativeDate(x.dateEstablished),
|
||||||
selectedEventId={selectedEventId}
|
date: x.dateEstablished,
|
||||||
setSelectedEventId={setSelectedEventId}
|
}))
|
||||||
hoveredEventId={hoveredEventId}
|
.sort(
|
||||||
setHoveredEventId={setHoveredEventId}
|
(a: GeologicalEvent, b: GeologicalEvent) =>
|
||||||
button1Name="Log a New Observatory"
|
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
button2Name="Search Observatories"
|
)
|
||||||
/>
|
: [],
|
||||||
</div>
|
[data]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleLogClick = () => {
|
||||||
|
if (canLogObservatory) {
|
||||||
|
setLogModalOpen(true);
|
||||||
|
} else {
|
||||||
|
setNoAccessModalOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden">
|
||||||
|
<div className="flex-grow h-full">
|
||||||
|
<Map
|
||||||
|
events={observatoryEvents}
|
||||||
|
selectedEventId={selectedEventId}
|
||||||
|
setSelectedEventId={setSelectedEventId}
|
||||||
|
hoveredEventId={hoveredEventId}
|
||||||
|
setHoveredEventId={setHoveredEventId}
|
||||||
|
mapType="observatories"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Sidebar
|
||||||
|
logTitle="Observatory Mapping"
|
||||||
|
logSubtitle="Record and search observatories - time/date set-up, location, scientists and recent earthquakes"
|
||||||
|
recentsTitle="New Observatories"
|
||||||
|
events={observatoryEvents}
|
||||||
|
selectedEventId={selectedEventId}
|
||||||
|
setSelectedEventId={setSelectedEventId}
|
||||||
|
hoveredEventId={hoveredEventId}
|
||||||
|
setHoveredEventId={setHoveredEventId}
|
||||||
|
button1Name="Log a New Observatory"
|
||||||
|
button2Name="Search Observatories"
|
||||||
|
onButton1Click={handleLogClick}
|
||||||
|
button1Disabled={!canLogObservatory}
|
||||||
|
/>
|
||||||
|
<LogObservatoryModal
|
||||||
|
open={logModalOpen}
|
||||||
|
onClose={() => setLogModalOpen(false)}
|
||||||
|
onSuccess={() => mutate()}
|
||||||
|
/>
|
||||||
|
<NoAccessModal
|
||||||
|
open={noAccessModalOpen}
|
||||||
|
onClose={() => setNoAccessModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import BottomFooter from "@components/BottomFooter";
|
||||||
|
|
||||||
function OurMission() {
|
function OurMission() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative h-full bg-fixed bg-cover bg-center text-white "
|
className="relative bg-fixed bg-cover bg-center text-white "
|
||||||
style={{ backgroundImage: "url('destruction.jpg')", overflow: "hidden" }}
|
style={{ backgroundImage: "url('destruction.jpg')", overflow: "hidden" }}
|
||||||
>
|
>
|
||||||
{/* Overlay for Readability */}
|
{/* Overlay for Readability */}
|
||||||
<div className="absolute inset-0 bg-black bg-opacity-50"></div>
|
{/*<div className="absolute inset-0 bg-black bg-opacity-50"></div>*/}
|
||||||
{/* Centered content */}
|
{/* Centered content */}
|
||||||
<div className="relative z-20 flex flex-col items-center justify-center h-full py-auto">
|
<div className="relative z-20 flex flex-col items-center justify-center py-auto">
|
||||||
{/* Title & Mission Statement */}
|
{/* Title & Mission Statement */}
|
||||||
<div className="mb-10 flex flex-col items-center">
|
<div className="mb-10 flex flex-col items-center">
|
||||||
<h1 className="text-4xl font-bold text-center tracking-tight mb-2 drop-shadow-lg">Our Mission</h1>
|
<h1 className="text-4xl font-bold text-center tracking-tight mb-2 drop-shadow-lg text-black">Our Mission</h1>
|
||||||
<p className="text-lg text-center max-w-2xl text-white drop-shadow-md">Earthquake awareness accessible for everyone</p>
|
<p className="text-lg text-center max-w-2xl text-gray-800 drop-shadow-md">Earthquake awareness accessible for everyone</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Content Area */}
|
{/* Content Area */}
|
||||||
<div className="max-w-5xl w-full p-8 bg-white bg-opacity-90 shadow-xl rounded-3xl">
|
<div className="max-w-5xl w-full p-8 bg-white bg-opacity-90 shadow-xl rounded-3xl">
|
||||||
@ -23,6 +24,15 @@ function OurMission() {
|
|||||||
and why earthquakes occur to enable better preparation and recovery. Education, cutting-edge research, and innovative
|
and why earthquakes occur to enable better preparation and recovery. Education, cutting-edge research, and innovative
|
||||||
technology combine together.
|
technology combine together.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<Image
|
||||||
|
src="/logo.png"
|
||||||
|
width={100} // Adjust as needed
|
||||||
|
height={100} // Adjust as needed
|
||||||
|
alt="Tremor Tracker Logo"
|
||||||
|
className="h-200 w-200 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<p className="text-xl text-black leading-relaxed mb-8 max-w-3xl mx-auto">
|
<p className="text-xl text-black leading-relaxed mb-8 max-w-3xl mx-auto">
|
||||||
We combine scientific insights with public awareness, delivering resources, tools, and real-time updates to enhance
|
We combine scientific insights with public awareness, delivering resources, tools, and real-time updates to enhance
|
||||||
preparedness for earthquakes in order to save lives, improve recovery efficiency, and build resilience against seismic
|
preparedness for earthquakes in order to save lives, improve recovery efficiency, and build resilience against seismic
|
||||||
@ -53,6 +63,7 @@ function OurMission() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<BottomFooter />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
441
src/app/page.tsx
@ -1,241 +1,214 @@
|
|||||||
|
"use client";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { TbHexagon } from "react-icons/tb";
|
||||||
|
import useSWR from "swr";
|
||||||
import BottomFooter from "@components/BottomFooter";
|
import BottomFooter from "@components/BottomFooter";
|
||||||
|
import { createPoster } from "@utils/axiosHelpers";
|
||||||
|
import getMagnitudeColor from "@utils/getMagnitudeColour";
|
||||||
|
|
||||||
|
// formats the date
|
||||||
|
function getRelativeDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
if (diffDays === 0) return "today";
|
||||||
|
if (diffDays === 1) return "yesterday";
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// copied from sidebar
|
||||||
|
function MagnitudeNumber({ magnitude }: { magnitude: number }) {
|
||||||
|
const magnitudeStr = magnitude.toFixed(1);
|
||||||
|
const [whole, decimal] = magnitudeStr.split(".");
|
||||||
|
return (
|
||||||
|
<div className="relative" style={{ color: getMagnitudeColor(magnitude) }}>
|
||||||
|
<TbHexagon size={40} className="drop-shadow-sm" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="flex items-baseline font-mono font-bold tracking-tight">
|
||||||
|
<span className="text-xl -mr-1">{whole}</span>
|
||||||
|
<span className="text-xs ml-[1.5px] -mr-[2.5px]">.</span>
|
||||||
|
<span className="text-xs -mr-[1px]">{decimal}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
const { data, error, isLoading } = useSWR(
|
||||||
<main className="min-h-screen text-black">
|
"/api/earthquakes",
|
||||||
<div className="w-full relative">
|
createPoster({ rangeDaysPrev: 6 })
|
||||||
<div className="">
|
);
|
||||||
<Image height={2000} width={2000} alt="Background Image" src="/lava_flows.jpg"></Image>
|
// Take 5 most recent
|
||||||
</div>
|
const recents = (data?.earthquakes ?? [])
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-black/10 to-black/40"></div>
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||||
<div className="absolute inset-0 top-[30%]">
|
.slice(0, 5);
|
||||||
<Image className="mx-auto" height={300} width={1000} alt="Title Image" src="/tremortrackertext.png"></Image>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2"></p>
|
|
||||||
<div className="flex flex-col md:flex-row md:justify-evenly gap-6 mt-2">
|
|
||||||
<Link href="/earthquakes" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
|
|
||||||
<Image height={100} width={100} src="/earthquake.png" alt="Education Icon" className="h-40 w-40 mb-4" />
|
|
||||||
<h3 className="text-xl font-bold text-black mb-4">Earthquakes</h3>
|
|
||||||
<p className="text-md text-black text-center max-w-xs opacity-90">
|
|
||||||
Log new earthquakes with their required details or search past seismic events
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/observatories"
|
|
||||||
className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300"
|
|
||||||
>
|
|
||||||
<Image height={100} width={100} src="/observatory.jpg" alt="Research Icon" className="h-40 w-40 mb-4" />
|
|
||||||
<h3 className="text-xl font-bold text-black mb-4">Observatories</h3>
|
|
||||||
<p className="text-md text-black text-center max-w-xs opacity-90">
|
|
||||||
Find recently active observatories, and newly opened/closed sites
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
<Link href="/shop" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
|
|
||||||
<Image height={100} width={100} src="/artifactIcon.jpg" alt="Technology Icon" className="h-40 w-40 mb-4" />
|
|
||||||
<h3 className="text-xl font-bold text-black mb-4">Artefacts</h3>
|
|
||||||
<p className="text-md text-black text-center max-w-xs opacity-90">
|
|
||||||
View or purchase recently discovered artefacts from seismic events
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<p className="mt-18"></p>
|
|
||||||
<section className="min-h-screen text-black">
|
|
||||||
<div className="w-full relative z-40">
|
|
||||||
<div className="">
|
|
||||||
<Image height={2500} width={2000} alt="Background Image" src="/earthquakesMap.jpg"></Image>
|
|
||||||
</div>
|
|
||||||
<div className="border absolute top-0 inset-0 bg-gradient-to-b from-transparent via-black/10 to-black/40">
|
|
||||||
<section className="relative z-10 flex flex-col items-center text-center w-full px-4 py-14 mt-6">
|
|
||||||
<h1 className="text-4xl md:text-5xl font-sans font-bold text-white drop-shadow-lg mb-4 tracking-tight z-10">
|
|
||||||
Welcome to Tremor Tracker
|
|
||||||
</h1>
|
|
||||||
<p className="text-lg md:text-xl font-sans text-white w-4/6 mx-auto drop-shadow-md z-10">
|
|
||||||
TremorTracker is a non-profit website and research company, that aims to provide true, reliable data. Our mission
|
|
||||||
is seismic education and preparation for all
|
|
||||||
</p>
|
|
||||||
<p className="mt-20"></p>
|
|
||||||
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">What is an earthquake?</p>
|
|
||||||
<p className="text-lg md:text-xl text-white w-4/6 mx-auto drop-shadow-md z-10">
|
|
||||||
Earthquakes are a shaking of the earth's surface caused by a sudden release of energy underground. They can range
|
|
||||||
in size, from tiny trembles to large quakes, which can cause destruction and even tsunamis. Hundreds of
|
|
||||||
earthquakes happen every day—but most are too small to feel.
|
|
||||||
</p>
|
|
||||||
<p className="mt-20"></p>
|
|
||||||
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">
|
|
||||||
How do we log earthquakes?
|
|
||||||
</p>
|
|
||||||
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">
|
|
||||||
What information are we interested in?
|
|
||||||
</p>
|
|
||||||
<p className="text-lg md:text-xl text-white w-4/6 mx-auto drop-shadow-md z-10">info</p>
|
|
||||||
<p className="mt-20"></p>
|
|
||||||
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">
|
|
||||||
What are observatories?
|
|
||||||
</p>
|
|
||||||
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">What is their role?</p>
|
|
||||||
<p className="text-lg md:text-xl text-white w-4/6 mx-auto drop-shadow-md z-10">info</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<p className="mt-20"></p>
|
|
||||||
<section className="relative z-10 flex flex-col items-start text-left w-5/6 mx-auto px-2 -mt-5 mb-2">
|
|
||||||
<h1 className="text-3xl md:text-3xl font-bold text-black drop-shadow-lg mb-3 tracking-tight">Recent Earthquake Events</h1>
|
|
||||||
<p className="text-lg md:text-xl text-black drop-shadow-md">
|
|
||||||
Learn about the most recent earthquake events from around the world:
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
<p className="mt-6"></p>
|
|
||||||
<div className="mx-auto w-5/6 px-2 border border-black divide-y bg-white bg-opacity-90 rounded-xl shadow-md">
|
|
||||||
{["Earthquake 1", "Earthquake 2", "Earthquake 3", "Earthquake 4", "Earthquake 5"].map((name) => (
|
|
||||||
<div className="px-5 py-5" key={name}>
|
|
||||||
<p className="ml-3">{name}</p>
|
|
||||||
<p></p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="mt-20"></p>
|
|
||||||
<section className="relative z-10 flex flex-col items-start text-left w-5/6 mx-auto px-2 -mt-5 mb-2">
|
|
||||||
<h1 className="text-3xl md:text-3xl font-bold text-black drop-shadow-lg mb-3 tracking-tight">Contact Information</h1>
|
|
||||||
<p className="text-lg md:text-xl text-black drop-shadow-md">
|
|
||||||
Learn about Tremor Tracker's mission, our team or contact us directly:
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
<p className="mt-2"></p>
|
|
||||||
<div className="flex flex-col md:flex-row md:justify-evenly gap-6 mt-2">
|
|
||||||
<Link href="/contact-us" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
|
|
||||||
<Image height={100} width={100} src="/contactUs.jpg" alt="Education Icon" className="h-20 w-20 mb-4" />
|
|
||||||
<h3 className="text-xl font-bold text-black mb-4">Contact us directly</h3>
|
|
||||||
<p className="text-md text-black text-center max-w-xs opacity-90">
|
|
||||||
Visit our socials or leave us a message via phone or email.
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
<Link href="/our-mission" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
|
|
||||||
<Image height={100} width={100} src="/mission.jpg" alt="Research Icon" className="h-20 w-20 mb-4" />
|
|
||||||
<h3 className="text-xl font-bold text-black mb-4">Our Mission</h3>
|
|
||||||
<p className="text-md text-black text-center max-w-xs opacity-90">
|
|
||||||
Find out more about our purpose and the features we offer.
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
<Link href="/the-team" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
|
|
||||||
<Image height={100} width={100} src="/team.jpg" alt="Technology Icon" className="h-20 w-20 mb-4" />
|
|
||||||
<h3 className="text-xl font-bold text-black mb-4">Meet the Team</h3>
|
|
||||||
<p className="text-md text-black text-center max-w-xs opacity-90">
|
|
||||||
Learn about our team leads and their responsibilities.
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<p className="mt-10"></p>
|
|
||||||
<section style={{ height: 500 }} className="text-black">
|
|
||||||
<div className="w-full relative overflow-hidden z=10">
|
|
||||||
<div className="">
|
|
||||||
<Image height={1000} width={2000} alt="Background Image" src="/scientists.png"></Image>
|
|
||||||
</div>
|
|
||||||
<BottomFooter />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
|
|
||||||
// return (
|
return (
|
||||||
// <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
<main className="min-h-screen text-black">
|
||||||
// <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
<div className="w-full relative">
|
||||||
// <Image
|
<div>
|
||||||
// className="dark:invert"
|
<Image height={2000} width={2000} alt="Background Image" src="/lava_flows.jpg" />
|
||||||
// src="/next.svg"
|
</div>
|
||||||
// alt="Next.js logo"
|
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-black/10 to-black/40"></div>
|
||||||
// width={180}
|
<div className="absolute inset-0 top-[30%]">
|
||||||
// height={38}
|
<Image className="mx-auto" height={300} width={1000} alt="Title Image" src="/tremortrackertext.png" />
|
||||||
// priority
|
</div>
|
||||||
// />
|
</div>
|
||||||
// <ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
<p className="mt-2"></p>
|
||||||
// <li className="mb-2">
|
<div className="flex flex-col md:flex-row md:justify-evenly gap-6 mt-2">
|
||||||
// Get started by editing{" "}
|
<Link href="/earthquakes" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
|
||||||
// <code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
|
<Image height={100} width={100} src="/earthquake.png" alt="Education Icon" className="h-40 w-40 mb-4" />
|
||||||
// src/app/page.tsx
|
<h3 className="text-xl font-bold text-black mb-4">Earthquakes</h3>
|
||||||
// </code>
|
<p className="text-md text-black text-center max-w-xs opacity-90">
|
||||||
// .
|
Log new earthquakes with their required details or search past seismic events
|
||||||
// </li>
|
</p>
|
||||||
// <li>Save and see your changes instantly.</li>
|
</Link>
|
||||||
// </ol>
|
<Link
|
||||||
|
href="/observatories"
|
||||||
// <div className="flex gap-4 items-center flex-col sm:flex-row">
|
className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300"
|
||||||
// <a
|
>
|
||||||
// className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
<Image height={100} width={100} src="/observe.png" alt="Research Icon" className="h-40 w-40 mb-4" />
|
||||||
// href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<h3 className="text-xl font-bold text-black mb-4">Observatories</h3>
|
||||||
// target="_blank"
|
<p className="text-md text-black text-center max-w-xs opacity-90">
|
||||||
// rel="noopener noreferrer"
|
Find recently active observatories, and newly opened/closed sites
|
||||||
// >
|
</p>
|
||||||
// <Image
|
</Link>
|
||||||
// className="dark:invert"
|
<Link href="/shop" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
|
||||||
// src="/vercel.svg"
|
<Image height={100} width={100} src="/artefact.png" alt="Technology Icon" className="h-40 w-40 mb-4" />
|
||||||
// alt="Vercel logomark"
|
<h3 className="text-xl font-bold text-black mb-4">Artefacts</h3>
|
||||||
// width={20}
|
<p className="text-md text-black text-center max-w-xs opacity-90">
|
||||||
// height={20}
|
View or purchase recently discovered artefacts from seismic events
|
||||||
// />
|
</p>
|
||||||
// Deploy now
|
</Link>
|
||||||
// </a>
|
</div>
|
||||||
// <a
|
<p className="mt-18"></p>
|
||||||
// className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
<section className="min-h-screen text-black">
|
||||||
// href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<div className="w-full relative z-40">
|
||||||
// target="_blank"
|
<div>
|
||||||
// rel="noopener noreferrer"
|
<Image height={2500} width={2000} alt="Background Image" src="/earthquakesMap.jpg" />
|
||||||
// >
|
</div>
|
||||||
// Read our docs
|
<div className="border absolute top-0 inset-0 bg-gradient-to-b from-transparent via-black/10 to-black/40">
|
||||||
// </a>
|
<section className="relative z-10 flex flex-col items-center text-center w-full px-4 py-14 mt-6">
|
||||||
// </div>
|
<h1 className="text-4xl md:text-5xl font-sans font-bold text-white drop-shadow-lg mb-4 tracking-tight z-10">
|
||||||
// </main>
|
Welcome to Tremor Tracker
|
||||||
// <footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
</h1>
|
||||||
// <a
|
<p className="text-lg md:text-xl font-sans text-white w-4/6 mx-auto drop-shadow-md z-10">
|
||||||
// className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
TremorTracker is a non-profit website and research company, that aims to provide true, reliable data. Our mission
|
||||||
// href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
is seismic education and preparation for all
|
||||||
// target="_blank"
|
</p>
|
||||||
// rel="noopener noreferrer"
|
<p className="mt-20"></p>
|
||||||
// >
|
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">What is an earthquake?</p>
|
||||||
// <Image
|
<p className="text-lg md:text-xl text-white w-4/6 mx-auto drop-shadow-md z-10">
|
||||||
// aria-hidden
|
Earthquakes are a shaking of the earth's surface caused by a sudden release of energy underground. They can range
|
||||||
// src="/file.svg"
|
in size, from tiny trembles to large quakes, which can cause destruction and even tsunamis. Hundreds of
|
||||||
// alt="File icon"
|
earthquakes happen every day—but most are too small to feel.
|
||||||
// width={16}
|
</p>
|
||||||
// height={16}
|
<p className="mt-20"></p>
|
||||||
// />
|
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">
|
||||||
// Learn
|
How do we log earthquakes?
|
||||||
// </a>
|
</p>
|
||||||
// <a
|
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">
|
||||||
// className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
What information are we interested in?
|
||||||
// href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
</p>
|
||||||
// target="_blank"
|
<p className="text-lg md:text-xl text-white w-4/6 mx-auto drop-shadow-md z-10">info</p>
|
||||||
// rel="noopener noreferrer"
|
<p className="mt-20"></p>
|
||||||
// >
|
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">
|
||||||
// <Image
|
What are observatories?
|
||||||
// aria-hidden
|
</p>
|
||||||
// src="/window.svg"
|
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">What is their role?</p>
|
||||||
// alt="Window icon"
|
<p className="text-lg md:text-xl text-white w-4/6 mx-auto drop-shadow-md z-10">info</p>
|
||||||
// width={16}
|
</section>
|
||||||
// height={16}
|
</div>
|
||||||
// />
|
</div>
|
||||||
// Examples
|
</section>
|
||||||
// </a>
|
<p className="mt-20"></p>
|
||||||
// <a
|
<section className="relative z-10 flex flex-col items-start text-left w-5/6 mx-auto px-2 -mt-5 mb-2">
|
||||||
// className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
<h1 className="text-3xl md:text-3xl font-bold text-black drop-shadow-lg mb-3 tracking-tight">
|
||||||
// href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
Recent Earthquake Events
|
||||||
// target="_blank"
|
</h1>
|
||||||
// rel="noopener noreferrer"
|
<p className="text-lg md:text-xl text-black drop-shadow-md">
|
||||||
// >
|
Learn about the most recent earthquake events from around the world:
|
||||||
// <Image
|
</p>
|
||||||
// aria-hidden
|
</section>
|
||||||
// src="/globe.svg"
|
<p className="mt-6"></p>
|
||||||
// alt="Globe icon"
|
<div className="mx-auto w-5/6 px-2">
|
||||||
// width={16}
|
{error && (
|
||||||
// height={16}
|
<div className="border rounded-xl bg-white bg-opacity-90 shadow-md p-4 mb-2">
|
||||||
// />
|
<p>Failed to load earthquakes.</p>
|
||||||
// Go to nextjs.org →
|
</div>
|
||||||
// </a>
|
)}
|
||||||
// </footer>
|
{isLoading && (
|
||||||
// </div>
|
<div className="border rounded-xl bg-white bg-opacity-90 shadow-md p-4 mb-2">
|
||||||
// );
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && recents.length === 0 && (
|
||||||
|
<div className="border rounded-xl bg-white bg-opacity-90 shadow-md p-4 mb-2">
|
||||||
|
<p>No earthquakes found.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{recents.map((eq) => (
|
||||||
|
<div
|
||||||
|
key={eq.code}
|
||||||
|
className="flex items-center justify-between p-4 bg-white rounded-xl shadow border"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">
|
||||||
|
Earthquake in {eq.location || (eq.code && eq.code.split("-")[2])}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">{getRelativeDate(eq.date)}</div>
|
||||||
|
</div>
|
||||||
|
<MagnitudeNumber magnitude={eq.magnitude} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-20"></p>
|
||||||
|
<section className="relative z-10 flex flex-col items-start text-left w-5/6 mx-auto px-2 -mt-5 mb-2">
|
||||||
|
<h1 className="text-3xl md:text-3xl font-bold text-black drop-shadow-lg mb-3 tracking-tight">
|
||||||
|
Find Out More!
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg md:text-xl text-black drop-shadow-md">
|
||||||
|
Explore more of our website...
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<p className="mt-2"></p>
|
||||||
|
<div className="flex flex-col md:flex-row md:justify-evenly gap-6 mt-2">
|
||||||
|
<Link href="/contact-us" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
|
||||||
|
<Image height={100} width={100} src="/contactUs.jpg" alt="Education Icon" className="h-20 w-20 mb-4" />
|
||||||
|
<h3 className="text-xl font-bold text-black mb-4">Contact us directly</h3>
|
||||||
|
<p className="text-md text-black text-center max-w-xs opacity-90">
|
||||||
|
Visit our socials or leave us a message via phone or email.
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
<Link href="/our-mission" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
|
||||||
|
<Image height={100} width={100} src="/mission.jpg" alt="Research Icon" className="h-20 w-20 mb-4" />
|
||||||
|
<h3 className="text-xl font-bold text-black mb-4">Our Mission</h3>
|
||||||
|
<p className="text-md text-black text-center max-w-xs opacity-90">
|
||||||
|
Find out more about our purpose and the features we offer.
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
<Link href="/the-team" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
|
||||||
|
<Image height={100} width={100} src="/team.jpg" alt="Technology Icon" className="h-20 w-20 mb-4" />
|
||||||
|
<h3 className="text-xl font-bold text-black mb-4">Meet the Team</h3>
|
||||||
|
<p className="text-md text-black text-center max-w-xs opacity-90">
|
||||||
|
Learn about our team leads and their responsibilities.
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<p className="mt-10"></p>
|
||||||
|
<section style={{ height: 500 }} className="text-black">
|
||||||
|
<div className="w-full relative overflow-hidden z=10">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Image height={400} width={800} alt="Background Image" src="/team.PNG" />
|
||||||
|
</div>
|
||||||
|
<BottomFooter />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@ -1,43 +1,41 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { ExtendedArtefact } from "@appTypes/ApiTypes";
|
import { ExtendedArtefact } from "@appTypes/ApiTypes";
|
||||||
import { Currency } from "@appTypes/StoreModel";
|
import { Currency } from "@appTypes/StoreModel";
|
||||||
import BottomFooter from "@components/BottomFooter";
|
import BottomFooter from "@components/BottomFooter";
|
||||||
import { useStoreState } from "@hooks/store";
|
import { useStoreState } from "@hooks/store";
|
||||||
|
|
||||||
// todo hide from shop after purchase
|
|
||||||
|
|
||||||
export default function Shop() {
|
export default function Shop() {
|
||||||
const [artefacts, setArtefacts] = useState<ExtendedArtefact[]>([]);
|
const [artefacts, setArtefacts] = useState<ExtendedArtefact[]>([]);
|
||||||
const [hiddenArtefactIds, setHiddenArtefactIds] = useState<number[]>([]);
|
const [hiddenArtefactIds, setHiddenArtefactIds] = useState<number[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [cart, setCart] = useState<ExtendedArtefact[]>([]);
|
||||||
|
const [showCartModal, setShowCartModal] = useState(false);
|
||||||
|
|
||||||
const user = useStoreState((state) => state.user);
|
const user = useStoreState((state) => state.user);
|
||||||
|
|
||||||
// 3. Fetch from your API route and map data to fit your existing fields
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchArtefacts() {
|
async function fetchArtefacts() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// todo only show only non-required artefacts
|
|
||||||
const res = await fetch("/api/artefacts");
|
const res = await fetch("/api/artefacts");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
const transformed = data.artefact.map((a: any) => ({
|
const transformed = data.artefact.map((a: any) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
name: a.name,
|
name: a.name,
|
||||||
description: a.description,
|
description: a.description,
|
||||||
location: a.warehouseArea, // your database
|
location: a.warehouseArea,
|
||||||
earthquakeID: a.earthquakeId?.toString() ?? "",
|
earthquakeID: a.earthquakeId?.toString() ?? "",
|
||||||
observatory: a.type ?? "", // if you want to display type
|
observatory: a.type ?? "",
|
||||||
dateReleased: a.createdAt ? new Date(a.createdAt).toLocaleDateString() : "",
|
dateReleased: a.createdAt ? new Date(a.createdAt).toLocaleDateString() : "",
|
||||||
image: "/artefactImages/" + (a.imageName || "NoImageFound.PNG"),
|
image: "/artefactImages/" + (a.imageName || "NoImageFound.PNG"),
|
||||||
price: a.shopPrice ?? 100, // fallback price if not in DB
|
price: a.shopPrice ?? 100,
|
||||||
}));
|
}));
|
||||||
setArtefacts(transformed);
|
setArtefacts(transformed);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Optionally handle error
|
|
||||||
console.error("Failed to fetch artefacts", e);
|
console.error("Failed to fetch artefacts", e);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -47,9 +45,10 @@ export default function Shop() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [selectedArtefact, setSelectedArtefact] = useState<Artefact | null>(null);
|
const [selectedArtefact, setSelectedArtefact] = useState<ExtendedArtefact | null>(null);
|
||||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||||
const [artefactToBuy, setArtefactToBuy] = useState<Artefact | null>(null);
|
const [artefactToBuy, setArtefactToBuy] = useState<ExtendedArtefact | null>(null);
|
||||||
|
const [cartCheckout, setCartCheckout] = useState(false); // true = checkout cart (not single artefact)
|
||||||
const [showThankYouModal, setShowThankYouModal] = useState(false);
|
const [showThankYouModal, setShowThankYouModal] = useState(false);
|
||||||
const [orderNumber, setOrderNumber] = useState<string | null>(null);
|
const [orderNumber, setOrderNumber] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -93,11 +92,14 @@ export default function Shop() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Modal({ artefact }: { artefact: ExtendedArtefact }) {
|
function Modal({ artefact }: { artefact: ExtendedArtefact }) {
|
||||||
if (!artefact) return null;
|
if (!artefact) return null;
|
||||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||||
if (e.target === e.currentTarget) setSelectedArtefact(null);
|
if (e.target === e.currentTarget) setSelectedArtefact(null);
|
||||||
};
|
};
|
||||||
|
const inCart = cart.some((a) => a.id === artefact.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-neutral-900 bg-opacity-50 flex justify-center items-center z-50"
|
className="fixed inset-0 bg-neutral-900 bg-opacity-50 flex justify-center items-center z-50"
|
||||||
@ -121,16 +123,32 @@ export default function Shop() {
|
|||||||
<p className="text-neutral-500 mb-2">{artefact.earthquakeID}</p>
|
<p className="text-neutral-500 mb-2">{artefact.earthquakeID}</p>
|
||||||
<p className="text-neutral-500 mb-2">{artefact.observatory}</p>
|
<p className="text-neutral-500 mb-2">{artefact.observatory}</p>
|
||||||
<p className="text-neutral-500 mb-2">{artefact.dateReleased}</p>
|
<p className="text-neutral-500 mb-2">{artefact.dateReleased}</p>
|
||||||
<div className="flex justify-end gap-4 mt-4 mr-2">
|
<div className="flex flex-col sm:flex-row justify-end gap-4 mt-4 mr-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setArtefactToBuy(artefact); // Set artefact for payment modal
|
if (!inCart) setCart((cart) => [...cart, artefact]);
|
||||||
setShowPaymentModal(true); // Show payment modal
|
|
||||||
setSelectedArtefact(null); // Close this modal
|
|
||||||
}}
|
}}
|
||||||
className="px-10 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
disabled={inCart}
|
||||||
|
className={`px-6 py-2 rounded-md font-bold border
|
||||||
|
${
|
||||||
|
inCart
|
||||||
|
? "bg-gray-300 text-gray-400 cursor-not-allowed"
|
||||||
|
: "bg-green-500 hover:bg-green-600 text-white"
|
||||||
|
}
|
||||||
|
`}
|
||||||
>
|
>
|
||||||
Buy
|
{inCart ? "In Cart" : "Add to Cart"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setArtefactToBuy(artefact);
|
||||||
|
setShowPaymentModal(true);
|
||||||
|
setCartCheckout(false);
|
||||||
|
setSelectedArtefact(null);
|
||||||
|
}}
|
||||||
|
className="px-8 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Buy Now
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -138,15 +156,99 @@ export default function Shop() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaymentModal({ artefact, onClose }: { artefact: Artefact; onClose: () => void }) {
|
function CartModal() {
|
||||||
|
const total = cart.reduce((sum, art) => sum + art.price, 0);
|
||||||
|
|
||||||
|
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||||
|
if (e.target === e.currentTarget) setShowCartModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-neutral-900 bg-opacity-60 flex justify-center items-center z-[999]"
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-8">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-2xl font-bold">Your Cart</h2>
|
||||||
|
<button onClick={() => setShowCartModal(false)} className="text-xl font-bold px-2 py-1 rounded">
|
||||||
|
✖
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{cart.length === 0 ? (
|
||||||
|
<p className="text-neutral-500">Your cart is empty.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ul className="mb-4">
|
||||||
|
{cart.map((art) => (
|
||||||
|
<li key={art.id} className="flex items-center border-b py-2">
|
||||||
|
<div className="flex-shrink-0 mr-3">
|
||||||
|
<Image src={art.image} alt={art.name} width={60} height={40} className="rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow">
|
||||||
|
<p className="font-bold">{art.name}</p>
|
||||||
|
<p className="text-neutral-500 text-sm">{art.location}</p>
|
||||||
|
</div>
|
||||||
|
<p className="font-bold mr-2">
|
||||||
|
{currencyTickers[selectedCurrency]}
|
||||||
|
{convertPrice(art.price, selectedCurrency)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 bg-red-400 hover:bg-red-500 text-white rounded"
|
||||||
|
onClick={() => setCart((c) => c.filter((a) => a.id !== art.id))}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="font-bold">Total:</span>
|
||||||
|
<span className="text-lg font-bold">
|
||||||
|
{currencyTickers[selectedCurrency]}
|
||||||
|
{convertPrice(total, selectedCurrency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<button
|
||||||
|
className="px-8 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCartModal(false);
|
||||||
|
setArtefactToBuy(null);
|
||||||
|
setShowPaymentModal(true);
|
||||||
|
setCartCheckout(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Checkout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaymentModal({
|
||||||
|
artefact,
|
||||||
|
onClose,
|
||||||
|
cartItems,
|
||||||
|
}: {
|
||||||
|
artefact?: ExtendedArtefact;
|
||||||
|
onClose: () => void;
|
||||||
|
cartItems?: ExtendedArtefact[];
|
||||||
|
}) {
|
||||||
const [cardNumber, setCardNumber] = useState("");
|
const [cardNumber, setCardNumber] = useState("");
|
||||||
const [expiry, setExpiry] = useState("");
|
const [expiry, setExpiry] = useState("");
|
||||||
const [cvc, setCvc] = useState("");
|
const [cvc, setCvc] = useState("");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState(user?.email || "");
|
||||||
const [remember, setRemember] = useState(false);
|
const [remember, setRemember] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const artefactsToBuy = artefact ? [artefact] : cartItems || [];
|
||||||
|
const total = artefactsToBuy.reduce((sum, art) => sum + art.price, 0);
|
||||||
|
|
||||||
function validateEmail(email: string) {
|
function validateEmail(email: string) {
|
||||||
return (
|
return (
|
||||||
email.includes("@") &&
|
email.includes("@") &&
|
||||||
@ -162,18 +264,13 @@ export default function Shop() {
|
|||||||
function validateExpiry(exp: string) {
|
function validateExpiry(exp: string) {
|
||||||
return /^\d{2}\/\d{2}$/.test(exp);
|
return /^\d{2}\/\d{2}$/.test(exp);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePay() {
|
function handlePay() {
|
||||||
setError("");
|
setError("");
|
||||||
if (email || user?.email) {
|
const paymentEmail = user?.email || email;
|
||||||
if (!validateEmail(email)) {
|
if (!validateEmail(paymentEmail)) {
|
||||||
setError("Please enter a valid email ending");
|
setError("Please enter a valid email");
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validateCardNumber(cardNumber)) {
|
if (!validateCardNumber(cardNumber)) {
|
||||||
setError("Card number must be 12-19 digits.");
|
setError("Card number must be 12-19 digits.");
|
||||||
return;
|
return;
|
||||||
@ -186,46 +283,51 @@ export default function Shop() {
|
|||||||
setError("CVC must be 3 or 4 digits.");
|
setError("CVC must be 3 or 4 digits.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// remove all artefacts that were bought (works for both cart and single)
|
||||||
setHiddenArtefactIds((ids) => [...ids, artefact.id]);
|
setHiddenArtefactIds((ids) => [...ids, ...artefactsToBuy.map((a) => a.id)]);
|
||||||
|
|
||||||
// todo create receiving api route
|
// todo create receiving api route
|
||||||
// todo handle sending to api route
|
// todo handle sending to api route
|
||||||
// todo only ask for email if the user is not signed in
|
|
||||||
// todo (optional) add create account button to auto-fill email in sign-up modal
|
|
||||||
const genOrder = () => "#" + Math.random().toString(36).substring(2, 10).toUpperCase();
|
const genOrder = () => "#" + Math.random().toString(36).substring(2, 10).toUpperCase();
|
||||||
setOrderNumber(genOrder());
|
setOrderNumber(genOrder());
|
||||||
onClose();
|
onClose();
|
||||||
setShowThankYouModal(true);
|
setShowThankYouModal(true);
|
||||||
|
setCart((c) => c.filter((a) => !artefactsToBuy.map((x) => x.id).includes(a.id)));
|
||||||
}
|
}
|
||||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-neutral-900 bg-opacity-60 flex justify-center items-center z-10"
|
className="fixed inset-0 bg-neutral-900 bg-opacity-60 flex justify-center items-center z-[12000]"
|
||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
>
|
>
|
||||||
<div className="bg-white rounded-xl shadow-2xl max-w-md w-full p-6">
|
<div className="bg-white rounded-xl shadow-2xl max-w-md w-full p-6">
|
||||||
<h2 className="text-2xl font-bold mb-4">Buy {artefact.name}</h2>
|
<h2 className="text-2xl font-bold mb-4">
|
||||||
{/* ...Image... */}
|
Checkout {artefact ? artefact.name : artefactsToBuy.length + " item(s)"}
|
||||||
|
{!artefact && <span className="ml-1">({artefactsToBuy.map((x) => x.name).join(", ")})</span>}
|
||||||
|
</h2>
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handlePay();
|
handlePay();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!user ? (
|
{/* Email autofill */}
|
||||||
<input
|
<input
|
||||||
className="w-full mb-2 px-3 py-2 border rounded"
|
className="w-full mb-2 px-3 py-2 border rounded"
|
||||||
placeholder="Email Address"
|
placeholder="Email Address"
|
||||||
value={email}
|
value={user?.email ? user.email : email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
disabled={!!user?.email}
|
||||||
) : null}
|
/>
|
||||||
|
{user?.email && (
|
||||||
|
<p className="text-sm text-gray-500 mb-2">
|
||||||
|
Signed in as <span className="font-bold">{user.email}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
className="w-full mb-2 px-3 py-2 border rounded"
|
className="w-full mb-2 px-3 py-2 border rounded"
|
||||||
placeholder="Cardholder Name"
|
placeholder="Cardholder Name"
|
||||||
@ -268,6 +370,13 @@ export default function Shop() {
|
|||||||
Remember me
|
Remember me
|
||||||
</label>
|
</label>
|
||||||
{error && <p className="text-red-600 mb-2">{error}</p>}
|
{error && <p className="text-red-600 mb-2">{error}</p>}
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="font-bold">Total:</span>
|
||||||
|
<span className="text-lg font-bold">
|
||||||
|
{currencyTickers[selectedCurrency]}
|
||||||
|
{convertPrice(total, selectedCurrency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="flex justify-end gap-2 mt-2">
|
<div className="flex justify-end gap-2 mt-2">
|
||||||
<button type="button" onClick={onClose} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md mr-2">
|
<button type="button" onClick={onClose} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md mr-2">
|
||||||
Cancel
|
Cancel
|
||||||
@ -312,6 +421,28 @@ export default function Shop() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-black bg-opacity-0 z-0"></div>
|
<div className="absolute inset-0 bg-black bg-opacity-0 z-0"></div>
|
||||||
|
{/* --- Cart Button fixed at top right --- */}
|
||||||
|
<button
|
||||||
|
className="absolute top-6 right-6 z-[11000] bg-white border border-blue-500 shadow-lg rounded-full p-3 hover:bg-blue-100 flex flex-row items-center"
|
||||||
|
onClick={() => setShowCartModal(true)}
|
||||||
|
aria-label="Open your cart"
|
||||||
|
>
|
||||||
|
<span className="mr-2 font-bold">{cart.length || ""}</span>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="w-7 h-7 text-blue-600"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13l-1.35 2.7a1 1 0 00.9 1.45h12.2M7 13l1.2-2.4M3 3l.01 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<div className="relative z-10 flex flex-col items-center w-full px-2 py-12">
|
<div className="relative z-10 flex flex-col items-center w-full px-2 py-12">
|
||||||
<h1 className="text-4xl md:text-4xl font-bold text-center text-blue-300 mb-2 tracking-tight drop-shadow-lg">
|
<h1 className="text-4xl md:text-4xl font-bold text-center text-blue-300 mb-2 tracking-tight drop-shadow-lg">
|
||||||
Artefact Shop
|
Artefact Shop
|
||||||
@ -350,19 +481,22 @@ export default function Shop() {
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
{selectedArtefact && <Modal artefact={selectedArtefact} />}
|
{selectedArtefact && <Modal artefact={selectedArtefact} />}
|
||||||
{artefactToBuy && showPaymentModal && (
|
{showCartModal && <CartModal />}
|
||||||
|
{showPaymentModal && (cartCheckout || artefactToBuy) && (
|
||||||
<PaymentModal
|
<PaymentModal
|
||||||
artefact={artefactToBuy}
|
artefact={cartCheckout ? undefined : artefactToBuy!}
|
||||||
|
cartItems={cartCheckout ? cart : undefined}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowPaymentModal(false);
|
setShowPaymentModal(false);
|
||||||
setArtefactToBuy(null);
|
setArtefactToBuy(null);
|
||||||
|
setCartCheckout(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showThankYouModal && orderNumber && (
|
{showThankYouModal && orderNumber && (
|
||||||
<ThankYouModal orderNumber={orderNumber} onClose={() => setShowThankYouModal(false)} />
|
<ThankYouModal orderNumber={orderNumber} onClose={() => setShowThankYouModal(false)} />
|
||||||
)}
|
)}
|
||||||
{!selectedArtefact && !showPaymentModal && !showThankYouModal && (
|
{!selectedArtefact && !showPaymentModal && !showThankYouModal && !showCartModal && (
|
||||||
<div className="relative z-50">
|
<div className="relative z-50">
|
||||||
<BottomFooter />
|
<BottomFooter />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,73 +1,98 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import BottomFooter from "@components/BottomFooter";
|
||||||
const teamMembers = [
|
const teamMembers = [
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "Tim Howitz",
|
||||||
|
title: "Chief Crack Inspector",
|
||||||
|
description:
|
||||||
|
"Tim is responsible for analysing structures on a day-to-day basis. In his home life he is a keen outdoors enthusiast, with three kids and a dog.",
|
||||||
|
image: "/Timthescientist.PNG",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Emily Neighbour",
|
||||||
|
title: "Chief Software Engineer",
|
||||||
|
description:
|
||||||
|
"Emily focuses on vital earthquake prediction models. In her personal life, her hobbies include knitting, birdwatching, and becoming a mute.",
|
||||||
|
image: "/Emilythescientist.PNG",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Izzy Patterson",
|
||||||
|
title: "Chief Geologist",
|
||||||
|
description:
|
||||||
|
"Izzy's team are responsible for inspecting the rocks that make up our planet. For enjoyment she likes to look at rocks, sometimes she likes to lick them.",
|
||||||
|
image: "/Izzythescientist.PNG",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Lukeshan Thananchayan",
|
||||||
|
title: "Chief Duster",
|
||||||
|
description:
|
||||||
|
"Lukeshan and his team look at the dust particles created by an earthquake and how to minimise their damage. For pleasure, he likes to play Monopoly on repeat. Maybe one day he'll get a hotel!",
|
||||||
|
image: "/Lukeshanthescientist.PNG",
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: "Tim Howitz",
|
name: "Stuart Nicholson",
|
||||||
title: "Chief Crack Inspector",
|
title: "Chief Earthquake Enthusiast",
|
||||||
description:
|
description:
|
||||||
"Tim is responsible for analysing structures on a day-to-day basis. In his home life he is a keen outdoors enthusiast, with three kids and a dog.",
|
"Stuart is an avid earthquake enthusiast interested in their origins and humanitarian efforts. In his home life likes to sing karaoke to shake it off.",
|
||||||
image: "/Timthescientist.PNG",
|
image: "/stuart.png",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Emily Neighbour",
|
name: "Athena",
|
||||||
title: "Chief Software Engineer",
|
title: "Chief Software Engineer",
|
||||||
description:
|
description: "Athena is responsible for making all software dreams come true. <3",
|
||||||
"Emily focuses on vital earthquake prediction models. In her personal life, her hobbies include knitting, birdwatching, and becoming a mute.",
|
image: "/athena.PNG",
|
||||||
image: "/Emilythescientist.PNG",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Izzy Patterson",
|
|
||||||
title: "Chief Geologist",
|
|
||||||
description:
|
|
||||||
"Izzy's team are responsible for inspecting the rocks that make up our planet. For enjoyment she likes to look at rocks, sometimes she likes to lick them.",
|
|
||||||
image: "/Izzythescientist.PNG",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Lukeshan Thananchayan",
|
|
||||||
title: "Chief Duster",
|
|
||||||
description:
|
|
||||||
"Lukeshan and his team look at the dust particles created by an earthquake and how to minimise their damage. For pleasure, he likes to play Monopoly on repeat. Maybe one day he'll get a hotel!",
|
|
||||||
image: "/Lukeshanthescientist.PNG",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className="min-h-screen relative flex flex-col items-center justify-center px-4 py-30"
|
<div
|
||||||
style={{ backgroundImage: "url('tectonicPlates.png')", backgroundSize: "cover", backgroundPosition: "center" }}
|
className="min-h-screen relative flex flex-col items-center justify-center px-4 py-30"
|
||||||
>
|
style={{
|
||||||
{/* Overlay */}
|
backgroundImage: "url('tectonicPlates.png')",
|
||||||
<div className="absolute inset-0 bg-black bg-opacity-50 pointer-events-none"></div>
|
backgroundSize: "cover",
|
||||||
{/* Header */}
|
backgroundPosition: "center"
|
||||||
<div className="relative z-10 flex flex-col items-center mb-1">
|
}}
|
||||||
<h1 className="text-4xl font-bold text-center text-white mb-4 tracking-tight drop-shadow-lg">Meet the Team</h1>
|
>
|
||||||
<p className="text-lg text-center max-w-2xl text-white mb-12 drop-shadow-md">
|
{/* Overlay */}
|
||||||
Our world-class scientists and engineers drive innovation across the globe. Meet our four department heads:
|
<div className="absolute inset-0 bg-black bg-opacity-50 pointer-events-none"></div>
|
||||||
</p>
|
{/* Header */}
|
||||||
</div>
|
<div className="relative z-10 flex flex-col items-center mb-1">
|
||||||
{/* Team Members Section */}
|
<h1 className="text-4xl font-bold text-center text-white mb-4 tracking-tight drop-shadow-lg">Meet the Team</h1>
|
||||||
<div className="relative z-10 flex flex-col items-center gap-6 w-full max-w-3xl">
|
<p className="text-lg text-center max-w-2xl text-white mb-12 drop-shadow-md">
|
||||||
{teamMembers.map((member, index) => (
|
Our world-class scientists and engineers drive innovation across the globe. Meet our four department heads:
|
||||||
<div
|
</p>
|
||||||
key={index}
|
</div>
|
||||||
className="flex bg-white bg-opacity-90 rounded-3xl border border-neutral-200 w-full shadow-md hover:shadow-lg transition-shadow duration-300"
|
{/* Team Members Section */}
|
||||||
>
|
<div className="relative z-10 flex flex-col items-center gap-6 w-full max-w-3xl">
|
||||||
{/* Image */}
|
{teamMembers.map((member, index) => (
|
||||||
<div className="flex items-center ml-6">
|
<div
|
||||||
<div className="relative w-20 h-20">
|
key={index}
|
||||||
<div className="absolute inset-0 rounded-full overflow-hidden ring-4 ring-neutral-100">
|
className="flex bg-white bg-opacity-90 rounded-3xl border border-neutral-200 w-full shadow-md hover:shadow-lg transition-shadow duration-300"
|
||||||
<img src={member.image} alt={member.name} className="h-full w-full object-cover" />
|
>
|
||||||
</div>
|
{/* Image */}
|
||||||
</div>
|
<div className="flex items-center ml-6">
|
||||||
</div>
|
<div className="relative w-20 h-20">
|
||||||
{/* Text Content */}
|
<div className="absolute inset-0 rounded-full overflow-hidden ring-4 ring-neutral-100">
|
||||||
<div className="flex flex-col items-start pl-8 py-4 pr-6">
|
<img src={member.image} alt={member.name} className="h-full w-full object-cover" />
|
||||||
<h2 className="text-2xl font-bold text-neutral-800">{member.name}</h2>
|
</div>
|
||||||
<p className="text-md text-neutral-500 font-semibold">{member.title}</p>
|
</div>
|
||||||
<p className="text-neutral-600 mt-3 text-left text-sm leading-relaxed">{member.description}</p>
|
</div>
|
||||||
</div>
|
{/* Text Content */}
|
||||||
</div>
|
<div className="flex flex-col items-start pl-8 py-4 pr-6">
|
||||||
))}
|
<h2 className="text-2xl font-bold text-neutral-800">{member.name}</h2>
|
||||||
</div>
|
<p className="text-md text-neutral-500 font-semibold">{member.title}</p>
|
||||||
</div>
|
<p className="text-neutral-600 mt-3 text-left text-sm leading-relaxed">{member.description}</p>
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BottomFooter />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,74 +1,178 @@
|
|||||||
// components/Footer.tsx
|
import React, { useCallback, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { FaFacebook, FaLinkedin, FaTwitter, FaYoutube } from "react-icons/fa";
|
|
||||||
|
|
||||||
export default function Footer() {
|
export default function BottomFooter() {
|
||||||
return (
|
// ig easter egg
|
||||||
<footer className="bg-[#16424b] text-white pt-12 pb-4 px-6 mt-12 z-0">
|
const [lavaActive, setLavaActive] = useState(false);
|
||||||
<div className="max-w-6xl mx-auto flex flex-col md:flex-row justify-between gap-8">
|
const lavaTimeout = useRef<any>(null);
|
||||||
{/* Useful Links */}
|
|
||||||
<div className="min-w-[200px] mb-8 md:mb-0 flex-1">
|
// LinkedIn easter egg
|
||||||
<h3 className="font-bold underline text-lg mb-3">Useful links</h3>
|
const [shaking, setShaking] = useState(false);
|
||||||
<ul className="space-y-2">
|
const shakeTimeout = useRef<any>(null);
|
||||||
<li>
|
|
||||||
<Link
|
// x easter egg
|
||||||
href="https://www.gov.uk/guidance/extreme-weather-and-natural-hazards"
|
const [showCracks, setShowCracks] = useState(false);
|
||||||
className="hover:underline"
|
const [collapse, setCollapse] = useState(false);
|
||||||
target="_blank"
|
const crackTimeout = useRef<any>(null);
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
// Lava flood handler (top-down flood)
|
||||||
Gov.UK guidance
|
const handleInstagramClick = useCallback((e: React.MouseEvent) => {
|
||||||
</Link>
|
e.preventDefault();
|
||||||
</li>
|
setLavaActive(true);
|
||||||
<li>
|
clearTimeout(lavaTimeout.current);
|
||||||
<Link href="https://www.dysoninstitute.ac.uk/about-us/governance/privacy-notices/" className="hover:underline">
|
lavaTimeout.current = setTimeout(() => setLavaActive(false), 2000);
|
||||||
Privacy policy
|
}, []);
|
||||||
</Link>
|
|
||||||
</li>
|
// LinkedIn shake handler
|
||||||
<li>
|
const handleLinkedInClick = useCallback((e: React.MouseEvent) => {
|
||||||
<Link href="https://privacy.dyson.com/en/globalcookiepolicy.aspx" className="hover:underline">
|
e.preventDefault();
|
||||||
Cookies policy
|
if (shaking) return; // prevent stacking
|
||||||
</Link>
|
setShaking(true);
|
||||||
</li>
|
const body = document.body;
|
||||||
</ul>
|
body.classList.remove("shake-screen");
|
||||||
</div>
|
void body.offsetWidth;
|
||||||
{/* Donate Section */}
|
body.classList.add("shake-screen");
|
||||||
<div className="min-w-[220px] mb-8 md:mb-0 flex-1">
|
shakeTimeout.current = setTimeout(() => {
|
||||||
<h3 className="font-bold underline text-lg mb-3">Donate</h3>
|
setShaking(false);
|
||||||
<p className="mb-4">
|
body.classList.remove("shake-screen");
|
||||||
We are a nonprofit entirely funded by your donations, every penny helps provide life saving information.
|
}, 1000);
|
||||||
</p>
|
}, [shaking]);
|
||||||
<Link
|
|
||||||
href="#"
|
// X (crack and collapse) handler
|
||||||
className="bg-gray-200 hover:bg-blue-600 hover:text-white text-black font-bold rounded-full px-8 py-2 shadow transition-colors duration-200 inline-block text-center"
|
const handleXClick = useCallback((e: React.MouseEvent) => {
|
||||||
>
|
e.preventDefault();
|
||||||
Donate Now
|
setShowCracks(true);
|
||||||
</Link>
|
crackTimeout.current = setTimeout(() => {
|
||||||
</div>
|
setCollapse(true);
|
||||||
</div>
|
setTimeout(() => {
|
||||||
{/* Bottom bar */}
|
setShowCracks(false);
|
||||||
<div className="max-w-6xl mx-auto mt-8 pt-6 flex flex-col md:flex-row items-center justify-between border-t border-gray-200/30">
|
setCollapse(false);
|
||||||
{/* Bottom left: Copyright */}
|
}, 1500);
|
||||||
<span className="text-sm flex items-center">
|
}, 1000);
|
||||||
<span className="mr-2">©</span> TremorTracker 2025
|
}, []);
|
||||||
</span>
|
|
||||||
{/* Bottom right: Social icons */}
|
React.useEffect(() => {
|
||||||
<div className="flex flex-col items-end">
|
return () => {
|
||||||
<span className="text-sm mb-2">Follow us on</span>
|
clearTimeout(lavaTimeout.current);
|
||||||
<div className="flex space-x-3">
|
clearTimeout(shakeTimeout.current);
|
||||||
{/* Replace src with your icon URLs, or use next/image if preferred */}
|
clearTimeout(crackTimeout.current);
|
||||||
<a href="#" target="_blank" rel="noopener noreferrer">
|
document.body.classList.remove("shake-screen");
|
||||||
<img src="instagram.png" alt="instagram" className="h-7 w-7 rounded-full shadow" />
|
};
|
||||||
</a>
|
}, []);
|
||||||
<a href="#" target="_blank" rel="noopener noreferrer">
|
|
||||||
<img src="linkedin.png" alt="linkedin" className="h-7 w-7 rounded-full shadow" />
|
return (
|
||||||
</a>
|
<>
|
||||||
<a href="#" target="_blank" rel="noopener noreferrer">
|
{/* Lava Flood Overlay */}
|
||||||
<img src="x_logo.jpg" alt="X" className="h-7 w-7 rounded-full shadow" />
|
{lavaActive && (
|
||||||
</a>
|
<div className="lava-flood-overlay lava-active">
|
||||||
</div>
|
<img src="/lava.jpg" alt="Lava flood" draggable={false} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</footer>
|
|
||||||
);
|
{/* Crack & Collapse Overlay */}
|
||||||
|
{(showCracks || collapse) && (
|
||||||
|
<div className={`crack-overlay${collapse ? " crack-collapse" : ""}`}>
|
||||||
|
<img className="crack crack1" src="/crack1.png" alt="" />
|
||||||
|
<img className="crack crack2" src="/crack2.png" alt="" />
|
||||||
|
{/* Add more cracks for extra effect if you wish */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="bg-[#16424b] text-white pt-12 pb-4 px-6 mt-12 z-0">
|
||||||
|
<div className="max-w-6xl mx-auto flex flex-col md:flex-row justify-between gap-8">
|
||||||
|
{/* Useful Links */}
|
||||||
|
<div className="min-w-[200px] mb-8 md:mb-0 flex-1">
|
||||||
|
<h3 className="font-bold underline text-lg mb-3">Useful links</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="https://www.gov.uk/guidance/extreme-weather-and-natural-hazards"
|
||||||
|
className="hover:underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Gov.UK guidance
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="https://www.dysoninstitute.ac.uk/about-us/governance/privacy-notices/"
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
Privacy policy
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="https://privacy.dyson.com/en/globalcookiepolicy.aspx"
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
Cookies policy
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Donate Section */}
|
||||||
|
<div className="min-w-[220px] mb-8 md:mb-0 flex-1">
|
||||||
|
<h3 className="font-bold underline text-lg mb-3">Donate</h3>
|
||||||
|
<p className="mb-4">
|
||||||
|
We are a nonprofit entirely funded by your donations, every penny helps provide life saving information.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="https://shelterbox.org/"
|
||||||
|
className="bg-gray-200 hover:bg-blue-600 hover:text-white text-black font-bold rounded-full px-8 py-2 shadow transition-colors duration-200 inline-block text-center"
|
||||||
|
>
|
||||||
|
Donate Now
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom bar */}
|
||||||
|
<div className="max-w-6xl mx-auto mt-8 pt-6 flex flex-col md:flex-row items-center justify-between border-t border-gray-200/30">
|
||||||
|
<div className="flex flex-row items-center w-full md:w-auto">
|
||||||
|
<img
|
||||||
|
src="/logo.png"
|
||||||
|
alt="TremorTracker logo"
|
||||||
|
className="h-16 w-auto mr-4 object-contain"
|
||||||
|
style={{ maxHeight: 75 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm flex items-center">
|
||||||
|
<span className="mr-2">©</span> TremorTracker 2025
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<span className="text-sm mb-2">Follow us on</span>
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={handleInstagramClick}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
aria-label="Instagram Lava Easter egg"
|
||||||
|
>
|
||||||
|
<img src="instagram.png" alt="instagram" className="h-7 w-7 rounded-full shadow" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={handleLinkedInClick}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
aria-label="LinkedIn Shake Easter egg"
|
||||||
|
>
|
||||||
|
<img src="linkedin.png" alt="linkedin" className="h-7 w-7 rounded-full shadow" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={handleXClick}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
aria-label="X Crack Easter egg"
|
||||||
|
>
|
||||||
|
<img src="x_logo.jpg" alt="X" className="h-7 w-7 rounded-full shadow" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
248
src/components/EarthquakeLogModal.tsx
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import DatePicker from "react-datepicker";
|
||||||
|
import "react-datepicker/dist/react-datepicker.css";
|
||||||
|
|
||||||
|
const typeOptions = [
|
||||||
|
{ value: "volcanic", label: "Volcanic" },
|
||||||
|
{ value: "tectonic", label: "Tectonic" },
|
||||||
|
{ value: "collapse", label: "Collapse" },
|
||||||
|
{ value: "explosion", label: "Explosion" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function EarthquakeLogModal({ open, onClose, onSuccess }) {
|
||||||
|
const [date, setDate] = useState<Date | null>(new Date());
|
||||||
|
const [magnitude, setMagnitude] = useState("");
|
||||||
|
const [type, setType] = useState(typeOptions[0].value);
|
||||||
|
const [city, setCity] = useState("");
|
||||||
|
const [country, setCountry] = useState("");
|
||||||
|
const [latitude, setLatitude] = useState("");
|
||||||
|
const [longitude, setLongitude] = useState("");
|
||||||
|
const [depth, setDepth] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [successCode, setSuccessCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleLatLonChange(lat: string, lon: string) {
|
||||||
|
setLatitude(lat);
|
||||||
|
setLongitude(lon);
|
||||||
|
if (/^-?\d+(\.\d+)?$/.test(lat) && /^-?\d+(\.\d+)?$/.test(lon)) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10`
|
||||||
|
);
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
setCity(
|
||||||
|
data.address.city ||
|
||||||
|
data.address.town ||
|
||||||
|
data.address.village ||
|
||||||
|
data.address.hamlet ||
|
||||||
|
data.address.county ||
|
||||||
|
data.address.state ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
setCountry(data.address.country || "");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
if (!date || !magnitude || !type || !city || !country || !latitude || !longitude || !depth) {
|
||||||
|
alert("Please complete all fields.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/earthquakes/log", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
date,
|
||||||
|
magnitude: parseFloat(magnitude),
|
||||||
|
type,
|
||||||
|
location: `${city.trim()}, ${country.trim()}`,
|
||||||
|
country: country.trim(),
|
||||||
|
latitude: parseFloat(latitude),
|
||||||
|
longitude: parseFloat(longitude),
|
||||||
|
depth
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const result = await res.json();
|
||||||
|
setSuccessCode(result.code);
|
||||||
|
setLoading(false);
|
||||||
|
if (onSuccess) onSuccess();
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert("Failed to log earthquake! " + (err.error || ""));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
alert("Failed to log. " + e.message);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
// Success popup overlay
|
||||||
|
if (successCode) {
|
||||||
|
return (
|
||||||
|
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
|
||||||
|
<div className="bg-white rounded-lg px-8 py-8 max-w-md w-full relative shadow-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSuccessCode(null);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="absolute right-4 top-4 text-xl text-gray-400 hover:text-gray-700"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">
|
||||||
|
Thank you for logging an earthquake!
|
||||||
|
</h2>
|
||||||
|
<div className="mb-0">The Earthquake Identifier is</div>
|
||||||
|
<div className="font-mono text-lg mb-4 bg-slate-100 rounded px-2 py-1 inline-block text-blue-800">{successCode}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6 max-w-lg w-full relative">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<h2 className="font-bold text-xl mb-4">Log Earthquake</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">Date</label>
|
||||||
|
<DatePicker
|
||||||
|
selected={date}
|
||||||
|
onChange={date => setDate(date)}
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
dateFormat="yyyy-MM-dd"
|
||||||
|
maxDate={new Date()}
|
||||||
|
showMonthDropdown
|
||||||
|
showYearDropdown
|
||||||
|
dropdownMode="select"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">Magnitude</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
step="0.1"
|
||||||
|
value={magnitude}
|
||||||
|
onChange={e => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (parseFloat(val) > 10) return;
|
||||||
|
setMagnitude(val);
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">Type</label>
|
||||||
|
<select
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
value={type}
|
||||||
|
onChange={e => setType(e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{typeOptions.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">City/Area</label>
|
||||||
|
<span className="block text-xs text-gray-400">
|
||||||
|
(Use Lat/Lon then press Enter for reverse lookup)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
value={city}
|
||||||
|
onChange={e => setCity(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">Country</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
value={country}
|
||||||
|
onChange={e => setCountry(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium">Latitude</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
value={latitude}
|
||||||
|
onChange={e => handleLatLonChange(e.target.value, longitude)}
|
||||||
|
placeholder="e.g. 36.12"
|
||||||
|
step="any"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium">Longitude</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
value={longitude}
|
||||||
|
onChange={e => handleLatLonChange(latitude, e.target.value)}
|
||||||
|
placeholder="e.g. -115.17"
|
||||||
|
step="any"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">Depth</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
value={depth}
|
||||||
|
onChange={e => setDepth(e.target.value)}
|
||||||
|
placeholder="e.g. 10 km"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 w-full"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "Logging..." : "Log Earthquake"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
245
src/components/EarthquakeSearchModal.tsx
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export type Earthquake = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
magnitude: number;
|
||||||
|
location: string;
|
||||||
|
date: string;
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS = [
|
||||||
|
{ label: "Code", key: "code", className: "font-mono font-bold" },
|
||||||
|
{ label: "Location", key: "location" },
|
||||||
|
{ label: "Magnitude", key: "magnitude", numeric: true },
|
||||||
|
{ label: "Date", key: "date" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function EarthquakeSearchModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (eq: Earthquake) => void;
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = useState<string>("");
|
||||||
|
const [results, setResults] = useState<Earthquake[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>("");
|
||||||
|
|
||||||
|
// Filters per column
|
||||||
|
const [filters, setFilters] = useState<{ [k: string]: string }>({
|
||||||
|
code: "",
|
||||||
|
location: "",
|
||||||
|
magnitude: "",
|
||||||
|
date: "",
|
||||||
|
});
|
||||||
|
// Sort state
|
||||||
|
const [sort, setSort] = useState<{ key: keyof Earthquake; dir: "asc" | "desc" } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSearch("");
|
||||||
|
setResults([]);
|
||||||
|
setFilters({ code: "", location: "", magnitude: "", date: "" });
|
||||||
|
setError("");
|
||||||
|
setSort(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const doSearch = async (q = search) => {
|
||||||
|
setLoading(true);
|
||||||
|
setResults([]);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const resp = await axios.post("/api/earthquakes/search", { query: q });
|
||||||
|
setResults(resp.data.earthquakes || []);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError("Failed to search earthquakes.");
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter logic
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
return results.filter((row) =>
|
||||||
|
(!filters.code ||
|
||||||
|
row.code.toLowerCase().includes(filters.code.toLowerCase())) &&
|
||||||
|
(!filters.location ||
|
||||||
|
(row.location || "").toLowerCase().includes(filters.location.toLowerCase())) &&
|
||||||
|
(!filters.magnitude ||
|
||||||
|
String(row.magnitude).startsWith(filters.magnitude)) &&
|
||||||
|
(!filters.date ||
|
||||||
|
row.date.slice(0, 10) === filters.date)
|
||||||
|
);
|
||||||
|
}, [results, filters]);
|
||||||
|
|
||||||
|
// Sort logic
|
||||||
|
const sortedRows = useMemo(() => {
|
||||||
|
if (!sort) return filteredRows;
|
||||||
|
const sorted = [...filteredRows].sort((a, b) => {
|
||||||
|
let valA = a[sort.key];
|
||||||
|
let valB = b[sort.key];
|
||||||
|
if (sort.key === "magnitude") {
|
||||||
|
valA = Number(valA);
|
||||||
|
valB = Number(valB);
|
||||||
|
} else if (sort.key === "date") {
|
||||||
|
valA = a.date;
|
||||||
|
valB = b.date;
|
||||||
|
} else {
|
||||||
|
valA = String(valA || "");
|
||||||
|
valB = String(valB || "");
|
||||||
|
}
|
||||||
|
if (valA < valB) return sort.dir === "asc" ? -1 : 1;
|
||||||
|
if (valA > valB) return sort.dir === "asc" ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}, [filteredRows, sort]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center animate-fadein">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-2xl relative">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-4 top-4 text-2xl text-gray-400 hover:text-red-500 font-bold"
|
||||||
|
>×</button>
|
||||||
|
<h2 className="font-bold text-xl mb-4">Search Earthquakes</h2>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
doSearch();
|
||||||
|
}}
|
||||||
|
className="flex gap-2 mb-3"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Mexico, EV-7.4-Mexico-00035"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="flex-grow px-3 py-2 border rounded"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 min-w-[6.5rem] flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<svg className="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
|
||||||
|
</svg>
|
||||||
|
Search...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Search</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSearch("");
|
||||||
|
setResults([]);
|
||||||
|
setFilters({ code: "", location: "", magnitude: "", date: "" });
|
||||||
|
}}
|
||||||
|
className="bg-gray-100 text-gray-600 px-3 py-2 rounded hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-600 font-medium mb-2">{error}</div>
|
||||||
|
)}
|
||||||
|
{/* Filter Row */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{COLUMNS.map((col) => (
|
||||||
|
<input
|
||||||
|
key={col.key}
|
||||||
|
type={col.key === "magnitude" ? "number" : col.key === "date" ? "date" : "text"}
|
||||||
|
value={filters[col.key] || ""}
|
||||||
|
onChange={e =>
|
||||||
|
setFilters(f => ({ ...f, [col.key]: e.target.value }))
|
||||||
|
}
|
||||||
|
className="border border-neutral-200 rounded px-2 py-1 text-xs"
|
||||||
|
style={{
|
||||||
|
width:
|
||||||
|
col.key === "magnitude"
|
||||||
|
? 70
|
||||||
|
: col.key === "date"
|
||||||
|
? 130
|
||||||
|
: 120,
|
||||||
|
}}
|
||||||
|
placeholder={`Filter ${col.label}`}
|
||||||
|
aria-label={`Filter ${col.label}`}
|
||||||
|
disabled={loading || results.length === 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Results Table */}
|
||||||
|
<div className="border rounded shadow-inner bg-white max-h-72 overflow-y-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-neutral-100 border-b">
|
||||||
|
{COLUMNS.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
className={`font-semibold px-3 py-2 cursor-pointer select-none text-left`}
|
||||||
|
onClick={() =>
|
||||||
|
setSort(sort && sort.key === col.key
|
||||||
|
? { key: col.key as keyof Earthquake, dir: sort.dir === "asc" ? "desc" : "asc" }
|
||||||
|
: { key: col.key as keyof Earthquake, dir: "asc" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
{sort?.key === col.key &&
|
||||||
|
(sort.dir === "asc" ? " ↑" : " ↓")}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedRows.length === 0 && !loading && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={COLUMNS.length} className="p-3 text-center text-gray-400">
|
||||||
|
No results found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{sortedRows.map(eq => (
|
||||||
|
<tr
|
||||||
|
key={eq.id}
|
||||||
|
className="hover:bg-blue-50 cursor-pointer border-b"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(eq);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 font-mono">{eq.code}</td>
|
||||||
|
<td className="px-3 py-2">{eq.location}</td>
|
||||||
|
<td className="px-3 py-2 font-bold">{eq.magnitude}</td>
|
||||||
|
<td className="px-3 py-2">{formatDate(eq.date)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
223
src/components/LogObservatoryModal.tsx
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import DatePicker from "react-datepicker";
|
||||||
|
import "react-datepicker/dist/react-datepicker.css";
|
||||||
|
|
||||||
|
const yesNo = [
|
||||||
|
{ value: true, label: "Yes" },
|
||||||
|
{ value: false, label: "No" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function LogObservatoryModal({ open, onClose, onSuccess }) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [isOpen, setIsOpen] = useState("true");
|
||||||
|
const [dateOpened, setDateOpened] = useState<Date | null>(new Date());
|
||||||
|
const [dateClosed, setDateClosed] = useState<Date | null>(null);
|
||||||
|
const [city, setCity] = useState("");
|
||||||
|
const [country, setCountry] = useState("");
|
||||||
|
const [latitude, setLatitude] = useState("");
|
||||||
|
const [longitude, setLongitude] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState<{ name: string } | null>(null);
|
||||||
|
|
||||||
|
// Reverse Geo-code
|
||||||
|
async function handleLatLonChange(lat: string, lon: string) {
|
||||||
|
setLatitude(lat);
|
||||||
|
setLongitude(lon);
|
||||||
|
if (/^-?\d+(\.\d+)?$/.test(lat) && /^-?\d+(\.\d+)?$/.test(lon)) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10`
|
||||||
|
);
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
setCity(
|
||||||
|
data.address.city ||
|
||||||
|
data.address.town ||
|
||||||
|
data.address.village ||
|
||||||
|
data.address.hamlet ||
|
||||||
|
data.address.county ||
|
||||||
|
data.address.state ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
setCountry(data.address.country || "");
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
if (!name || !dateOpened || !latitude || !longitude || !city || !country) {
|
||||||
|
alert("Please complete all fields.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isOpen === "false" && !dateClosed) {
|
||||||
|
alert("Please enter the date this observatory closed.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/observatories/log", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name.trim(),
|
||||||
|
isFunctional: isOpen === "true" ? true : false,
|
||||||
|
location: `${city.trim()}, ${country.trim()}`,
|
||||||
|
latitude: parseFloat(latitude),
|
||||||
|
longitude: parseFloat(longitude),
|
||||||
|
dateEstablished: dateOpened,
|
||||||
|
dateClosed: isOpen === "false" ? dateClosed : null,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setSuccess({ name });
|
||||||
|
setLoading(false);
|
||||||
|
if (onSuccess) onSuccess();
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert("Failed to log observatory! " + (err.error || ""));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
alert("Failed to log. " + e.message);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
|
||||||
|
<div className="bg-white rounded-lg px-8 py-8 max-w-md w-full relative shadow-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => { setSuccess(null); onClose(); }}
|
||||||
|
className="absolute right-4 top-4 text-xl text-gray-400 hover:text-gray-700"
|
||||||
|
aria-label="Close"
|
||||||
|
>×</button>
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">Thank you for logging an observatory!</h2>
|
||||||
|
<div>The Observatory is now being shown as <b>{success.name}</b></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6 max-w-lg w-full relative">
|
||||||
|
<button onClick={onClose}
|
||||||
|
className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg">×</button>
|
||||||
|
<h2 className="font-bold text-xl mb-4">Log Observatory</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">Observatory Name</label>
|
||||||
|
<input
|
||||||
|
type="text" className="border rounded px-3 py-2 w-full"
|
||||||
|
value={name} onChange={e => setName(e.target.value)} required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">Is this observatory still open?</label>
|
||||||
|
<select
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
value={isOpen}
|
||||||
|
onChange={e => setIsOpen(e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="true">Yes</option>
|
||||||
|
<option value="false">No</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">Date Opened</label>
|
||||||
|
<DatePicker
|
||||||
|
selected={dateOpened}
|
||||||
|
onChange={date => setDateOpened(date)}
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
dateFormat="yyyy-MM-dd"
|
||||||
|
showMonthDropdown
|
||||||
|
showYearDropdown
|
||||||
|
dropdownMode="select"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isOpen === "false" && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">Date Closed</label>
|
||||||
|
<DatePicker
|
||||||
|
selected={dateClosed}
|
||||||
|
onChange={date => setDateClosed(date)}
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
dateFormat="yyyy-MM-dd"
|
||||||
|
showMonthDropdown
|
||||||
|
showYearDropdown
|
||||||
|
dropdownMode="select"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">City/Area</label>
|
||||||
|
<span className="block text-xs text-gray-400">
|
||||||
|
(Use Lat/Lon then press Enter for reverse lookup)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
value={city}
|
||||||
|
onChange={e => setCity(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">Country</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
value={country}
|
||||||
|
onChange={e => setCountry(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium">Latitude</label>
|
||||||
|
<input type="number"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
value={latitude}
|
||||||
|
onChange={e => handleLatLonChange(e.target.value, longitude)}
|
||||||
|
placeholder="e.g. 36.12"
|
||||||
|
step="any"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium">Longitude</label>
|
||||||
|
<input type="number"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
value={longitude}
|
||||||
|
onChange={e => handleLatLonChange(latitude, e.target.value)}
|
||||||
|
placeholder="e.g. -115.17"
|
||||||
|
step="any"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 w-full"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "Logging..." : "Log Observatory"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -111,8 +111,12 @@ function MapComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const observatoryElement = document.createElement("div");
|
const observatoryElement = document.createElement("div");
|
||||||
const root = createRoot(observatoryElement);
|
const root = createRoot(observatoryElement);
|
||||||
root.render(<GiObservatory className="text-blue-600 text-2xl drop-shadow-lg" />);
|
root.render(
|
||||||
|
<GiObservatory
|
||||||
|
className={`text-2xl drop-shadow-lg ${event.isFunctional === false ? "text-gray-400" : "text-blue-600"}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
quakeElement.appendChild(pulseElement);
|
quakeElement.appendChild(pulseElement);
|
||||||
quakeElement.appendChild(dotElement);
|
quakeElement.appendChild(dotElement);
|
||||||
|
|||||||
@ -134,11 +134,15 @@ export default function Navbar({}: // currencySelector,
|
|||||||
<ManagementNavbarButton name="Warehouse" href="/warehouse"></ManagementNavbarButton>
|
<ManagementNavbarButton name="Warehouse" href="/warehouse"></ManagementNavbarButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{user && (user.role === "SCIENTIST" || user.role === "ADMIN") && (
|
{user && (
|
||||||
<div className="flex h-full mr-5">
|
(user.role === "ADMIN" ||
|
||||||
<ManagementNavbarButton name="Scientist Management" href="/management"></ManagementNavbarButton>
|
(user.role === "SCIENTIST" && user.scientist?.level === "SENIOR")
|
||||||
</div>
|
) && (
|
||||||
)}
|
<div className="flex h-full mr-5">
|
||||||
|
<ManagementNavbarButton name="Scientist Management" href="/management" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
{user && user.role === "ADMIN" && (
|
{user && user.role === "ADMIN" && (
|
||||||
<div className="flex h-full mr-5">
|
<div className="flex h-full mr-5">
|
||||||
<ManagementNavbarButton name="Admin" href="/administrator"></ManagementNavbarButton>
|
<ManagementNavbarButton name="Admin" href="/administrator"></ManagementNavbarButton>
|
||||||
|
|||||||
@ -1,120 +1,124 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import React, { Dispatch, SetStateAction, useEffect, useRef } from "react";
|
import React, { Dispatch, SetStateAction, useEffect, useRef } from "react";
|
||||||
import { TbHexagon } from "react-icons/tb";
|
import { TbHexagon } from "react-icons/tb";
|
||||||
|
|
||||||
import GeologicalEvent from "@appTypes/Event";
|
import GeologicalEvent from "@appTypes/Event";
|
||||||
import getMagnitudeColor from "@utils/getMagnitudeColour";
|
import getMagnitudeColor from "@utils/getMagnitudeColour";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
logTitle: string;
|
logTitle: string;
|
||||||
logSubtitle: string;
|
logSubtitle: string;
|
||||||
recentsTitle: string;
|
recentsTitle: string;
|
||||||
events: GeologicalEvent[];
|
events: GeologicalEvent[];
|
||||||
selectedEventId: GeologicalEvent["id"];
|
selectedEventId: GeologicalEvent["id"];
|
||||||
setSelectedEventId: Dispatch<SetStateAction<string>>;
|
setSelectedEventId: Dispatch<SetStateAction<string>>;
|
||||||
hoveredEventId: GeologicalEvent["id"];
|
hoveredEventId: GeologicalEvent["id"];
|
||||||
setHoveredEventId: Dispatch<SetStateAction<string>>;
|
setHoveredEventId: Dispatch<SetStateAction<string>>;
|
||||||
button1Name: string;
|
button1Name: string;
|
||||||
button2Name: string;
|
button2Name: string;
|
||||||
onButton2Click?: () => void;
|
onButton2Click?: () => void;
|
||||||
|
onButton1Click?: () => void;
|
||||||
|
button1Disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MagnitudeNumber({ magnitude }: { magnitude: number }) {
|
function MagnitudeNumber({ magnitude }: { magnitude: number }) {
|
||||||
const magnitudeStr = magnitude.toFixed(1);
|
const magnitudeStr = magnitude.toFixed(1);
|
||||||
const [whole, decimal] = magnitudeStr.split(".");
|
const [whole, decimal] = magnitudeStr.split(".");
|
||||||
|
return (
|
||||||
return (
|
<div className="relative" style={{ color: getMagnitudeColor(magnitude) }}>
|
||||||
<div className="relative" style={{ color: getMagnitudeColor(magnitude) }}>
|
<TbHexagon size={40} className="drop-shadow-sm" />
|
||||||
<TbHexagon size={40} className="drop-shadow-sm" />
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="flex items-baseline font-mono font-bold tracking-tight">
|
||||||
<div className="flex items-baseline font-mono font-bold tracking-tight">
|
<span className="text-xl -mr-1">{whole}</span>
|
||||||
<span className="text-xl -mr-1">{whole}</span>
|
<span className="text-xs ml-[1.5px] -mr-[2.5px]">.</span>
|
||||||
<span className="text-xs ml-[1.5px] -mr-[2.5px]">.</span>
|
<span className="text-xs -mr-[1px]">{decimal}</span>
|
||||||
<span className="text-xs -mr-[1px]">{decimal}</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo change sidebar event highlighting on selection
|
|
||||||
|
|
||||||
export default function Sidebar({
|
export default function Sidebar({
|
||||||
logTitle,
|
logTitle,
|
||||||
logSubtitle,
|
logSubtitle,
|
||||||
recentsTitle,
|
recentsTitle,
|
||||||
events,
|
events,
|
||||||
selectedEventId,
|
selectedEventId,
|
||||||
setSelectedEventId,
|
setSelectedEventId,
|
||||||
hoveredEventId,
|
hoveredEventId,
|
||||||
setHoveredEventId,
|
setHoveredEventId,
|
||||||
button1Name,
|
button1Name,
|
||||||
button2Name,
|
button2Name,
|
||||||
onButton2Click,
|
onButton2Click,
|
||||||
|
onButton1Click,
|
||||||
|
button1Disabled = false,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const eventsContainerRef = useRef<HTMLDivElement>(null);
|
const eventsContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedEventId && eventsContainerRef.current) {
|
||||||
|
const selectedEventElement = eventsContainerRef.current.querySelector(`[data-event-id="${selectedEventId}"]`);
|
||||||
|
if (selectedEventElement) {
|
||||||
|
selectedEventElement.scrollIntoView({
|
||||||
|
block: "center",
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedEventId]);
|
||||||
|
|
||||||
useEffect(() => {
|
return (
|
||||||
if (selectedEventId && eventsContainerRef.current) {
|
<div className="flex flex-col h-full w-80 bg-gradient-to-b from-neutral-100 to-neutral-50 shadow-lg">
|
||||||
const selectedEventElement = eventsContainerRef.current.querySelector(`[data-event-id="${selectedEventId}"]`);
|
<div className="py-6 flex flex-col h-full">
|
||||||
if (selectedEventElement) {
|
<div className="px-6 pb-8 border-b border-neutral-200">
|
||||||
selectedEventElement.scrollIntoView({
|
<h2 className="text-2xl font-bold text-neutral-800 mb-2">{logTitle}</h2>
|
||||||
block: "center",
|
<p className="text-sm text-neutral-600 leading-relaxed">{logSubtitle}</p>
|
||||||
behavior: "smooth",
|
<button
|
||||||
});
|
className={`mt-4 w-full py-2 px-4 rounded-lg transition-colors duration-200 font-medium
|
||||||
}
|
${button1Disabled
|
||||||
}
|
? "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||||
}, [selectedEventId]);
|
: "bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
}`}
|
||||||
|
onClick={onButton1Click}
|
||||||
|
type="button"
|
||||||
|
|
||||||
return (
|
tabIndex={button1Disabled ? -1 : 0}
|
||||||
<div className="flex flex-col h-full w-80 bg-gradient-to-b from-neutral-100 to-neutral-50 shadow-lg">
|
aria-disabled={button1Disabled ? "true" : "false"}
|
||||||
<div className="py-6 flex flex-col h-full">
|
>
|
||||||
<div className="px-6 pb-8 border-b border-neutral-200">
|
{button1Name}
|
||||||
<h2 className="text-2xl font-bold text-neutral-800 mb-2">{logTitle}</h2>
|
</button>
|
||||||
<p className="text-sm text-neutral-600 leading-relaxed">{logSubtitle}</p>
|
<button
|
||||||
|
className="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition-colors duration-200 font-medium"
|
||||||
<Link href="/">
|
onClick={onButton2Click}
|
||||||
<button className="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition-colors duration-200 font-medium">
|
type="button"
|
||||||
{button1Name}
|
>
|
||||||
</button>
|
{button2Name}
|
||||||
</Link>
|
</button>
|
||||||
{/* "Search Earthquakes" should NOT be wrapped in a Link! */}
|
</div>
|
||||||
<button
|
<div className="px-6 pt-6">
|
||||||
className="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition-colors duration-200 font-medium"
|
<h2 className="text-xl font-bold text-neutral-800 mb-4">{recentsTitle}</h2>
|
||||||
onClick={onButton2Click}
|
</div>
|
||||||
type="button"
|
<div className="flex-1 px-6 overflow-y-auto" ref={eventsContainerRef}>
|
||||||
>
|
<div className="space-y-3">
|
||||||
{button2Name}
|
{events.map((event) => (
|
||||||
</button>
|
<button
|
||||||
</div>
|
key={event.id}
|
||||||
<div className="px-6 pt-6">
|
data-event-id={event.id}
|
||||||
<h2 className="text-xl font-bold text-neutral-800 mb-4">{recentsTitle}</h2>
|
className={`w-full border ${hoveredEventId === event.id ? "bg-neutral-100" : "bg-white"} ${
|
||||||
</div>
|
selectedEventId === event.id ? "border-neutral-800" : "border-neutral-200"
|
||||||
<div className="flex-1 px-6 overflow-y-auto" ref={eventsContainerRef}>
|
} rounded-lg p-4 flex items-center gap-4 hover:bg-neutral-100 transition-colors duration-150 shadow-sm text-left`}
|
||||||
<div className="space-y-3">
|
onClick={() => {
|
||||||
{events.map((event) => (
|
setSelectedEventId((prevEventId) => (prevEventId !== event.id ? event.id : ""));
|
||||||
<button
|
}}
|
||||||
key={event.id}
|
onMouseEnter={() => setHoveredEventId(event.id)}
|
||||||
data-event-id={event.id}
|
onMouseLeave={() => setHoveredEventId("")}
|
||||||
className={`w-full border ${hoveredEventId === event.id ? "bg-neutral-100" : "bg-white"} ${
|
>
|
||||||
selectedEventId === event.id ? "border-neutral-800" : "border-neutral-200"
|
<div className="flex-1">
|
||||||
} rounded-lg p-4 flex items-center gap-4 hover:bg-neutral-100 transition-colors duration-150 shadow-sm text-left`}
|
<p className="text-sm font-medium text-neutral-800 line-clamp-1">{event.title}</p>
|
||||||
onClick={() => {
|
<p className="text-xs text-neutral-500 mt-1 line-clamp-1">{event.text2}</p>
|
||||||
setSelectedEventId((prevEventId) => (prevEventId !== event.id ? event.id : ""));
|
</div>
|
||||||
}}
|
{event.magnitude ? <MagnitudeNumber magnitude={event.magnitude} /> : <></>}
|
||||||
onMouseEnter={() => setHoveredEventId(event.id)}
|
</button>
|
||||||
onMouseLeave={() => setHoveredEventId("")}
|
))}
|
||||||
>
|
</div>
|
||||||
<div className="flex-1">
|
</div>
|
||||||
<p className="text-sm font-medium text-neutral-800 line-clamp-1">{event.title}</p>
|
</div>
|
||||||
<p className="text-xs text-neutral-500 mt-1 line-clamp-1">{event.text2}</p>
|
</div>
|
||||||
</div>
|
);
|
||||||
{event.magnitude ? <MagnitudeNumber magnitude={event.magnitude} /> : <></>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
21
src/databases/Users.csv
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
name,email,password,level
|
||||||
|
undefined,undefined,undefined,undefined
|
||||||
|
undefined,undefined,undefined,undefined
|
||||||
|
undefined,undefined,undefined,undefined
|
||||||
|
undefined,undefined,undefined,undefined
|
||||||
|
undefined,undefined,undefined,undefined
|
||||||
|
undefined,undefined,undefined,undefined
|
||||||
|
undefined,undefined,undefined,undefined
|
||||||
|
undefined,undefined,undefined,undefined
|
||||||
|
undefined,undefined,undefined,undefined
|
||||||
|
undefined,undefined,undefined,undefined
|
||||||
|
undefined,undefined,undefined,undefined
|
||||||
|
undefined,undefined,undefined,undefined
|
||||||
|
undefined,undefined,undefined,undefined
|
||||||
|
undefined,undefined,undefined,undefined
|
||||||
|
undefined,undefined,undefined,undefined
|
||||||
|
undefined,undefined,undefined,undefined
|
||||||
|
undefined,undefined,undefined,undefined
|
||||||
|
undefined,undefined,undefined,undefined
|
||||||
|
undefined,undefined,undefined,undefined
|
||||||
|
undefined,undefined,undefined,undefined
|
||||||
|
12
src/lib/prisma.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// src/lib/prisma.ts
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const globalForPrisma = global as unknown as { prisma: PrismaClient };
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ||
|
||||||
|
new PrismaClient({
|
||||||
|
log: ["query", "error", "warn"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||||
@ -1,34 +1,60 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"moduleResolution": "node", // Use "node" module resolution strategy
|
"moduleResolution": "node",
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
"allowJs": true,
|
"dom",
|
||||||
"skipLibCheck": true,
|
"dom.iterable",
|
||||||
"strict": true,
|
"esnext"
|
||||||
"noEmit": true,
|
],
|
||||||
"esModuleInterop": true,
|
"allowJs": true,
|
||||||
"module": "ESNext",
|
"skipLibCheck": true,
|
||||||
"resolveJsonModule": true,
|
"strict": true,
|
||||||
"isolatedModules": true,
|
"noEmit": true,
|
||||||
"jsx": "preserve",
|
"esModuleInterop": true,
|
||||||
"incremental": true,
|
"module": "ESNext",
|
||||||
"plugins": [
|
"resolveJsonModule": true,
|
||||||
{
|
"isolatedModules": true,
|
||||||
"name": "next"
|
"jsx": "preserve",
|
||||||
}
|
"incremental": true,
|
||||||
],
|
"baseUrl": "src",
|
||||||
"paths": {
|
"plugins": [
|
||||||
"@components/*": ["./src/components/*"],
|
{
|
||||||
"@hooks/*": ["./src/hooks/*"],
|
"name": "next"
|
||||||
"@utils/*": ["./src/utils/*"],
|
}
|
||||||
"@appTypes/*": ["./src/types/*"],
|
],
|
||||||
"@zod/*": ["./src/zod/*"],
|
"paths": {
|
||||||
"@prismaclient": ["./src/generated/prisma/client"],
|
"@components/*": [
|
||||||
"@/*": ["./src/*"]
|
"./components/*"
|
||||||
}
|
],
|
||||||
},
|
"@hooks/*": [
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"./hooks/*"
|
||||||
"exclude": ["node_modules"]
|
],
|
||||||
|
"@utils/*": [
|
||||||
|
"./utils/*"
|
||||||
|
],
|
||||||
|
"@appTypes/*": [
|
||||||
|
"./types/*"
|
||||||
|
],
|
||||||
|
"@zod/*": [
|
||||||
|
"./zod/*"
|
||||||
|
],
|
||||||
|
"@prismaclient": [
|
||||||
|
"./generated/prisma/client"
|
||||||
|
],
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||