Compare commits

..

No commits in common. "658cb92ace747d5b19b4dad3d6d5ddc1c619a50a" and "4c4e48fcd4ddc0beebf6cab441b7d097a9416fef" have entirely different histories.

41 changed files with 1692 additions and 3775 deletions

249
package-lock.json generated
View File

@ -16,7 +16,6 @@
"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",
@ -28,11 +27,9 @@
"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",
@ -249,59 +246,6 @@
"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",
@ -1071,15 +1015,6 @@
"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",
@ -2558,15 +2493,6 @@
"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",
@ -2857,16 +2783,6 @@
"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",
@ -5680,47 +5596,6 @@
} }
} }
}, },
"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",
@ -5759,12 +5634,6 @@
"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",
@ -5897,15 +5766,6 @@
"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",
@ -5927,51 +5787,6 @@
"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",
@ -6323,28 +6138,6 @@
"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",
@ -6355,12 +6148,6 @@
"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",
@ -6537,21 +6324,6 @@
"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",
@ -7572,12 +7344,6 @@
"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",
@ -8066,15 +7832,6 @@
"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",
@ -8311,12 +8068,6 @@
"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,7 +19,6 @@
"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",
@ -31,11 +30,9 @@
"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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 673 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -1,569 +1,386 @@
"use client"; "use client";
import React, { useRef, useState, useEffect } from "react"; import React, { useRef, useState } 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;
createdAt: string; password: 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: "users-loading@admin.api", { email: "jane@example.com", name: "Jane Smith", role: "GUEST", password: "secret2", createdAt: "2024-06-21T10:01:09Z", id: 2 },
name: "Loading Users", {
role: "ADMIN", email: "bob@example.com",
createdAt: "Check admin api and frontend", name: "Bob Brown",
id: 0, role: "SCIENTIST",
}, 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 = [
{ label: "Name", value: "name" }, // Sort box options
{ label: "Email", value: "email" }, { label: "Name", value: "name" },
{ 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() {
// ---- All hooks at the top! const [users, setUsers] = useState<User[]>(initialUsers);
const user = useStoreState((state) => state.user); const [selectedEmail, setSelectedEmail] = useState<string | null>(null);
const [selectedEmail, setSelectedEmail] = useState<string | null>(null); // Local edit state for SCIENTIST form
const [users, setUsers] = useState<User[]>(initialUsers); const [editUser, setEditUser] = useState<User | null>(null);
const [addOpen, setAddOpen] = useState(false); // Reset editUser when the selected user changes
const [addForm, setAddForm] = useState<{ name: string; email: string; role: Role; password: string }>({ React.useEffect(() => {
name: "", if (!selectedEmail) setEditUser(null);
email: "", else {
role: "SCIENTIST", const user = users.find((u) => u.email === selectedEmail);
password: "", setEditUser(user ? { ...user } : null);
}); }
const [addError, setAddError] = useState<string | null>(null); }, [selectedEmail, users]);
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);
useEffect(() => { // Search/filter/sort state
async function fetchUsers() { const [searchField, setSearchField] = useState<"name" | "email">("name");
try { const [searchText, setSearchText] = useState("");
const res = await fetch("/api/admin"); const [roleFilter, setRoleFilter] = useState<Role | "all">("all");
if (!res.ok) throw new Error("Failed to fetch"); const [sortField, setSortField] = useState<SortField>("name");
const data = await res.json(); const [sortDir, setSortDir] = useState<SortDir>("asc");
setUsers(data.users); // Dropdown states
} catch (err) { const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
console.error("Error fetching users:", err); const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
} 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"];
// --- ADMIN ONLY: React.useEffect(() => {
if (!user || user.role !== "ADMIN") { const handleClick = (e: MouseEvent) => {
return ( if (filterDropdownRef.current && !filterDropdownRef.current.contains(e.target as Node)) setFilterDropdownOpen(false);
<div className="flex items-center justify-center min-h-[70vh] flex-col"> if (sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as Node)) setSortDropdownOpen(false);
<h1 className="text-2xl font-bold text-red-500 mb-4"> };
Unauthorized Access document.addEventListener("mousedown", handleClick);
</h1> return () => document.removeEventListener("mousedown", handleClick);
<div className="text-gray-600">You do not have access to this page.</div> }, []);
</div>
);
}
// --- Render admin UI // Filtering, searching, sorting logic
return ( const filteredUsers = users.filter((user) => roleFilter === "all" || user.role === roleFilter);
<div className="flex flex-col h-full"> const searchedUsers = filteredUsers.filter((user) => user[searchField].toLowerCase().includes(searchText.toLowerCase()));
<div className="flex h-full overflow-hidden bg-gray-50"> const sortedUsers = [...searchedUsers].sort((a, b) => {
{/* SIDEBAR */} let cmp = a[sortField].localeCompare(b[sortField]);
<div className="w-80 h-full border-r border-neutral-200 bg-neutral-100 flex flex-col rounded-l-xl shadow-sm"> return sortDir === "asc" ? cmp : -cmp;
<div className="p-4 flex flex-col h-full"> });
{/* Search, filter, sort controls ... (your code unchanged) */}
<div className="mb-3 flex gap-2"> // Form input change handler
<input const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
className="flex-1 border rounded-lg px-2 py-1 text-sm" if (!editUser) return;
placeholder={`Search by ${searchField}`} const { name, value } = e.target;
value={searchText} setEditUser((prev) => (prev ? { ...prev, [name]: value } : null));
onChange={(e) => setSearchText(e.target.value)} };
/>
<button // Update button logic (compare original selectedUser and editUser)
type="button" const selectedUser = users.find((u) => u.email === selectedEmail);
className="border rounded-lg px-2 py-1 text-sm bg-white hover:bg-neutral-100 transition font-semibold" const isEditChanged = React.useMemo(() => {
style={{ width: "80px" }} if (!editUser || !selectedUser) return false;
onClick={() => setSearchField((field) => (field === "name" ? "email" : "name"))} // Compare primitive fields
title={`Switch to searching by ${searchField === "name" ? "Email" : "Name"}`} return (
> editUser.name !== selectedUser.name || editUser.role !== selectedUser.role || editUser.password !== selectedUser.password
{searchField === "name" ? "Email" : "Name"} );
</button> }, [editUser, selectedUser]);
</div>
<div className="flex gap-2 items-center mb-2"> // Update/save changes
{/* Filter */} const handleUpdate = (e: React.FormEvent) => {
<div className="relative" ref={filterDropdownRef}> e.preventDefault();
<button if (!editUser) return;
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition setUsers((prev) => prev.map((u) => (u.email === editUser.email ? { ...editUser } : u)));
${roleFilter !== "all" // todo create receiving api route
? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700" // todo send to api route
: "bg-white text-gray-700 border hover:bg-neutral-200" // After successful update, update selectedUser local state
} // (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>
{/* ADD BUTTON */} </div>
<button {/* Sort status text */}
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" <small className="text-xs text-gray-500 mb-2 px-1">
type="button" Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
style={{ minWidth: 36, minHeight: 36 }} </small>
onClick={() => setAddOpen(true)} {/* USERS LIST: full height, scrollable */}
disabled={addOpen} <ul className="overflow-y-auto flex-1 pr-1">
title="Add user" {sortedUsers.map((user) => (
> <li
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2.2} viewBox="0 0 24 24"> key={user.email}
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m7-7H5" /> onClick={() => setSelectedEmail(user.email)}
</svg> className={`rounded-lg cursor-pointer border
</button> ${selectedEmail === user.email ? "bg-blue-100 border-blue-400" : "hover:bg-gray-200 border-transparent"}
</div> transition px-2 py-1 mb-1`}
<small className="text-xs text-gray-500 mb-2 px-1"> >
Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]} <div className="flex items-center justify-between">
</small> <span className="text-sm font-medium truncate">{user.name}</span>
{/* USERS LIST */} <span className="ml-1 text-xs px-2 py-0.5 rounded-lg bg-gray-200 text-gray-700">{roleLabels[user.role]}</span>
<ul className="overflow-y-auto flex-1 pr-1"> </div>
{sortedUsers.map((user) => ( <div className="flex items-center justify-between mt-0.5">
<li <span className="text-xs text-gray-600 truncate">{user.email}</span>
key={user.email} </div>
onClick={() => setSelectedEmail(user.email)} </li>
className={`rounded-lg cursor-pointer border ))}
${selectedEmail === user.email ? "bg-blue-100 border-blue-400" : "hover:bg-gray-200 border-transparent"} {sortedUsers.length === 0 && <li className="text-gray-400 text-center py-6">No users found.</li>}
transition px-2 py-1 mb-1`} </ul>
> </div>
<div className="flex items-center justify-between"> </div>
<span className="text-sm font-medium truncate">{user.name}</span> {/* MAIN PANEL */}
<span className="ml-1 text-xs px-2 py-0.5 rounded-lg bg-gray-200 text-gray-700">{roleLabels[user.role]}</span> <div className="flex-1 p-24 bg-white overflow-y-auto">
</div> {editUser ? (
<div className="flex items-center justify-between mt-0.5"> <div className="max-w-lg mx-auto bg-white p-6 rounded-lg shadow">
<span className="text-xs text-gray-600 truncate">{user.email}</span> <h2 className="text-lg font-bold mb-6">Edit User</h2>
</div> <form className="space-y-4" onSubmit={handleUpdate}>
</li> <div className="flex items-center gap-2 mb-2">
))} <label className="text-sm font-medium text-gray-700">Account Creation Time:</label>
{sortedUsers.length === 0 && <li className="text-gray-400 text-center py-6">No users found.</li>} <span className="text-sm text-gray-500">{editUser.createdAt}</span>
</ul> </div>
</div> <div className="flex items-center gap-2 mb-2">
</div> <label className="text-sm font-medium text-gray-700">Account ID Number:</label>
{/* MAIN PANEL */} <span className="text-sm text-gray-500">{editUser.id}</span>
<div className="flex-1 p-24 bg-white overflow-y-auto"> </div>
{/* Add User Modal */} <div className="relative">
{addOpen && ( <label className="block text-sm font-medium text-gray-700 mb-1">Email (unique):</label>
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-30"> <input
<div className="bg-white rounded-lg shadow-lg p-8 max-w-sm w-full relative"> className="w-full border px-3 py-2 rounded-lg outline-none bg-gray-100 cursor-not-allowed"
<h3 className="text-lg font-bold mb-4">Add New User</h3> type="email"
<form onSubmit={handleAddUser} className="space-y-3"> name="email"
<div> value={editUser.email}
<label className="block text-sm font-medium mb-1">Email</label> readOnly
<input onMouseEnter={() => setShowEmailTooltip(true)}
className="w-full border px-2 py-1 rounded-lg" onMouseLeave={() => setShowEmailTooltip(false)}
type="email" />
required {/* Custom tooltip */}
value={addForm.email} {showEmailTooltip && (
onChange={e => setAddForm(f => ({ ...f, email: e.target.value }))} <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 />
</div> To change the email, delete and re-add the user.
<div> </div>
<label className="block text-sm font-medium mb-1">Name</label> )}
<input </div>
className="w-full border px-2 py-1 rounded-lg" <div>
type="text" <label className="block text-sm font-medium text-gray-700 mb-1">Name:</label>
required <input
value={addForm.name} className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
onChange={e => setAddForm(f => ({ ...f, name: e.target.value }))} type="text"
/> name="name"
</div> value={editUser.name}
<div> onChange={handleEditChange}
<label className="block text-sm font-medium mb-1">Role</label> />
<select </div>
className="w-full border px-2 py-1 rounded-lg" <div>
value={addForm.role} <label className="block text-sm font-medium text-gray-700 mb-1">Role:</label>
onChange={e => setAddForm(f => ({ ...f, role: e.target.value as Role }))} <select
> className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
{allRoles.map(role => ( name="role"
<option value={role} key={role}>{roleLabels[role]}</option> value={editUser.role}
))} onChange={handleEditChange}
</select> >
</div> {allRoles.map((role) => (
<div> <option key={role} value={role}>
<label className="block text-sm font-medium mb-1">Password</label> {roleLabels[role]}
<input </option>
className="w-full border px-2 py-1 rounded-lg" ))}
type="text" </select>
required </div>
value={addForm.password} <div>
onChange={e => setAddForm(f => ({ ...f, password: e.target.value }))} <label className="block text-sm font-medium text-gray-700 mb-1">Password:</label>
/> <input
</div> className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
{addError && <div className="text-red-600 text-xs">{addError}</div>} type="text"
<div className="flex gap-2 justify-end pt-2"> name="password"
<button value={editUser.password}
type="button" onChange={handleEditChange}
className="px-3 py-1 rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 transition" />
onClick={() => setAddOpen(false)} </div>
disabled={addLoading} <div className="flex gap-2 justify-end pt-6">
> <button
Cancel type="button"
</button> className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white font-semibold rounded-lg shadow transition"
<button onClick={handleDelete}
type="submit" >
className={`px-3 py-1 rounded-lg text-white font-semibold ${addLoading ? "bg-green-400" : "bg-green-600 hover:bg-green-700"}`} Delete
disabled={addLoading} </button>
> <button
{addLoading ? "Adding..." : "Add"} type="submit"
</button> className={`px-4 py-2 rounded-lg font-semibold transition
</div> ${
</form> isEditChanged
</div> ? "bg-blue-600 hover:bg-blue-700 text-white shadow"
</div> : "bg-gray-300 text-gray-500 cursor-not-allowed"
)} }`}
disabled={!isEditChanged}
{/* Edit User Panel */} >
{editUser ? ( Update
<div className="max-w-lg mx-auto bg-white p-6 rounded-lg shadow"> </button>
<h2 className="text-lg font-bold mb-6">Edit User</h2> </div>
<form className="space-y-4" onSubmit={handleUpdate}> </form>
<div className="flex items-center gap-2 mb-2"> </div>
<label className="text-sm font-medium text-gray-700"> ) : (
Account Creation Time: <div className="text-center text-gray-400 mt-16 text-lg">Select a user...</div>
</label> )}
<span className="text-sm text-gray-500">{editUser.createdAt}</span> </div>
</div> </div>
<div className="flex items-center gap-2 mb-2"> </div>
<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>
);
}

View File

@ -1,126 +0,0 @@
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

@ -1,52 +0,0 @@
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

@ -1,46 +0,0 @@
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

@ -1,21 +0,0 @@
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

@ -1,80 +0,0 @@
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

@ -1,34 +0,0 @@
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

@ -1,16 +0,0 @@
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,150 +1,28 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, { useState } from "react";
import BottomFooter from "@components/BottomFooter"; import BottomFooter from "@components/BottomFooter";
const ContactUs = () => { const ContactUs = () => {
// Form/modal const [formData, setFormData] = useState({
const [formData, setFormData] = useState({ name: "", email: "", message: "" }); name: "",
const [isModalOpen, setIsModalOpen] = useState(false); email: "",
message: "",
});
// 1. Lava (Instagram): state & timer const handleChange = (e: { target: { name: any; value: any } }) => {
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);
setIsModalOpen(true); alert("Thank you for reaching out! We will get back to you soon.");
setFormData({ name: "", email: "", message: "" }); setFormData({ name: "", email: "", message: "" });
}; };
return ( return (
<div className="min-h-screen relative text-white border border-black "> <div className="h-full relative text-white border border-black overflow-hidden">
{/* 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}
@ -152,8 +30,9 @@ 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="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"> <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">
{/* 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 */}
@ -162,6 +41,8 @@ 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">
@ -181,6 +62,7 @@ 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
@ -196,6 +78,7 @@ 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
@ -212,6 +95,7 @@ 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"
@ -220,6 +104,7 @@ 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>
@ -235,51 +120,23 @@ 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 Sweentown Row, Greenwich, London, SE3 0FQ</p> <p className="text-neutral-600 font-medium">1 Swentown 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">
{/* Instagram: Lava Flood */} <a href="#" className="w-20 h-20 text-blue-600 hover:text-blue-800 transition duration-200">
<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>
{/* Facebook: Pulsating Map */} <a href="#" className="w-20 h-20 text-blue-600 hover:text-blue-800 transition duration-200">
<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>
{/* X: Crack & Collapse */} <a href="#" className="w-20 h-20 p-4 text-blue-600 hover:text-blue-800 transition duration-200">
<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>
{/* LinkedIn: Shake */} <a href="#" className="w-20 h-20 flex items-center text-blue-600 hover:text-blue-800 transition duration-200">
<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>
@ -288,7 +145,7 @@ const ContactUs = () => {
</div> </div>
</div> </div>
</div> </div>
<BottomFooter /> <BottomFooter />
</div> </div>
); );
}; };

View File

@ -1,4 +1,5 @@
"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";
@ -7,133 +8,152 @@ 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 EarthquakeSearchModal from "@components/EarthquakeSearchModal"; import axios from "axios";
import EarthquakeLogModal from "@components/EarthquakeLogModal"; // If you use a separate log modal
import { useStoreState } from "@hooks/store";
// Optional: "No Access Modal" - as in your original // todo (optional) add in filtering of map earthquakes
function NoAccessModal({ open, onClose }) {
if (!open) return null; // --- SEARCH MODAL COMPONENT ---
return ( function EarthquakeSearchModal({ open, onClose, onSelect }) {
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center"> const [search, setSearch] = useState("");
<div className="bg-white rounded-lg shadow-lg p-8 max-w-xs w-full text-center relative"> const [results, setResults] = useState([]);
<button const [loading, setLoading] = useState(false);
onClick={onClose}
className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg" const handleSearch = async (e) => {
aria-label="Close" e.preventDefault();
> setLoading(true);
&times; setResults([]);
</button> try {
<h2 className="font-bold text-xl mb-4">Access Denied</h2> const res = await axios.post("/api/earthquakes/search", { query: search });
<p className="text-gray-600 mb-3"> setResults(res.data.earthquakes || []);
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 } catch (e) {
</p> alert("Failed to search.");
<button }
onClick={onClose} setLoading(false);
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 mt-2" };
>OK</button>
</div> if (!open) return null;
</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">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);
// Your user/role logic // Search modal state
const user = useStoreState((state) => state.user); const [searchModalOpen, setSearchModalOpen] = useState(false);
const role: "GUEST" | "SCIENTIST" | "ADMIN" = user?.role ?? "GUEST";
const canLogEarthquake = role === "SCIENTIST" || role === "ADMIN";
// Fetch earthquakes (10 days recent) // Fetch recent earthquakes as before
const { data, error, isLoading, mutate } = useSWR( const { data, error, isLoading } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 5 }));
"/api/earthquakes",
createPoster({ rangeDaysPrev: 10 })
);
// Shape for Map/Sidebar // Prepare events for maps/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.location || (x.code && x.code.split("-")[2])}`, title: `Earthquake in ${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( .sort((a: GeologicalEvent, b: GeologicalEvent) => new Date(b.date).getTime() - new Date(a.date).getTime())
(a: GeologicalEvent, b: GeologicalEvent) => : [],
new Date(b.date).getTime() - new Date(a.date).getTime() [data]
) );
: [],
[data]
);
// Handler for log // Optional: show details of selected search result (not implemented here)
const handleLogClick = () => { // const [selectedSearchResult, setSelectedSearchResult] = useState(null);
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"
onButton1Click={handleLogClick} onButton2Click={() => setSearchModalOpen(true)} // <-- important!
onButton2Click={() => setSearchModalOpen(true)} />
button1Disabled={!canLogEarthquake} <EarthquakeSearchModal
/> open={searchModalOpen}
{/* ---- SEARCH MODAL ---- */} onClose={() => setSearchModalOpen(false)}
<EarthquakeSearchModal onSelect={(eq) => {
open={searchModalOpen} setSelectedEventId(eq.code); // select on map/sidebar
onClose={() => setSearchModalOpen(false)} // setSelectedSearchResult(eq); // you can use this if you want to show detail modal
onSelect={(eq) => setSelectedEventId(eq.code)} }}
/> />
{/* ---- LOGGING MODAL ---- */} </div>
<EarthquakeLogModal );
open={logModalOpen} }
onClose={() => setLogModalOpen(false)}
onSuccess={() => mutate()}
/>
{/* ---- NO ACCESS ---- */}
<NoAccessModal
open={noAccessModalOpen}
onClose={() => setNoAccessModalOpen(false)}
/>
</div>
);
}

