Compare commits

...

20 Commits

Author SHA1 Message Date
658cb92ace Merge branch 'master' of ssh://stash.dyson.global.corp:7999/~thowitz/tremor-tracker 2025-06-01 10:57:02 +01:00
IZZY
27dfc18154 Sorry for the spam last one i promise 2025-05-31 23:40:55 +01:00
IZZY
8f70ac60c6 Merge branch 'master' of ssh://stash.dyson.global.corp:7999/~thowitz/tremor-tracker i think this works?! 2025-05-31 23:35:00 +01:00
IZZY
6cfef6fe6a Search function 2025-05-31 23:25:51 +01:00
Emily Neighbour
2a6f8d1011 small fix 2025-05-31 23:02:33 +01:00
Emily Neighbour
88ef137449 home page finishes 2025-05-31 22:59:24 +01:00
IZZY
09b2034522 neatening 2025-05-31 22:35:17 +01:00
Emily Neighbour
ca80a2e043 new team members 2025-05-31 22:34:35 +01:00
Emily Neighbour
9a76ba9f54 Merge branches 'master' and 'master' of ssh://stash.dyson.global.corp:7999/~thowitz/tremor-tracker 2025-05-31 22:01:18 +01:00
Emily Neighbour
7ba82efc3c cart feature added 2025-05-31 21:58:05 +01:00
IZZY
a6e80b026a Merge branches 'master' and 'master' of ssh://stash.dyson.global.corp:7999/~thowitz/tremor-tracker 2025-05-31 21:38:59 +01:00
IZZY
8756b71878 logging for observatories big slay 2025-05-31 21:38:48 +01:00
Emily Neighbour
8d3575591d Merge branch 'master' of ssh://stash.dyson.global.corp:7999/~thowitz/tremor-tracker 2025-05-31 21:30:12 +01:00
Emily Neighbour
ba99d6a2be adding easter eggs to the contact page 2025-05-31 21:29:50 +01:00
IZZY
c2211747e2 Merge branch 'master' of ssh://stash.dyson.global.corp:7999/~thowitz/tremor-tracker 2025-05-31 21:26:08 +01:00
IZZY
d585cc908f logging works for earthquake 2025-05-31 21:25:59 +01:00
Lukeshan Thananchayan
647c531d20 Management and Admin pages 2025-05-31 19:52:50 +01:00
IZZY
4438953fab Easter Eggs 2025-05-31 17:54:56 +01:00
IZZY
587b6b7f01 Lots of changes to Log earthquake/observatory 2025-05-31 16:43:20 +01:00
IZZY
b73114afd1 earthquakes on home page 2025-05-31 11:55:57 +01:00
41 changed files with 3773 additions and 1690 deletions

249
package-lock.json generated
View File

@ -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",

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

BIN
public/StuartEnthusiast.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/crack1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

BIN
public/crack2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
public/lava.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
public/observe.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
public/pulsatingMap.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
public/stuart.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

BIN
public/team.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

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

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

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

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

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

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

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

View File

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

View File

@ -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([]); &times;
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">
&times;
</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>
);
} }

View File

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

View File

@ -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;
}

View File

@ -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>

File diff suppressed because it is too large Load Diff

View File

@ -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"
>&times;</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>
);
} }

View File

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

View File

@ -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 daybut 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 daybut 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>
);
} }

View File

@ -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>

View File

@ -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 />
</>
);
} }

View File

@ -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">&#169;</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">&#169;</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>
</>
);
} }

View 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"
>
&times;
</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"
>
&times;
</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>
);
}

View 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"
>&times;</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>
);
}

View 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"
>&times;</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">&times;</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>
);
}

View File

@ -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);

View File

@ -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>

View File

@ -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
View 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
1 name email password level
2 undefined undefined undefined undefined
3 undefined undefined undefined undefined
4 undefined undefined undefined undefined
5 undefined undefined undefined undefined
6 undefined undefined undefined undefined
7 undefined undefined undefined undefined
8 undefined undefined undefined undefined
9 undefined undefined undefined undefined
10 undefined undefined undefined undefined
11 undefined undefined undefined undefined
12 undefined undefined undefined undefined
13 undefined undefined undefined undefined
14 undefined undefined undefined undefined
15 undefined undefined undefined undefined
16 undefined undefined undefined undefined
17 undefined undefined undefined undefined
18 undefined undefined undefined undefined
19 undefined undefined undefined undefined
20 undefined undefined undefined undefined
21 undefined undefined undefined undefined

12
src/lib/prisma.ts Normal file
View 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;

View File

@ -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"
]
} }