View File

@ -0,0 +1,25 @@
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

@ -64,167 +64,4 @@ body {
.icon-link:focus p { .icon-link:focus p {
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</li> <li>Torch (flashlight)</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,116 +1,70 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { 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";
function NoAccessModal({ open, onClose }) { // todo add in showing of observatory stats when searching
if (!open) return null; // todo add in deleting observatory when searching
return ( // todo add in changing colour of observatory icons if non-functional
<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 [logModalOpen, setLogModalOpen] = useState(false); const { data, error, isLoading } = useSWR("/api/observatories", fetcher);
const [noAccessModalOpen, setNoAccessModalOpen] = useState(false);
const user = useStoreState((state) => state.user);
const role: "GUEST" | "SCIENTIST" | "ADMIN" = user?.role ?? "GUEST";
const canLogObservatory = role === "SCIENTIST" || role === "ADMIN";
const { data, error, isLoading, mutate } = useSWR( // todo add in earthquake events
"/api/observatories", const observatoryEvents = useMemo(
fetcher () =>
); 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]
);
const observatoryEvents = useMemo( return (
() => <div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden">
data && data.observatories <div className="flex-grow h-full">
? data.observatories <Map
.map((x: Observatory): GeologicalEvent & { isFunctional: boolean } => ({ events={observatoryEvents}
id: x.id.toString(), selectedEventId={selectedEventId}
title: ` ${x.name}`, setSelectedEventId={setSelectedEventId}
longitude: x.longitude, hoveredEventId={hoveredEventId}
latitude: x.latitude, setHoveredEventId={setHoveredEventId}
isFunctional: x.isFunctional, // <-- include this! mapType="observatories"
text1: "", />
text2: getRelativeDate(x.dateEstablished), </div>
date: x.dateEstablished, <Sidebar
})) logTitle="Observatory Mapping"
.sort( logSubtitle="Record and search observatories - time/date set-up, location, scientists and recent earthquakes"
(a: GeologicalEvent, b: GeologicalEvent) => recentsTitle="Observatory Events"
new Date(b.date).getTime() - new Date(a.date).getTime() events={observatoryEvents}
) selectedEventId={selectedEventId}
: [], setSelectedEventId={setSelectedEventId}
[data] hoveredEventId={hoveredEventId}
); setHoveredEventId={setHoveredEventId}
button1Name="Log a New Observatory"
const handleLogClick = () => { button2Name="Search Observatories"
if (canLogObservatory) { />
setLogModalOpen(true); </div>
} 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,21 +1,20 @@
"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 bg-fixed bg-cover bg-center text-white " className="relative h-full 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 py-auto"> <div className="relative z-20 flex flex-col items-center justify-center h-full 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 text-black">Our Mission</h1> <h1 className="text-4xl font-bold text-center tracking-tight mb-2 drop-shadow-lg">Our Mission</h1>
<p className="text-lg text-center max-w-2xl text-gray-800 drop-shadow-md">Earthquake awareness accessible for everyone</p> <p className="text-lg text-center max-w-2xl text-white 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">
@ -24,15 +23,6 @@ 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
@ -63,7 +53,6 @@ function OurMission() {
</div> </div>
</div> </div>
</div> </div>
<BottomFooter />
</div> </div>
); );
} }

View File

@ -1,214 +1,241 @@
"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() {
const { data, error, isLoading } = useSWR( return (
"/api/earthquakes", <main className="min-h-screen text-black">
createPoster({ rangeDaysPrev: 6 }) <div className="w-full relative">
); <div className="">
// Take 5 most recent <Image height={2000} width={2000} alt="Background Image" src="/lava_flows.jpg"></Image>
const recents = (data?.earthquakes ?? []) </div>
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) <div className="absolute inset-0 bg-gradient-to-b from-transparent via-black/10 to-black/40"></div>
.slice(0, 5); <div className="absolute inset-0 top-[30%]">
<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 (
<main className="min-h-screen text-black"> // <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)]">
<div className="w-full relative"> // <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<div> // <Image
<Image height={2000} width={2000} alt="Background Image" src="/lava_flows.jpg" /> // className="dark:invert"
</div> // src="/next.svg"
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-black/10 to-black/40"></div> // alt="Next.js logo"
<div className="absolute inset-0 top-[30%]"> // width={180}
<Image className="mx-auto" height={300} width={1000} alt="Title Image" src="/tremortrackertext.png" /> // height={38}
</div> // priority
</div> // />
<p className="mt-2"></p> // <ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<div className="flex flex-col md:flex-row md:justify-evenly gap-6 mt-2"> // <li className="mb-2">
<Link href="/earthquakes" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300"> // Get started by editing{" "}
<Image height={100} width={100} src="/earthquake.png" alt="Education Icon" className="h-40 w-40 mb-4" /> // <code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
<h3 className="text-xl font-bold text-black mb-4">Earthquakes</h3> // src/app/page.tsx
<p className="text-md text-black text-center max-w-xs opacity-90"> // </code>
Log new earthquakes with their required details or search past seismic events // .
</p> // </li>
</Link> // <li>Save and see your changes instantly.</li>
<Link // </ol>
href="/observatories"
className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300" // <div className="flex gap-4 items-center flex-col sm:flex-row">
> // <a
<Image height={100} width={100} src="/observe.png" alt="Research Icon" className="h-40 w-40 mb-4" /> // 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"
<h3 className="text-xl font-bold text-black mb-4">Observatories</h3> // href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
<p className="text-md text-black text-center max-w-xs opacity-90"> // target="_blank"
Find recently active observatories, and newly opened/closed sites // rel="noopener noreferrer"
</p> // >
</Link> // <Image
<Link href="/shop" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300"> // className="dark:invert"
<Image height={100} width={100} src="/artefact.png" alt="Technology Icon" className="h-40 w-40 mb-4" /> // src="/vercel.svg"
<h3 className="text-xl font-bold text-black mb-4">Artefacts</h3> // alt="Vercel logomark"
<p className="text-md text-black text-center max-w-xs opacity-90"> // width={20}
View or purchase recently discovered artefacts from seismic events // height={20}
</p> // />
</Link> // Deploy now
</div> // </a>
<p className="mt-18"></p> // <a
<section className="min-h-screen text-black"> // 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"
<div className="w-full relative z-40"> // href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
<div> // target="_blank"
<Image height={2500} width={2000} alt="Background Image" src="/earthquakesMap.jpg" /> // rel="noopener noreferrer"
</div> // >
<div className="border absolute top-0 inset-0 bg-gradient-to-b from-transparent via-black/10 to-black/40"> // Read our docs
<section className="relative z-10 flex flex-col items-center text-center w-full px-4 py-14 mt-6"> // </a>
<h1 className="text-4xl md:text-5xl font-sans font-bold text-white drop-shadow-lg mb-4 tracking-tight z-10"> // </div>
Welcome to Tremor Tracker // </main>
</h1> // <footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
<p className="text-lg md:text-xl font-sans text-white w-4/6 mx-auto drop-shadow-md z-10"> // <a
TremorTracker is a non-profit website and research company, that aims to provide true, reliable data. Our mission // className="flex items-center gap-2 hover:underline hover:underline-offset-4"
is seismic education and preparation for all // href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
</p> // target="_blank"
<p className="mt-20"></p> // rel="noopener noreferrer"
<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"> // <Image
Earthquakes are a shaking of the earth's surface caused by a sudden release of energy underground. They can range // aria-hidden
in size, from tiny trembles to large quakes, which can cause destruction and even tsunamis. Hundreds of // src="/file.svg"
earthquakes happen every daybut most are too small to feel. // alt="File icon"
</p> // width={16}
<p className="mt-20"></p> // height={16}
<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? // Learn
</p> // </a>
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10"> // <a
What information are we interested in? // className="flex items-center gap-2 hover:underline hover:underline-offset-4"
</p> // href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
<p className="text-lg md:text-xl text-white w-4/6 mx-auto drop-shadow-md z-10">info</p> // target="_blank"
<p className="mt-20"></p> // rel="noopener noreferrer"
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10"> // >
What are observatories? // <Image
</p> // aria-hidden
<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> // src="/window.svg"
<p className="text-lg md:text-xl text-white w-4/6 mx-auto drop-shadow-md z-10">info</p> // alt="Window icon"
</section> // width={16}
</div> // height={16}
</div> // />
</section> // Examples
<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"> // <a
<h1 className="text-3xl md:text-3xl font-bold text-black drop-shadow-lg mb-3 tracking-tight"> // className="flex items-center gap-2 hover:underline hover:underline-offset-4"
Recent Earthquake Events // href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
</h1> // target="_blank"
<p className="text-lg md:text-xl text-black drop-shadow-md"> // rel="noopener noreferrer"
Learn about the most recent earthquake events from around the world: // >
</p> // <Image
</section> // aria-hidden
<p className="mt-6"></p> // src="/globe.svg"
<div className="mx-auto w-5/6 px-2"> // alt="Globe icon"
{error && ( // width={16}
<div className="border rounded-xl bg-white bg-opacity-90 shadow-md p-4 mb-2"> // height={16}
<p>Failed to load earthquakes.</p> // />
</div> // Go to nextjs.org →
)} // </a>
{isLoading && ( // </footer>
<div className="border rounded-xl bg-white bg-opacity-90 shadow-md p-4 mb-2"> // </div>
<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,41 +1,43 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { useCallback, useEffect, useState } from "react"; import { Dispatch, SetStateAction, 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, location: a.warehouseArea, // your database
earthquakeID: a.earthquakeId?.toString() ?? "", earthquakeID: a.earthquakeId?.toString() ?? "",
observatory: a.type ?? "", observatory: a.type ?? "", // if you want to display 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, price: a.shopPrice ?? 100, // fallback price if not in DB
})); }));
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);
@ -45,10 +47,9 @@ export default function Shop() {
}, []); }, []);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [selectedArtefact, setSelectedArtefact] = useState<ExtendedArtefact | null>(null); const [selectedArtefact, setSelectedArtefact] = useState<Artefact | null>(null);
const [showPaymentModal, setShowPaymentModal] = useState(false); const [showPaymentModal, setShowPaymentModal] = useState(false);
const [artefactToBuy, setArtefactToBuy] = useState<ExtendedArtefact | null>(null); const [artefactToBuy, setArtefactToBuy] = useState<Artefact | 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);
@ -92,14 +93,11 @@ 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"
@ -123,32 +121,16 @@ 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 flex-col sm:flex-row justify-end gap-4 mt-4 mr-2"> <div className="flex justify-end gap-4 mt-4 mr-2">
<button <button
onClick={() => { onClick={() => {
if (!inCart) setCart((cart) => [...cart, artefact]); setArtefactToBuy(artefact); // Set artefact for payment modal
setShowPaymentModal(true); // Show payment modal
setSelectedArtefact(null); // Close this modal
}} }}
disabled={inCart} className="px-10 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
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"
}
`}
> >
{inCart ? "In Cart" : "Add to Cart"} Buy
</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>
@ -156,99 +138,15 @@ export default function Shop() {
); );
} }
function CartModal() { function PaymentModal({ artefact, onClose }: { artefact: Artefact; onClose: () => void }) {
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(user?.email || ""); const [email, setEmail] = useState("");
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("@") &&
@ -264,13 +162,18 @@ 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("");
const paymentEmail = user?.email || email; if (email || user?.email) {
if (!validateEmail(paymentEmail)) { if (!validateEmail(email)) {
setError("Please enter a valid email"); setError("Please enter a valid email ending");
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;
@ -283,51 +186,46 @@ 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, ...artefactsToBuy.map((a) => a.id)]); setHiddenArtefactIds((ids) => [...ids, artefact.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-[12000]" className="fixed inset-0 bg-neutral-900 bg-opacity-60 flex justify-center items-center z-10"
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"> <h2 className="text-2xl font-bold mb-4">Buy {artefact.name}</h2>
Checkout {artefact ? artefact.name : artefactsToBuy.length + " item(s)"} {/* ...Image... */}
{!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();
}} }}
> >
{/* Email autofill */} {!user ? (
<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={user?.email ? user.email : email} value={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"
@ -370,13 +268,6 @@ 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
@ -421,28 +312,6 @@ 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
@ -481,22 +350,19 @@ export default function Shop() {
</footer> </footer>
</div> </div>
{selectedArtefact && <Modal artefact={selectedArtefact} />} {selectedArtefact && <Modal artefact={selectedArtefact} />}
{showCartModal && <CartModal />} {artefactToBuy && showPaymentModal && (
{showPaymentModal && (cartCheckout || artefactToBuy) && (
<PaymentModal <PaymentModal
artefact={cartCheckout ? undefined : artefactToBuy!} artefact={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 && !showCartModal && ( {!selectedArtefact && !showPaymentModal && !showThankYouModal && (
<div className="relative z-50"> <div className="relative z-50">
<BottomFooter /> <BottomFooter />
</div> </div>

View File

@ -1,98 +1,73 @@
"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: "Stuart Nicholson", name: "Tim Howitz",
title: "Chief Earthquake Enthusiast", title: "Chief Crack Inspector",
description: description:
"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.", "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: "/stuart.png", image: "/Timthescientist.PNG",
}, },
{ {
name: "Athena", name: "Emily Neighbour",
title: "Chief Software Engineer", title: "Chief Software Engineer",
description: "Athena is responsible for making all software dreams come true. <3", description:
image: "/athena.PNG", "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",
}, },
]; ];
export default function Page() { export default function Page() {
return ( return (
<> <div
<div className="min-h-screen relative flex flex-col items-center justify-center px-4 py-30"
className="min-h-screen relative flex flex-col items-center justify-center px-4 py-30" style={{ backgroundImage: "url('tectonicPlates.png')", backgroundSize: "cover", backgroundPosition: "center" }}
style={{ >
backgroundImage: "url('tectonicPlates.png')", {/* Overlay */}
backgroundSize: "cover", <div className="absolute inset-0 bg-black bg-opacity-50 pointer-events-none"></div>
backgroundPosition: "center" {/* Header */}
}} <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>
{/* Overlay */} <p className="text-lg text-center max-w-2xl text-white mb-12 drop-shadow-md">
<div className="absolute inset-0 bg-black bg-opacity-50 pointer-events-none"></div> Our world-class scientists and engineers drive innovation across the globe. Meet our four department heads:
{/* Header */} </p>
<div className="relative z-10 flex flex-col items-center mb-1"> </div>
<h1 className="text-4xl font-bold text-center text-white mb-4 tracking-tight drop-shadow-lg">Meet the Team</h1> {/* Team Members Section */}
<p className="text-lg text-center max-w-2xl text-white mb-12 drop-shadow-md"> <div className="relative z-10 flex flex-col items-center gap-6 w-full max-w-3xl">
Our world-class scientists and engineers drive innovation across the globe. Meet our four department heads: {teamMembers.map((member, index) => (
</p> <div
</div> key={index}
{/* Team Members Section */} className="flex bg-white bg-opacity-90 rounded-3xl border border-neutral-200 w-full shadow-md hover:shadow-lg transition-shadow duration-300"
<div className="relative z-10 flex flex-col items-center gap-6 w-full max-w-3xl"> >
{teamMembers.map((member, index) => ( {/* Image */}
<div <div className="flex items-center ml-6">
key={index} <div className="relative w-20 h-20">
className="flex bg-white bg-opacity-90 rounded-3xl border border-neutral-200 w-full shadow-md hover:shadow-lg transition-shadow duration-300" <div className="absolute inset-0 rounded-full overflow-hidden ring-4 ring-neutral-100">
> <img src={member.image} alt={member.name} className="h-full w-full object-cover" />
{/* Image */} </div>
<div className="flex items-center ml-6"> </div>
<div className="relative w-20 h-20"> </div>
<div className="absolute inset-0 rounded-full overflow-hidden ring-4 ring-neutral-100"> {/* Text Content */}
<img src={member.image} alt={member.name} className="h-full w-full object-cover" /> <div className="flex flex-col items-start pl-8 py-4 pr-6">
</div> <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>
{/* Text Content */} </div>
<div className="flex flex-col items-start pl-8 py-4 pr-6"> </div>
<h2 className="text-2xl font-bold text-neutral-800">{member.name}</h2> ))}
<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>
</div>
<BottomFooter />
</>
);
} }

View File

@ -1,178 +1,74 @@
import React, { useCallback, useRef, useState } from "react"; // components/Footer.tsx
import Link from "next/link"; import Link from "next/link";
import { FaFacebook, FaLinkedin, FaTwitter, FaYoutube } from "react-icons/fa";
export default function BottomFooter() { export default function Footer() {
// ig easter egg return (
const [lavaActive, setLavaActive] = useState(false); <footer className="bg-[#16424b] text-white pt-12 pb-4 px-6 mt-12 z-0">
const lavaTimeout = useRef<any>(null); <div className="max-w-6xl mx-auto flex flex-col md:flex-row justify-between gap-8">
{/* Useful Links */}
// LinkedIn easter egg <div className="min-w-[200px] mb-8 md:mb-0 flex-1">
const [shaking, setShaking] = useState(false); <h3 className="font-bold underline text-lg mb-3">Useful links</h3>
const shakeTimeout = useRef<any>(null); <ul className="space-y-2">
<li>
// x easter egg <Link
const [showCracks, setShowCracks] = useState(false); href="https://www.gov.uk/guidance/extreme-weather-and-natural-hazards"
const [collapse, setCollapse] = useState(false); className="hover:underline"
const crackTimeout = useRef<any>(null); target="_blank"
rel="noopener noreferrer"
// Lava flood handler (top-down flood) >
const handleInstagramClick = useCallback((e: React.MouseEvent) => { Gov.UK guidance
e.preventDefault(); </Link>
setLavaActive(true); </li>
clearTimeout(lavaTimeout.current); <li>
lavaTimeout.current = setTimeout(() => setLavaActive(false), 2000); <Link href="https://www.dysoninstitute.ac.uk/about-us/governance/privacy-notices/" className="hover:underline">
}, []); Privacy policy
</Link>
// LinkedIn shake handler </li>
const handleLinkedInClick = useCallback((e: React.MouseEvent) => { <li>
e.preventDefault(); <Link href="https://privacy.dyson.com/en/globalcookiepolicy.aspx" className="hover:underline">
if (shaking) return; // prevent stacking Cookies policy
setShaking(true); </Link>
const body = document.body; </li>
body.classList.remove("shake-screen"); </ul>
void body.offsetWidth; </div>
body.classList.add("shake-screen"); {/* Donate Section */}
shakeTimeout.current = setTimeout(() => { <div className="min-w-[220px] mb-8 md:mb-0 flex-1">
setShaking(false); <h3 className="font-bold underline text-lg mb-3">Donate</h3>
body.classList.remove("shake-screen"); <p className="mb-4">
}, 1000); We are a nonprofit entirely funded by your donations, every penny helps provide life saving information.
}, [shaking]); </p>
<Link
// X (crack and collapse) handler href="#"
const handleXClick = useCallback((e: React.MouseEvent) => { 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"
e.preventDefault(); >
setShowCracks(true); Donate Now
crackTimeout.current = setTimeout(() => { </Link>
setCollapse(true); </div>
setTimeout(() => { </div>
setShowCracks(false); {/* Bottom bar */}
setCollapse(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">
}, 1500); {/* Bottom left: Copyright */}
}, 1000); <span className="text-sm flex items-center">
}, []); <span className="mr-2">&#169;</span> TremorTracker 2025
</span>
React.useEffect(() => { {/* Bottom right: Social icons */}
return () => { <div className="flex flex-col items-end">
clearTimeout(lavaTimeout.current); <span className="text-sm mb-2">Follow us on</span>
clearTimeout(shakeTimeout.current); <div className="flex space-x-3">
clearTimeout(crackTimeout.current); {/* Replace src with your icon URLs, or use next/image if preferred */}
document.body.classList.remove("shake-screen"); <a href="#" target="_blank" rel="noopener noreferrer">
}; <img src="instagram.png" alt="instagram" className="h-7 w-7 rounded-full shadow" />
}, []); </a>
<a href="#" target="_blank" rel="noopener noreferrer">
return ( <img src="linkedin.png" alt="linkedin" className="h-7 w-7 rounded-full shadow" />
<> </a>
{/* Lava Flood Overlay */} <a href="#" target="_blank" rel="noopener noreferrer">
{lavaActive && ( <img src="x_logo.jpg" alt="X" className="h-7 w-7 rounded-full shadow" />
<div className="lava-flood-overlay lava-active"> </a>
<img src="/lava.jpg" alt="Lava flood" draggable={false} /> </div>
</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

@ -1,248 +0,0 @@
"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

@ -1,245 +0,0 @@
"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

@ -1,223 +0,0 @@
"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,12 +111,8 @@ function MapComponent({
} }
const observatoryElement = document.createElement("div"); const observatoryElement = document.createElement("div");
const root = createRoot(observatoryElement); const root = createRoot(observatoryElement);
root.render( root.render(<GiObservatory className="text-blue-600 text-2xl drop-shadow-lg" />);
<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,15 +134,11 @@ export default function Navbar({}: // currencySelector,
<ManagementNavbarButton name="Warehouse" href="/warehouse"></ManagementNavbarButton> <ManagementNavbarButton name="Warehouse" href="/warehouse"></ManagementNavbarButton>
</div> </div>
)} )}
{user && ( {user && (user.role === "SCIENTIST" || user.role === "ADMIN") && (
(user.role === "ADMIN" || <div className="flex h-full mr-5">
(user.role === "SCIENTIST" && user.scientist?.level === "SENIOR") <ManagementNavbarButton name="Scientist Management" href="/management"></ManagementNavbarButton>
) && ( </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,124 +1,120 @@
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 (
<div className="relative" style={{ color: getMagnitudeColor(magnitude) }}> return (
<TbHexagon size={40} className="drop-shadow-sm" /> <div className="relative" style={{ color: getMagnitudeColor(magnitude) }}>
<div className="absolute inset-0 flex items-center justify-center"> <TbHexagon size={40} className="drop-shadow-sm" />
<div className="flex items-baseline font-mono font-bold tracking-tight"> <div className="absolute inset-0 flex items-center justify-center">
<span className="text-xl -mr-1">{whole}</span> <div className="flex items-baseline font-mono font-bold tracking-tight">
<span className="text-xs ml-[1.5px] -mr-[2.5px]">.</span> <span className="text-xl -mr-1">{whole}</span>
<span className="text-xs -mr-[1px]">{decimal}</span> <span className="text-xs ml-[1.5px] -mr-[2.5px]">.</span>
</div> <span className="text-xs -mr-[1px]">{decimal}</span>
</div> </div>
</div> </div>
); </div>
);
} }
export default function Sidebar({ // todo change sidebar event highlighting on selection
logTitle,
logSubtitle,
recentsTitle,
events,
selectedEventId,
setSelectedEventId,
hoveredEventId,
setHoveredEventId,
button1Name,
button2Name,
onButton2Click,
onButton1Click,
button1Disabled = false,
}: SidebarProps) {
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]);
return ( export default function Sidebar({
<div className="flex flex-col h-full w-80 bg-gradient-to-b from-neutral-100 to-neutral-50 shadow-lg"> logTitle,
<div className="py-6 flex flex-col h-full"> logSubtitle,
<div className="px-6 pb-8 border-b border-neutral-200"> recentsTitle,
<h2 className="text-2xl font-bold text-neutral-800 mb-2">{logTitle}</h2> events,
<p className="text-sm text-neutral-600 leading-relaxed">{logSubtitle}</p> selectedEventId,
<button setSelectedEventId,
className={`mt-4 w-full py-2 px-4 rounded-lg transition-colors duration-200 font-medium hoveredEventId,
${button1Disabled setHoveredEventId,
? "bg-gray-300 text-gray-500 cursor-not-allowed" button1Name,
: "bg-blue-600 hover:bg-blue-700 text-white" button2Name,
}`} onButton2Click,
onClick={onButton1Click} }: SidebarProps) {
type="button" const eventsContainerRef = useRef<HTMLDivElement>(null);
tabIndex={button1Disabled ? -1 : 0} useEffect(() => {
aria-disabled={button1Disabled ? "true" : "false"} if (selectedEventId && eventsContainerRef.current) {
> const selectedEventElement = eventsContainerRef.current.querySelector(`[data-event-id="${selectedEventId}"]`);
{button1Name} if (selectedEventElement) {
</button> selectedEventElement.scrollIntoView({
<button block: "center",
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" behavior: "smooth",
onClick={onButton2Click} });
type="button" }
> }
{button2Name} }, [selectedEventId]);
</button>
</div> return (
<div className="px-6 pt-6"> <div className="flex flex-col h-full w-80 bg-gradient-to-b from-neutral-100 to-neutral-50 shadow-lg">
<h2 className="text-xl font-bold text-neutral-800 mb-4">{recentsTitle}</h2> <div className="py-6 flex flex-col h-full">
</div> <div className="px-6 pb-8 border-b border-neutral-200">
<div className="flex-1 px-6 overflow-y-auto" ref={eventsContainerRef}> <h2 className="text-2xl font-bold text-neutral-800 mb-2">{logTitle}</h2>
<div className="space-y-3"> <p className="text-sm text-neutral-600 leading-relaxed">{logSubtitle}</p>
{events.map((event) => (
<button <Link href="/">
key={event.id} <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">
data-event-id={event.id} {button1Name}
className={`w-full border ${hoveredEventId === event.id ? "bg-neutral-100" : "bg-white"} ${ </button>
selectedEventId === event.id ? "border-neutral-800" : "border-neutral-200" </Link>
} rounded-lg p-4 flex items-center gap-4 hover:bg-neutral-100 transition-colors duration-150 shadow-sm text-left`} {/* "Search Earthquakes" should NOT be wrapped in a Link! */}
onClick={() => { <button
setSelectedEventId((prevEventId) => (prevEventId !== event.id ? event.id : "")); 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"
}} onClick={onButton2Click}
onMouseEnter={() => setHoveredEventId(event.id)} type="button"
onMouseLeave={() => setHoveredEventId("")} >
> {button2Name}
<div className="flex-1"> </button>
<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 className="px-6 pt-6">
</div> <h2 className="text-xl font-bold text-neutral-800 mb-4">{recentsTitle}</h2>
{event.magnitude ? <MagnitudeNumber magnitude={event.magnitude} /> : <></>} </div>
</button> <div className="flex-1 px-6 overflow-y-auto" ref={eventsContainerRef}>
))} <div className="space-y-3">
</div> {events.map((event) => (
</div> <button
</div> key={event.id}
</div> data-event-id={event.id}
); className={`w-full border ${hoveredEventId === event.id ? "bg-neutral-100" : "bg-white"} ${
} selectedEventId === event.id ? "border-neutral-800" : "border-neutral-200"
} rounded-lg p-4 flex items-center gap-4 hover:bg-neutral-100 transition-colors duration-150 shadow-sm text-left`}
onClick={() => {
setSelectedEventId((prevEventId) => (prevEventId !== event.id ? event.id : ""));
}}
onMouseEnter={() => setHoveredEventId(event.id)}
onMouseLeave={() => setHoveredEventId("")}
>
<div className="flex-1">
<p className="text-sm font-medium text-neutral-800 line-clamp-1">{event.title}</p>
<p className="text-xs text-neutral-500 mt-1 line-clamp-1">{event.text2}</p>
</div>
{event.magnitude ? <MagnitudeNumber magnitude={event.magnitude} /> : <></>}
</button>
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -1,21 +0,0 @@
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

View File

@ -1,12 +0,0 @@
// 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,60 +1,34 @@
{ {
"compilerOptions": { "compilerOptions": {
"moduleResolution": "node", "moduleResolution": "node", // Use "node" module resolution strategy
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"target": "ESNext", "target": "ESNext",
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom", "allowJs": true,
"dom.iterable", "skipLibCheck": true,
"esnext" "strict": true,
], "noEmit": true,
"allowJs": true, "esModuleInterop": true,
"skipLibCheck": true, "module": "ESNext",
"strict": true, "resolveJsonModule": true,
"noEmit": true, "isolatedModules": true,
"esModuleInterop": true, "jsx": "preserve",
"module": "ESNext", "incremental": true,
"resolveJsonModule": true, "plugins": [
"isolatedModules": true, {
"jsx": "preserve", "name": "next"
"incremental": true, }
"baseUrl": "src", ],
"plugins": [ "paths": {
{ "@components/*": ["./src/components/*"],
"name": "next" "@hooks/*": ["./src/hooks/*"],
} "@utils/*": ["./src/utils/*"],
], "@appTypes/*": ["./src/types/*"],
"paths": { "@zod/*": ["./src/zod/*"],
"@components/*": [ "@prismaclient": ["./src/generated/prisma/client"],
"./components/*" "@/*": ["./src/*"]
], }
"@hooks/*": [ },
"./hooks/*" "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
], "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"
]
}