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,8 +1,6 @@
"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",
@ -14,233 +12,147 @@ type User = {
email: string; email: string;
name: string; name: string;
role: Role; role: Role;
password: string;
createdAt: string; createdAt: string;
}; };
// todo add fulfilling of requests
// todo create api route to get users, with auth for only admin
// todo add management of only junior scientists if senior scientist
// todo (optional) add display of each user's previous orders when selecting them
const initialUsers: User[] = [ const initialUsers: User[] = [
{ email: "john@example.com", name: "John Doe", role: "ADMIN", password: "secret1", createdAt: "2024-06-21T09:15:01Z", id: 1 },
{ email: "jane@example.com", name: "Jane Smith", role: "GUEST", password: "secret2", createdAt: "2024-06-21T10:01:09Z", id: 2 },
{ {
email: "users-loading@admin.api", email: "bob@example.com",
name: "Loading Users", name: "Bob Brown",
role: "ADMIN", role: "SCIENTIST",
createdAt: "Check admin api and frontend", password: "secret3",
id: 0, createdAt: "2024-06-21T12:13:45Z",
id: 3,
}, },
{
email: "alice@example.com",
name: "Alice Johnson",
role: "GUEST",
password: "secret4",
createdAt: "2024-06-20T18:43:20Z",
id: 4,
},
{ email: "eve@example.com", name: "Eve Black", role: "ADMIN", password: "secret5", createdAt: "2024-06-20T19:37:10Z", id: 5 },
{ email: "dave@example.com", name: "Dave Clark", role: "GUEST", password: "pw", createdAt: "2024-06-19T08:39:10Z", id: 6 },
{ email: "fred@example.com", name: "Fred Fox", role: "GUEST", password: "pw", createdAt: "2024-06-19T09:11:52Z", id: 7 },
{ email: "ginny@example.com", name: "Ginny Hall", role: "SCIENTIST", password: "pw", createdAt: "2024-06-17T14:56:27Z", id: 8 },
{ email: "harry@example.com", name: "Harry Lee", role: "ADMIN", password: "pw", createdAt: "2024-06-16T19:28:11Z", id: 9 },
{ email: "ivy@example.com", name: "Ivy Volt", role: "ADMIN", password: "pw", createdAt: "2024-06-15T21:04:05Z", id: 10 },
{ email: "kate@example.com", name: "Kate Moss", role: "SCIENTIST", password: "pw", createdAt: "2024-06-14T11:16:35Z", id: 11 },
{ email: "leo@example.com", name: "Leo Garrison", role: "GUEST", password: "pw", createdAt: "2024-06-12T08:02:51Z", id: 12 },
{ email: "isaac@example.com", name: "Isaac Yang", role: "GUEST", password: "pw", createdAt: "2024-06-12T15:43:29Z", id: 13 },
]; ];
const sortFields = [ const sortFields = [
// Sort box options
{ label: "Name", value: "name" }, { label: "Name", value: "name" },
{ label: "Email", value: "email" }, { label: "Email", value: "email" },
] as const; ] as const;
type SortField = (typeof sortFields)[number]["value"]; type SortField = (typeof sortFields)[number]["value"];
type SortDir = "asc" | "desc"; type SortDir = "asc" | "desc";
const dirLabels: Record<SortDir, string> = { asc: "ascending", desc: "descending" }; const dirLabels: Record<SortDir, string> = { asc: "ascending", desc: "descending" };
const fieldLabels: Record<SortField, string> = { name: "Name", email: "Email" }; const fieldLabels: Record<SortField, string> = { name: "Name", email: "Email" };
// =========== THE PAGE =============
export default function AdminPage() { export default function AdminPage() {
// ---- All hooks at the top!
const user = useStoreState((state) => state.user);
const [selectedEmail, setSelectedEmail] = useState<string | null>(null);
const [users, setUsers] = useState<User[]>(initialUsers); const [users, setUsers] = useState<User[]>(initialUsers);
const [addOpen, setAddOpen] = useState(false); const [selectedEmail, setSelectedEmail] = useState<string | null>(null);
const [addForm, setAddForm] = useState<{ name: string; email: string; role: Role; password: string }>({
name: "",
email: "",
role: "SCIENTIST",
password: "",
});
const [addError, setAddError] = useState<string | null>(null);
const [addLoading, setAddLoading] = useState(false);
const [editUser, setEditUser] = useState<User | null>(null);
const [searchField, setSearchField] = useState<"name" | "email">("name");
const [searchText, setSearchText] = useState("");
const [roleFilter, setRoleFilter] = useState<Role | "all">("all");
const [sortField, setSortField] = useState<SortField>("name");
const [sortDir, setSortDir] = useState<SortDir>("asc");
const [newPassword, setNewPassword] = useState<string>("");
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
const filterDropdownRef = useRef<HTMLDivElement>(null);
const sortDropdownRef = useRef<HTMLDivElement>(null);
const [showEmailTooltip, setShowEmailTooltip] = useState(false);
useEffect(() => { // Local edit state for SCIENTIST form
async function fetchUsers() { const [editUser, setEditUser] = useState<User | null>(null);
try { // Reset editUser when the selected user changes
const res = await fetch("/api/admin"); React.useEffect(() => {
if (!res.ok) throw new Error("Failed to fetch");
const data = await res.json();
setUsers(data.users);
} catch (err) {
console.error("Error fetching users:", err);
}
}
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); if (!selectedEmail) setEditUser(null);
else { else {
const user = users.find((u) => u.email === selectedEmail); const user = users.find((u) => u.email === selectedEmail);
setEditUser(user ? { ...user } : null); setEditUser(user ? { ...user } : null);
} }
}, [selectedEmail, users]); }, [selectedEmail, users]);
// Search/filter/sort state
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");
// Dropdown states
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
const filterDropdownRef = useRef<HTMLDivElement>(null);
const sortDropdownRef = useRef<HTMLDivElement>(null);
React.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);
}, []);
// Filtering, searching, sorting logic // Filtering, searching, sorting logic
const filteredUsers = users.filter( const filteredUsers = users.filter((user) => roleFilter === "all" || user.role === roleFilter);
(user) => roleFilter === "all" || user.role === roleFilter const searchedUsers = filteredUsers.filter((user) => user[searchField].toLowerCase().includes(searchText.toLowerCase()));
);
const searchedUsers = filteredUsers.filter((user) =>
user[searchField].toLowerCase().includes(searchText.toLowerCase())
);
const sortedUsers = [...searchedUsers].sort((a, b) => { const sortedUsers = [...searchedUsers].sort((a, b) => {
let cmp = a[sortField].localeCompare(b[sortField]); let cmp = a[sortField].localeCompare(b[sortField]);
return sortDir === "asc" ? cmp : -cmp; return sortDir === "asc" ? cmp : -cmp;
}); });
async function handleAddUser(e: React.FormEvent) {
e.preventDefault(); // Form input change handler
setAddError(null); const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
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; if (!editUser) return;
const { name, value } = e.target; const { name, value } = e.target;
setEditUser((prev) => (prev ? { ...prev, [name]: value } : null)); setEditUser((prev) => (prev ? { ...prev, [name]: value } : null));
}; };
// Update button logic (compare original selectedUser and editUser)
const selectedUser = users.find((u) => u.email === selectedEmail); const selectedUser = users.find((u) => u.email === selectedEmail);
const isEditChanged = React.useMemo(() => { const isEditChanged = React.useMemo(() => {
if (!editUser || !selectedUser) return false; if (!editUser || !selectedUser) return false;
// Compare primitive fields
return ( return (
editUser.name !== selectedUser.name || editUser.name !== selectedUser.name || editUser.role !== selectedUser.role || editUser.password !== selectedUser.password
editUser.role !== selectedUser.role ||
newPassword
); );
}, [editUser, selectedUser, newPassword]); }, [editUser, selectedUser]);
async function updateUserOnServer(user: User, password: string) {
const body: any = { // Update/save changes
id: user.id, const handleUpdate = (e: React.FormEvent) => {
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(); e.preventDefault();
if (!editUser) return; if (!editUser) return;
try { setUsers((prev) => prev.map((u) => (u.email === editUser.email ? { ...editUser } : u)));
const updated = await updateUserOnServer(editUser, newPassword); // todo create receiving api route
setUsers((prev) => // todo send to api route
prev.map((u) => (u.id === updated.id ? { ...updated } : u)) // After successful update, update selectedUser local state
); // (editUser will auto-sync due to useEffect on users)
setNewPassword("");
} catch (err) {
console.error("Failed to update user:", err);
}
}; };
const handleDelete = async () => {
// Delete user logic
const handleDelete = () => {
if (!selectedUser) return; if (!selectedUser) return;
if ( if (!window.confirm(`Are you sure you want to delete "${selectedUser.name}"? This cannot be undone.`)) return;
!window.confirm( setUsers((prev) => prev.filter((u) => u.email !== selectedUser.email));
`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); setSelectedEmail(null);
setEditUser(null); setEditUser(null);
} catch (err: any) {
alert(err?.message || "Delete failed!");
}
}; };
const allRoles: Role[] = ["ADMIN", "GUEST", "SCIENTIST"]; const allRoles: Role[] = ["ADMIN", "GUEST", "SCIENTIST"];
// --- ADMIN ONLY: // Tooltip handling for email field
if (!user || user.role !== "ADMIN") { const [showEmailTooltip, setShowEmailTooltip] = useState(false);
return (
<div className="flex items-center justify-center min-h-[70vh] flex-col">
<h1 className="text-2xl font-bold text-red-500 mb-4">
Unauthorized Access
</h1>
<div className="text-gray-600">You do not have access to this page.</div>
</div>
);
}
// --- Render admin UI
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex h-full overflow-hidden bg-gray-50"> <div className="flex h-full overflow-hidden bg-gray-50">
{/* SIDEBAR */} {/* 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="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"> <div className="p-4 flex flex-col h-full">
{/* Search, filter, sort controls ... (your code unchanged) */} {/* Search Bar */}
<div className="mb-3 flex gap-2"> <div className="mb-3 flex gap-2">
<input <input
className="flex-1 border rounded-lg px-2 py-1 text-sm" className="flex-1 border rounded-lg px-2 py-1 text-sm"
@ -251,19 +163,21 @@ export default function AdminPage() {
<button <button
type="button" type="button"
className="border rounded-lg px-2 py-1 text-sm bg-white hover:bg-neutral-100 transition font-semibold" className="border rounded-lg px-2 py-1 text-sm bg-white hover:bg-neutral-100 transition font-semibold"
style={{ width: "80px" }} style={{ width: "80px" }} // fixed width, adjust as needed
onClick={() => setSearchField((field) => (field === "name" ? "email" : "name"))} onClick={() => setSearchField((field) => (field === "name" ? "email" : "name"))}
title={`Switch to searching by ${searchField === "name" ? "Email" : "Name"}`} title={`Switch to searching by ${searchField === "name" ? "Email" : "Name"}`}
> >
{searchField === "name" ? "Email" : "Name"} {searchField === "name" ? "Email" : "Name"}
</button> </button>
</div> </div>
{/* Filter and Sort Buttons */}
<div className="flex gap-2 items-center mb-2"> <div className="flex gap-2 items-center mb-2">
{/* Filter */} {/* Filter */}
<div className="relative" ref={filterDropdownRef}> <div className="relative" ref={filterDropdownRef}>
<button <button
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
${roleFilter !== "all" ${
roleFilter !== "all"
? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700" ? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700"
: "bg-white text-gray-700 border hover:bg-neutral-200" : "bg-white text-gray-700 border hover:bg-neutral-200"
} }
@ -343,24 +257,12 @@ export default function AdminPage() {
> >
{sortDir === "asc" ? "↑" : "↓"} {sortDir === "asc" ? "↑" : "↓"}
</button> </button>
{/* ADD BUTTON */}
<button
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"
type="button"
style={{ minWidth: 36, minHeight: 36 }}
onClick={() => setAddOpen(true)}
disabled={addOpen}
title="Add user"
>
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2.2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m7-7H5" />
</svg>
</button>
</div> </div>
{/* Sort status text */}
<small className="text-xs text-gray-500 mb-2 px-1"> <small className="text-xs text-gray-500 mb-2 px-1">
Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]} Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
</small> </small>
{/* USERS LIST */} {/* USERS LIST: full height, scrollable */}
<ul className="overflow-y-auto flex-1 pr-1"> <ul className="overflow-y-auto flex-1 pr-1">
{sortedUsers.map((user) => ( {sortedUsers.map((user) => (
<li <li
@ -385,98 +287,20 @@ export default function AdminPage() {
</div> </div>
{/* MAIN PANEL */} {/* MAIN PANEL */}
<div className="flex-1 p-24 bg-white overflow-y-auto"> <div className="flex-1 p-24 bg-white overflow-y-auto">
{/* Add User Modal */}
{addOpen && (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-30">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-sm w-full relative">
<h3 className="text-lg font-bold mb-4">Add New User</h3>
<form onSubmit={handleAddUser} className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
className="w-full border px-2 py-1 rounded-lg"
type="email"
required
value={addForm.email}
onChange={e => setAddForm(f => ({ ...f, email: e.target.value }))}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
className="w-full border px-2 py-1 rounded-lg"
type="text"
required
value={addForm.name}
onChange={e => setAddForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Role</label>
<select
className="w-full border px-2 py-1 rounded-lg"
value={addForm.role}
onChange={e => setAddForm(f => ({ ...f, role: e.target.value as Role }))}
>
{allRoles.map(role => (
<option value={role} key={role}>{roleLabels[role]}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
className="w-full border px-2 py-1 rounded-lg"
type="text"
required
value={addForm.password}
onChange={e => setAddForm(f => ({ ...f, password: e.target.value }))}
/>
</div>
{addError && <div className="text-red-600 text-xs">{addError}</div>}
<div className="flex gap-2 justify-end pt-2">
<button
type="button"
className="px-3 py-1 rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 transition"
onClick={() => setAddOpen(false)}
disabled={addLoading}
>
Cancel
</button>
<button
type="submit"
className={`px-3 py-1 rounded-lg text-white font-semibold ${addLoading ? "bg-green-400" : "bg-green-600 hover:bg-green-700"}`}
disabled={addLoading}
>
{addLoading ? "Adding..." : "Add"}
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit User Panel */}
{editUser ? ( {editUser ? (
<div className="max-w-lg mx-auto bg-white p-6 rounded-lg shadow"> <div className="max-w-lg mx-auto bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-bold mb-6">Edit User</h2> <h2 className="text-lg font-bold mb-6">Edit User</h2>
<form className="space-y-4" onSubmit={handleUpdate}> <form className="space-y-4" onSubmit={handleUpdate}>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<label className="text-sm font-medium text-gray-700"> <label className="text-sm font-medium text-gray-700">Account Creation Time:</label>
Account Creation Time:
</label>
<span className="text-sm text-gray-500">{editUser.createdAt}</span> <span className="text-sm text-gray-500">{editUser.createdAt}</span>
</div> </div>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<label className="text-sm font-medium text-gray-700"> <label className="text-sm font-medium text-gray-700">Account ID Number:</label>
Account ID Number:
</label>
<span className="text-sm text-gray-500">{editUser.id}</span> <span className="text-sm text-gray-500">{editUser.id}</span>
</div> </div>
<div className="relative"> <div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">Email (unique):</label>
Email (unique):
</label>
<input <input
className="w-full border px-3 py-2 rounded-lg outline-none bg-gray-100 cursor-not-allowed" className="w-full border px-3 py-2 rounded-lg outline-none bg-gray-100 cursor-not-allowed"
type="email" type="email"
@ -486,6 +310,7 @@ export default function AdminPage() {
onMouseEnter={() => setShowEmailTooltip(true)} onMouseEnter={() => setShowEmailTooltip(true)}
onMouseLeave={() => setShowEmailTooltip(false)} onMouseLeave={() => setShowEmailTooltip(false)}
/> />
{/* Custom tooltip */}
{showEmailTooltip && ( {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"> <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 /> This field cannot be changed. <br />
@ -494,9 +319,7 @@ export default function AdminPage() {
)} )}
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">Name:</label>
Name:
</label>
<input <input
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300" className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
type="text" type="text"
@ -506,9 +329,7 @@ export default function AdminPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">Role:</label>
Role:
</label>
<select <select
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300" className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
name="role" name="role"
@ -523,15 +344,13 @@ export default function AdminPage() {
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">Password:</label>
Password:
</label>
<input <input
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300" className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
type="text" type="text"
name="password" name="password"
value={newPassword} value={editUser.password}
onChange={(e) => setNewPassword(e.target.value)} onChange={handleEditChange}
/> />
</div> </div>
<div className="flex gap-2 justify-end pt-6"> <div className="flex gap-2 justify-end pt-6">
@ -558,9 +377,7 @@ export default function AdminPage() {
</form> </form>
</div> </div>
) : ( ) : (
<div className="text-center text-gray-400 mt-16 text-lg"> <div className="text-center text-gray-400 mt-16 text-lg">Select a user...</div>
Select a user...
</div>
)} )}
</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>

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,56 +8,95 @@ 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"; // todo (optional) add in filtering of map earthquakes
// --- SEARCH MODAL COMPONENT ---
function EarthquakeSearchModal({ open, onClose, onSelect }) {
const [search, setSearch] = useState("");
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const handleSearch = async (e) => {
e.preventDefault();
setLoading(true);
setResults([]);
try {
const res = await axios.post("/api/earthquakes/search", { query: search });
setResults(res.data.earthquakes || []);
} catch (e) {
alert("Failed to search.");
}
setLoading(false);
};
// Optional: "No Access Modal" - as in your original
function NoAccessModal({ open, onClose }) {
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center"> <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"> <div className="bg-white rounded-lg shadow-lg p-6 max-w-lg w-full relative">
<button <button onClick={onClose} className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg">
onClick={onClose}
className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg"
aria-label="Close"
>
&times; &times;
</button> </button>
<h2 className="font-bold text-xl mb-4">Access Denied</h2> <h2 className="font-bold text-xl mb-4">Search Earthquakes</h2>
<p className="text-gray-600 mb-3"> <form onSubmit={handleSearch} className="flex gap-2 mb-4">
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 <input
</p> type="text"
<button placeholder="e.g. Mexico, EV-7.4-Mexico-00035"
onClick={onClose} value={search}
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 mt-2" onChange={(e) => setSearch(e.target.value)}
>OK</button> 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>
</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("");
// Search modal state
const [searchModalOpen, setSearchModalOpen] = useState(false); const [searchModalOpen, setSearchModalOpen] = useState(false);
const [logModalOpen, setLogModalOpen] = useState(false);
const [noAccessModalOpen, setNoAccessModalOpen] = useState(false);
// Your user/role logic // Fetch recent earthquakes as before
const user = useStoreState((state) => state.user); const { data, error, isLoading } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 5 }));
const role: "GUEST" | "SCIENTIST" | "ADMIN" = user?.role ?? "GUEST";
const canLogEarthquake = role === "SCIENTIST" || role === "ADMIN";
// Fetch earthquakes (10 days recent) // Prepare events for maps/sidebar
const { data, error, isLoading, mutate } = useSWR(
"/api/earthquakes",
createPoster({ rangeDaysPrev: 10 })
);
// Shape for Map/Sidebar
const earthquakeEvents = useMemo( const earthquakeEvents = useMemo(
() => () =>
data && data.earthquakes data && data.earthquakes
@ -64,7 +104,7 @@ export default function 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,
@ -73,22 +113,13 @@ export default function Earthquakes() {
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">
@ -113,26 +144,15 @@ export default function Earthquakes() {
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}
/> />
{/* ---- SEARCH MODAL ---- */}
<EarthquakeSearchModal <EarthquakeSearchModal
open={searchModalOpen} open={searchModalOpen}
onClose={() => setSearchModalOpen(false)} onClose={() => setSearchModalOpen(false)}
onSelect={(eq) => setSelectedEventId(eq.code)} onSelect={(eq) => {
/> setSelectedEventId(eq.code); // select on map/sidebar
{/* ---- LOGGING MODAL ---- */} // setSelectedSearchResult(eq); // you can use this if you want to show detail modal
<EarthquakeLogModal }}
open={logModalOpen}
onClose={() => setLogModalOpen(false)}
onSuccess={() => mutate()}
/>
{/* ---- NO ACCESS ---- */}
<NoAccessModal
open={noAccessModalOpen}
onClose={() => setNoAccessModalOpen(false)}
/> />
</div> </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

@ -65,166 +65,3 @@ body {
color: #111; color: #111;
/* or black */ /* or black */
} }
/* ---- LAVA FLOOD OVERLAY ---- */
.lava-flood-overlay {
pointer-events: none;
position: fixed;
top: -100vh;
left: 0;
right: 0;
width: 100vw;
height: 100vh;
z-index: 9999;
display: flex;
justify-content: center;
align-items: flex-start;
transition: top 0.9s cubic-bezier(.6, 0, .2, 1);
}
.lava-flood-overlay.lava-active {
top: 0;
transition: top 0.33s cubic-bezier(.6, 0, .2, 1);
}
.lava-flood-overlay img,
.lava-gradient {
width: 100vw;
height: 100vh;
object-fit: cover;
pointer-events: none;
user-select: none;
filter: brightness(1.15) saturate(1.8) drop-shadow(0 0 80px #ff5500);
}
/* ---- INSANE SCREEN SHAKE (LinkedIn) ---- */
@keyframes supershake {
0% {
transform: translate(0, 0) rotate(0);
}
5% {
transform: translate(-20px, 5px) rotate(-2deg);
}
10% {
transform: translate(18px, -8px) rotate(2deg);
}
15% {
transform: translate(-22px, 8px) rotate(-4deg);
}
20% {
transform: translate(22px, -2px) rotate(4deg);
}
25% {
transform: translate(-18px, 12px) rotate(-2deg);
}
30% {
transform: translate(18px, -10px) rotate(2deg);
}
35% {
transform: translate(-22px, 14px) rotate(-4deg);
}
40% {
transform: translate(22px, -12px) rotate(4deg);
}
45% {
transform: translate(-18px, 8px) rotate(-2deg);
}
50% {
transform: translate(18px, -14px) rotate(4deg);
}
55% {
transform: translate(-22px, 12px) rotate(-4deg);
}
60% {
transform: translate(22px, -8px) rotate(2deg);
}
65% {
transform: translate(-18px, 10px) rotate(-2deg);
}
70% {
transform: translate(18px, -12px) rotate(2deg);
}
75% {
transform: translate(-22px, 14px) rotate(-4deg);
}
80% {
transform: translate(22px, -10px) rotate(4deg);
}
85% {
transform: translate(-18px, 8px) rotate(-2deg);
}
90% {
transform: translate(18px, -14px) rotate(2deg);
}
95% {
transform: translate(-20px, 5px) rotate(-2deg);
}
100% {
transform: translate(0, 0) rotate(0);
}
}
.shake-screen {
animation: supershake 1s cubic-bezier(.36, .07, .19, .97) both;
}
/* ---- CRACK + COLLAPSE OVERLAY (X icon) ---- */
.crack-overlay {
pointer-events: none;
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
z-index: 9999;
transition: transform 1.1s cubic-bezier(.65, .05, .45, 1), opacity 0.5s;
will-change: transform, opacity;
}
.crack {
position: absolute;
pointer-events: none;
}
.crack1 {
width: 35vw;
left: 10vw;
top: 22vh;
opacity: 0.8;
}
.crack2 {
width: 32vw;
right: 12vw;
top: 42vh;
opacity: 0.7;
transform: rotate(-8deg);
}
/* Add more .crackN classes if using more cracks */
/* Collapse falling effect */
.crack-collapse {
transform: perspective(900px) rotateX(75deg) translateY(80vh) scale(0.9);
opacity: 0;
transition: transform 1.1s cubic-bezier(.65, .05, .45, 1), opacity 0.6s;
}

View File

@ -55,7 +55,7 @@ export default function LearnPage() {
<li>First aid kit and emergency medication</li> <li>First aid kit and emergency medication</li>
<li>Food (non-perishable)</li> <li>Food (non-perishable)</li>
<li>Bottled water</li> <li>Bottled water</li>
<li>Torch</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>

View File

@ -1,200 +1,177 @@
"use client"; "use client";
import React, { useRef, useState, useEffect } from "react"; import React, { useRef, useState } from "react";
import { useStoreState } from "@hooks/store";
// --- Types ---
type Level = "JUNIOR" | "SENIOR"; type Level = "JUNIOR" | "SENIOR";
const levelLabels: Record<Level, string> = { JUNIOR: "Junior", SENIOR: "Senior" }; const levelLabels: Record<Level, string> = { JUNIOR: "Junior", SENIOR: "Senior" };
type User = { type User = { id: number; email: string; name: string; };
id: number; type Earthquakes = { id: number; code: string; location: string; };
name: string; type Observatory = { id: number; name: string; location: string; };
email: string; type Artefact = { id: number; name: string; type: string; };
role?: string;
scientist?: Scientist | null;
};
type Scientist = { type Scientist = {
id: number; id: number;
createdAt: string; createdAt: string;
name: string; name: string;
level: Level; level: Level;
user: User; user: User;
userId: number; userId: User["id"];
superior: Scientist | null; superior: Scientist | null;
superiorId: number | null; superiorId: Scientist["id"] | null;
subordinates: Scientist[]; subordinates: Scientist[];
earthquakes: Earthquakes[];
earthquakeIds: number[];
observatories: Observatory[];
observatoryIds: number[];
artefacts: Artefact[];
artefactIds: number[];
}; };
// --- Helpers --- const users: User[] = [
const initialScientists: Scientist[] = [ { id: 1, name: "Albert Einstein", email: "ae@uni.edu" },
{ id: 2, name: "Marie Curie", email: "mc@uni.edu" },
{ id: 3, name: "Ada Lovelace", email: "al@uni.edu" },
{ id: 4, name: "Carl Sagan", email: "cs@uni.edu" },
{ id: 5, name: "Isaac Newton", email: "in@uni.edu" }
];
const artefacts: Artefact[] = [
{ id: 1, name: "SeismoRing", type: "Instrument" },
{ id: 2, name: "QuakeCube", type: "Sensor" },
{ id: 3, name: "WavePen", type: "Recorder" },
{ id: 4, name: "TremorNet", type: "AI Chip" }
];
const observatories: Observatory[] = [
{ id: 1, name: "Stanford Observatory", location: "Stanford" },
{ id: 2, name: "Tokyo Seismic Center", location: "Tokyo" },
{ id: 3, name: "Oxford Observatory", location: "Oxford" },
{ id: 4, name: "Mount Wilson", location: "Pasadena" }
];
const earthquakes: Earthquakes[] = [
{ id: 1, code: "EQ-001", location: "San Francisco" },
{ id: 2, code: "EQ-002", location: "Tokyo" },
{ id: 3, code: "EQ-003", location: "Istanbul" },
{ id: 4, code: "EQ-004", location: "Mexico City" },
{ id: 5, code: "EQ-005", location: "Rome" }
];
const scientistList: Scientist[] = [
{ {
id: 0, id: 1,
name: "Loading Scientist", createdAt: "2024-06-01T09:00:00Z",
name: "Dr. John Junior",
level: "JUNIOR", level: "JUNIOR",
createdAt: "", user: users[0],
user: { id: 0, name: "Loading...", email: "--" }, userId: 1,
userId: 0, superior: null,
superiorId: 2,
subordinates: [],
earthquakes: [earthquakes[0], earthquakes[2]],
earthquakeIds: [1, 3],
observatories: [observatories[0], observatories[1]],
observatoryIds: [1, 2],
artefacts: [artefacts[0], artefacts[2]],
artefactIds: [1, 3],
},
{
id: 2,
createdAt: "2024-06-01T10:00:00Z",
name: "Dr. Jane Senior",
level: "SENIOR",
user: users[1],
userId: 2,
superior: null, superior: null,
superiorId: null, superiorId: null,
subordinates: [], subordinates: [],
earthquakes: [earthquakes[1], earthquakes[3], earthquakes[4]],
earthquakeIds: [2, 4, 5],
observatories: [observatories[1], observatories[2]],
observatoryIds: [2, 3],
artefacts: [artefacts[1]],
artefactIds: [2],
}, },
{
id: 3,
createdAt: "2024-06-02T08:00:00Z",
name: "Dr. Amy Junior",
level: "JUNIOR",
user: users[2],
userId: 3,
superior: null,
superiorId: 2,
subordinates: [],
earthquakes: [earthquakes[0]],
earthquakeIds: [1],
observatories: [observatories[2]],
observatoryIds: [3],
artefacts: [artefacts[2], artefacts[3]],
artefactIds: [3, 4],
},
{
id: 4,
createdAt: "2024-06-02T08:15:00Z",
name: "Prof. Isaac Senior",
level: "SENIOR",
user: users[4],
userId: 5,
superior: null,
superiorId: null,
subordinates: [],
earthquakes: [earthquakes[2], earthquakes[3]],
earthquakeIds: [3, 4],
observatories: [observatories[3]],
observatoryIds: [4],
artefacts: [artefacts[3]],
artefactIds: [4],
},
{
id: 5,
createdAt: "2024-06-02T08:20:00Z",
name: "Dr. Carl Junior",
level: "JUNIOR",
user: users[3],
userId: 4,
superior: null,
superiorId: 4,
subordinates: [],
earthquakes: [earthquakes[3]],
earthquakeIds: [4],
observatories: [observatories[1], observatories[2]],
observatoryIds: [2, 3],
artefacts: [artefacts[0]],
artefactIds: [1],
}
]; ];
scientistList[0].superior = scientistList[1];
scientistList[2].superior = scientistList[1];
scientistList[4].superior = scientistList[3];
scientistList[1].subordinates = [scientistList[0], scientistList[2]];
scientistList[3].subordinates = [scientistList[4]];
const sortFields = [ const sortFields = [
{ label: "Name", value: "name" }, { label: "Name", value: "name" },
{ label: "Level", value: "level" } { label: "Level", value: "level" },
] 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", level: "Level" }; const fieldLabels: Record<SortField, string> = { name: "Name", level: "Level" };
// --- Updated RequestModal (only level/removal, no comment) export default function Scientist() {
type RequestModalProps = { const [scientists, setScientists] = useState<Scientist[]>(scientistList);
open: boolean;
onClose: () => void;
requestingUserId: number;
scientist?: Scientist | null;
};
function RequestModal({ open, onClose, requestingUserId, scientist }: RequestModalProps) {
const [requestType, setRequestType] = useState<string>("CHANGE_LEVEL");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null); setSuccess(null);
setLoading(true);
try {
const body = {
requestType,
requestingUserId,
scientistId: scientist?.id ?? null,
comment: "", // Still send blank to backend for compatibility
};
const res = await fetch("/api/management/request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d?.error || "Request failed");
}
setSuccess("Request submitted for review.");
} catch (e: any) {
setError(e?.message || "Unknown error");
} finally {
setLoading(false);
}
}
return open ? (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-30">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full relative">
<h3 className="text-lg font-bold mb-4">Request Action</h3>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">Action Type</label>
<select
required
className="w-full border px-2 py-1 rounded-lg"
value={requestType}
onChange={e=>setRequestType(e.target.value)}
>
<option value="CHANGE_LEVEL">Request Change Level</option>
<option value="DELETE">Request Removal</option>
</select>
</div>
{error && <div className="text-red-600 text-xs">{error}</div>}
{success && <div className="text-green-600 text-xs">{success}</div>}
<div className="flex gap-2 justify-end pt-2">
<button
type="button"
className="px-3 py-1 rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 transition"
onClick={onClose}
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className={`px-3 py-1 rounded-lg text-white font-semibold ${loading ? "bg-blue-300" : "bg-blue-600 hover:bg-blue-700"}`}
disabled={loading}
>
{loading ? "Submitting..." : "Submit"}
</button>
</div>
</form>
</div>
</div>
) : null;
}
// ========================================================
export default function ScientistManagementPage() {
// All hooks first
const user = useStoreState((state)=>state.user);
const [scientists, setScientists] = useState<Scientist[]>(initialScientists);
const [allUsers, setAllUsers] = useState<User[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedId, setSelectedId] = useState<number | null>(null);
const [editScientist, setEditScientist] = useState<Scientist | null>(null); const [editScientist, setEditScientist] = useState<Scientist | null>(null);
const [addOpen, setAddOpen] = useState(false); React.useEffect(() => {
const [requestOpen, setRequestOpen] = useState(false);
const [addForm, setAddForm] = useState<{ name: string; level: Level; userId: number }>({
name: "",
level: "JUNIOR",
userId: 0,
});
const [addError, setAddError] = useState<string | null>(null);
const [addLoading, setAddLoading] = useState(false);
const [searchField, setSearchField] = useState<SortField>("name");
const [searchText, setSearchText] = useState("");
const [levelFilter, setLevelFilter] = useState<Level | "all">("all");
const [sortField, setSortField] = useState<SortField>("name");
const [sortDir, setSortDir] = useState<SortDir>("asc");
const [success, setSuccess] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
const filterDropdownRef = useRef<HTMLDivElement>(null);
const sortDropdownRef = useRef<HTMLDivElement>(null);
// AUTH LOGIC
const userRole = user?.role as string | undefined;
const isAdmin = userRole === "ADMIN";
const isSeniorScientist = userRole === "SCIENTIST" && user?.scientist?.level === "SENIOR";
const readOnly = isSeniorScientist && !isAdmin;
// Data loading effects
useEffect(() => {
async function fetchAllUsers() {
try {
const res = await fetch("/api/users/all");
if (!res.ok) throw new Error("Failed to fetch users");
const data = await res.json();
setAllUsers(data.users || []);
} catch (err) {
setError("Error fetching all users");
}
}
fetchAllUsers();
}, []);
useEffect(() => {
async function fetchScientists() {
try {
const res = await fetch("/api/management");
if (!res.ok) throw new Error("Failed to fetch scientists");
const data = await res.json();
setScientists(data.scientists);
} catch (err) {
setError("Error fetching scientists");
}
}
fetchScientists();
}, []);
useEffect(() => {
if (selectedId == null) setEditScientist(null); if (selectedId == null) setEditScientist(null);
else { else {
const sc = scientists.find((x) => x.id === selectedId); const sc = scientists.find((x) => x.id === selectedId);
setEditScientist(sc ? { ...sc } : null); setEditScientist(sc ? { ...sc } : null);
} }
}, [selectedId, scientists]); }, [selectedId, scientists]);
useEffect(() => { const [searchField, setSearchField] = useState<SortField>("name");
const [searchText, setSearchText] = useState("");
const [levelFilter, setLevelFilter] = useState<Level | "all">("all");
const [sortField, setSortField] = useState<SortField>("name");
const [sortDir, setSortDir] = useState<SortDir>("asc");
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
const filterDropdownRef = useRef<HTMLDivElement>(null);
const sortDropdownRef = useRef<HTMLDivElement>(null);
React.useEffect(() => {
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
if (filterDropdownRef.current && !filterDropdownRef.current.contains(e.target as Node)) setFilterDropdownOpen(false); if (filterDropdownRef.current && !filterDropdownRef.current.contains(e.target as Node)) setFilterDropdownOpen(false);
if (sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as Node)) setSortDropdownOpen(false); if (sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as Node)) setSortDropdownOpen(false);
@ -202,114 +179,151 @@ export default function ScientistManagementPage() {
document.addEventListener("mousedown", handleClick); document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick);
}, []); }, []);
const filtered = scientists.filter(s => levelFilter === "all" || s.level === levelFilter); const filtered = scientists.filter((s) => levelFilter === "all" || s.level === levelFilter);
const searched = filtered.filter(s => String(s[searchField]).toLowerCase().includes(searchText.toLowerCase())); const searched = filtered.filter((s) => s[searchField].toLowerCase().includes(searchText.toLowerCase()));
const sorted = [...searched].sort((a, b) => { const sorted = [...searched].sort((a, b) => {
let cmp = String(a[sortField]).localeCompare(String(b[sortField])); let cmp = a[sortField].localeCompare(b[sortField]);
return sortDir === "asc" ? cmp : -cmp; return sortDir === "asc" ? cmp : -cmp;
}); });
const allLevels: Level[] = ["JUNIOR", "SENIOR"]; const allLevels: Level[] = ["JUNIOR", "SENIOR"];
const allUsers = users;
const allObservatories = observatories;
const allArtefacts = artefacts;
const allEarthquakes = earthquakes;
const allOtherScientistOptions = (curId?: number) =>
scientists.filter((s) => s.id !== curId);
// -- Queries for selectors
const [artefactQuery, setArtefactQuery] = useState("");
const [earthquakeQuery, setEarthquakeQuery] = useState("");
const [observatoryQuery, setObservatoryQuery] = useState("");
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => { const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
if (!editScientist) return; if (!editScientist) return;
const { name, value } = e.target; const { name, value } = e.target;
if (name === "superiorId") { if (name === "superiorId") {
const supId = value === "" ? null : Number(value); const supId = value === "" ? null : Number(value);
setEditScientist(prev => prev ? { setEditScientist((prev) =>
prev
? {
...prev, ...prev,
superiorId: supId, superiorId: supId,
superior: supId ? scientists.find((s) => s.id === supId) ?? null : null superior: supId ? scientists.find((s) => s.id === supId) ?? null : null,
} : null); }
: null
);
} else if (name === "level") { } else if (name === "level") {
setEditScientist(prev => prev ? { ...prev, level: value as Level } : null); setEditScientist((prev) => (prev ? { ...prev, level: value as Level } : null));
} else if (name === "userId") { } else if (name === "userId") {
const user = allUsers.find((u) => u.id === Number(value)); const user = users.find((u) => u.id === Number(value));
setEditScientist(prev => (prev && user ? { ...prev, user, userId: user.id } : prev)); setEditScientist((prev) => (prev && user ? { ...prev, user, userId: user.id } : prev));
} else { } else {
setEditScientist(prev => prev ? { ...prev, [name]: value } : null); setEditScientist((prev) => (prev ? { ...prev, [name]: value } : null));
} }
}; };
async function handleAddScientist(e: React.FormEvent) { function handleArtefactCheck(id: number) {
e.preventDefault();
setAddError(null);
if (!addForm.name || !addForm.level || !addForm.userId) {
setAddError("All fields are required.");
return;
}
setAddLoading(true);
try {
const res = await fetch("/api/management", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(addForm),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d?.error || "Failed to add scientist");
}
const data = await res.json();
setScientists(prev => [...prev, data.scientist]);
setAddOpen(false);
setAddForm({ name: "", level: "JUNIOR", userId: 0 });
} catch (err: any) {
setAddError(err?.message || "Unknown error");
} finally {
setAddLoading(false);
}
}
async function updateScientistOnServer(sc: Scientist) {
const res = await fetch("/api/management", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: sc.id,
name: sc.name,
level: sc.level,
userId: sc.user.id,
superiorId: sc.superior ? sc.superior.id : null,
}),
});
if (!res.ok) throw new Error("Failed to update scientist");
return (await res.json()).scientist as Scientist;
}
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
setSuccess(null); setError(null);
if (!editScientist) return; if (!editScientist) return;
try { let nextIds = editScientist.artefactIds.includes(id)
const updatedScientist = await updateScientistOnServer(editScientist); ? editScientist.artefactIds.filter((ai) => ai !== id)
setScientists(prev => prev.map(sci => sci.id === updatedScientist.id ? updatedScientist : sci)); : [...editScientist.artefactIds, id];
setEditScientist(updatedScientist); setEditScientist((prev) =>
setSuccess("Scientist updated!"); prev
} catch { ? {
setError("Couldn't update scientist"); ...prev,
artefactIds: nextIds,
artefacts: allArtefacts.filter((a) => nextIds.includes(a.id)),
} }
}; : null
const handleDeleteScientist = async () => {
if (!editScientist) return;
if (!window.confirm(`Are you sure you want to delete "${editScientist.name}"?`)) return;
try {
const res = await fetch("/api/management", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: editScientist.id }),
});
if (!res.ok) throw new Error("Failed to delete scientist");
setScientists(prev => prev.filter(sci => sci.id !== editScientist.id));
setEditScientist(null);
setSelectedId(null);
} catch {
alert("Delete failed");
}
};
const usersWithNoScientist = allUsers.filter(u => !u.scientist);
if (!isAdmin && !isSeniorScientist) {
return (
<div className="flex items-center justify-center min-h-[70vh] flex-col">
<h1 className="text-2xl font-bold text-red-500 mb-4">Unauthorized Access</h1>
<div className="text-gray-600">You do not have access to this page.</div>
</div>
); );
} }
function handleEarthquakeCheck(id: number) {
if (!editScientist) return;
let nextIds = editScientist.earthquakeIds.includes(id)
? editScientist.earthquakeIds.filter((ei) => ei !== id)
: [...editScientist.earthquakeIds, id];
setEditScientist((prev) =>
prev
? {
...prev,
earthquakeIds: nextIds,
earthquakes: allEarthquakes.filter((e) => nextIds.includes(e.id)),
}
: null
);
}
function handleObservatoryCheck(id: number) {
if (!editScientist) return;
let nextIds = editScientist.observatoryIds.includes(id)
? editScientist.observatoryIds.filter((oi) => oi !== id)
: [...editScientist.observatoryIds, id];
setEditScientist((prev) =>
prev
? {
...prev,
observatoryIds: nextIds,
observatories: allObservatories.filter((obs) => nextIds.includes(obs.id)),
}
: null
);
}
const selectedScientist = scientists.find((u) => u.id === selectedId);
function arraysEqualSet(a: number[], b: number[]) {
return a.length === b.length && a.every((v) => b.includes(v));
}
const isEditChanged = React.useMemo(() => {
if (!editScientist || !selectedScientist) return false;
return (
editScientist.name !== selectedScientist.name ||
editScientist.level !== selectedScientist.level ||
editScientist.superiorId !== selectedScientist.superiorId ||
editScientist.userId !== selectedScientist.userId ||
!arraysEqualSet(editScientist.observatoryIds, selectedScientist.observatoryIds) ||
!arraysEqualSet(editScientist.artefactIds, selectedScientist.artefactIds) ||
!arraysEqualSet(editScientist.earthquakeIds, selectedScientist.earthquakeIds)
);
}, [editScientist, selectedScientist]);
const handleUpdate = (e: React.FormEvent) => {
e.preventDefault();
if (!editScientist) return;
setScientists((prev) =>
prev.map((item) =>
item.id === editScientist.id
? {
...editScientist,
artefacts: allArtefacts.filter((a) => editScientist.artefactIds.includes(a.id)),
earthquakes: allEarthquakes.filter((eq) => editScientist.earthquakeIds.includes(eq.id)),
observatories: allObservatories.filter((obs) => editScientist.observatoryIds.includes(obs.id)),
subordinates: scientistList.filter((s) => s.superiorId === editScientist.id),
}
: item
)
);
};
const handleDelete = () => {
if (!selectedScientist) return;
if (!window.confirm(`Are you sure you want to delete "${selectedScientist.name}"?`)) return;
setScientists((prev) => prev.filter((i) => i.id !== selectedScientist.id));
setSelectedId(null);
setEditScientist(null);
};
const searchedArtefacts = allArtefacts.filter(
(a) =>
artefactQuery.trim() === "" ||
a.name.toLowerCase().includes(artefactQuery.toLowerCase()) ||
a.id.toString().includes(artefactQuery)
);
const searchedEarthquakes = allEarthquakes.filter(
(eq) =>
earthquakeQuery.trim() === "" ||
eq.id.toString().includes(earthquakeQuery) ||
eq.code.toLowerCase().includes(earthquakeQuery.toLowerCase())
);
const searchedObservatories = allObservatories.filter(
(obs) =>
observatoryQuery.trim() === "" ||
obs.name.toLowerCase().includes(observatoryQuery.toLowerCase()) ||
obs.location.toLowerCase().includes(observatoryQuery.toLowerCase()) ||
obs.id.toString().includes(observatoryQuery)
);
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex h-full overflow-hidden bg-gray-50"> <div className="flex h-full overflow-hidden bg-gray-50">
@ -334,6 +348,7 @@ export default function ScientistManagementPage() {
</button> </button>
</div> </div>
<div className="flex gap-2 items-center mb-2"> <div className="flex gap-2 items-center mb-2">
{/* Filter dropdown */}
<div className="relative" ref={filterDropdownRef}> <div className="relative" ref={filterDropdownRef}>
<button <button
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
@ -378,6 +393,7 @@ export default function ScientistManagementPage() {
</div> </div>
)} )}
</div> </div>
{/* sort dropdown */}
<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"
@ -415,20 +431,6 @@ export default function ScientistManagementPage() {
> >
{sortDir === "asc" ? "↑" : "↓"} {sortDir === "asc" ? "↑" : "↓"}
</button> </button>
{isAdmin && (
<button
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"
type="button"
style={{ minWidth: 36, minHeight: 36 }}
onClick={() => setAddOpen(true)}
disabled={addOpen}
title="Add scientist"
>
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2.2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m7-7H5" />
</svg>
</button>
)}
</div> </div>
<small className="text-xs text-gray-500 mb-2 px-1"> <small className="text-xs text-gray-500 mb-2 px-1">
Scientists sorted by {fieldLabels[sortField]} {dirLabels[sortDir]} Scientists sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
@ -455,86 +457,33 @@ export default function ScientistManagementPage() {
</ul> </ul>
</div> </div>
</div> </div>
{/* Add Scientist Modal */}
{addOpen && (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-30">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-sm w-full relative">
<h3 className="text-lg font-bold mb-4">Add New Scientist</h3>
<form onSubmit={handleAddScientist} className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
className="w-full border px-2 py-1 rounded-lg"
type="text"
required
value={addForm.name}
onChange={e => setAddForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Level</label>
<select
className="w-full border px-2 py-1 rounded-lg"
value={addForm.level}
onChange={e => setAddForm(f => ({ ...f, level: e.target.value as Level }))}
>
{allLevels.map(level => (
<option value={level} key={level}>{levelLabels[level]}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">User (select by email)</label>
<select
className="w-full border px-2 py-1 rounded-lg"
required
value={addForm.userId}
onChange={e => setAddForm(f => ({ ...f, userId: Number(e.target.value) }))}
>
<option value={0} disabled>Choose user...</option>
{usersWithNoScientist.map(u =>
<option key={u.id} value={u.id}>
{u.name} ({u.email})
</option>
)}
</select>
</div>
{addError && <div className="text-red-600 text-xs">{addError}</div>}
<div className="flex gap-2 justify-end pt-2">
<button
type="button"
className="px-3 py-1 rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 transition"
onClick={() => setAddOpen(false)}
disabled={addLoading}
>
Cancel
</button>
<button
type="submit"
className={`px-3 py-1 rounded-lg text-white font-semibold ${addLoading ? "bg-green-400" : "bg-green-600 hover:bg-green-700"}`}
disabled={addLoading}
>
{addLoading ? "Adding..." : "Add"}
</button>
</div>
</form>
</div>
</div>
)}
{/* Request Modal */}
<RequestModal
open={requestOpen}
onClose={()=>setRequestOpen(false)}
requestingUserId={user?.id}
scientist={editScientist}
/>
{/* MAIN PANEL */} {/* MAIN PANEL */}
<div className="flex-1 p-24 bg-white overflow-y-auto"> <div className="flex-1 flex justify-center p-24 bg-white overflow-y-auto">
{editScientist ? ( {editScientist ? (
<form className="max-w-xl mx-auto bg-white p-6 rounded-lg shadow" onSubmit={handleUpdate}> <div
<h2 className="text-lg font-bold mb-4">Scientist Details</h2> className="
{!!success && <div className="text-green-600 text-sm">{success}</div>} max-w-4xl w-full bg-white rounded-xl shadow flex flex-col pt-4 pb-3 px-5
{!!error && <div className="text-red-600 text-xs">{error}</div>} "
style={{
minHeight: 0,
maxHeight: 780,
overflow: "hidden"
}}
>
{/* Heading */}
<div className="text-xl font-bold text-gray-900 mb-3 px-1">Edit Scientist</div>
<form className="flex flex-col flex-1 min-h-0" onSubmit={handleUpdate}>
<div className="flex flex-col lg:flex-row gap-8 min-h-0 flex-1">
{/* LEFT COLUMN */}
<div className="flex-1 flex flex-col gap-4 min-h-0 overflow-y-auto">
<div>
<div className="text-xs text-gray-400">Created at</div>
<div className="font-mono text-sm">{editScientist.createdAt}</div>
</div>
<div>
<div className="text-xs text-gray-400">ID</div>
<div className="font-mono text-sm">{editScientist.id}</div>
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name:</label> <label className="block text-sm font-medium text-gray-700 mb-1">Name:</label>
<input <input
@ -543,7 +492,6 @@ export default function ScientistManagementPage() {
name="name" name="name"
value={editScientist.name} value={editScientist.name}
onChange={handleEditChange} onChange={handleEditChange}
readOnly={readOnly}
/> />
</div> </div>
<div> <div>
@ -553,80 +501,200 @@ export default function ScientistManagementPage() {
name="level" name="level"
value={editScientist.level} value={editScientist.level}
onChange={handleEditChange} onChange={handleEditChange}
disabled={readOnly}
> >
{allLevels.map(lvl => <option key={lvl} value={lvl}>{levelLabels[lvl]}</option>)} {allLevels.map((lvl) => (
<option key={lvl} value={lvl}>{levelLabels[lvl]}</option>
))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">User (email):</label> <label className="block text-sm font-medium text-gray-700 mb-1">User (email):</label>
<select <select
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300 bg-gray-100" className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
name="userId" name="userId"
value={editScientist.user.id} value={editScientist.userId}
onChange={handleEditChange} onChange={handleEditChange}
disabled
> >
<option value={editScientist.user.id}>{editScientist.user.name} ({editScientist.user.email})</option> {allUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.name} ({u.email})
</option>
))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Supervisor:</label> <label className="block text-sm font-medium text-gray-700 mb-1">Superior:</label>
{isAdmin ? (
<select <select
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300" className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
name="superiorId" name="superiorId"
value={editScientist.superiorId ?? ""} value={editScientist.superiorId ?? ""}
onChange={handleEditChange} onChange={handleEditChange}
disabled={readOnly}
> >
<option value="">None</option> <option value="">None</option>
{scientists {allOtherScientistOptions(editScientist.id).map((s) => (
.filter(s => s.id !== editScientist.id) <option key={s.id} value={s.id}>{s.name}</option>
.map(s => (
<option key={s.id} value={s.id}>
{s.name} ({s.user.email})
</option>
))} ))}
</select> </select>
) : ( </div>
<div className="border px-3 py-2 rounded-lg bg-gray-100"> <div>
{editScientist.superior && editScientist.superior.user ? ( <label className="block text-sm font-medium text-gray-700 mb-1">Subordinates:</label>
<> <div className="flex flex-wrap gap-1 bg-gray-200 rounded px-2 py-2 min-h-[28px]">
<span>{editScientist.superior.name}</span> {editScientist.subordinates.length > 0
<span className="ml-2 text-gray-500 text-xs"> ? editScientist.subordinates.map((s) => (
({editScientist.superior.user.email}) <span
key={s.id}
className="px-2 py-1 rounded-full bg-gray-300 text-gray-700 text-xs font-medium"
>
{s.name}
</span> </span>
</> ))
: <span className="text-sm text-gray-400">None</span>
}
</div>
</div>
</div>
{/* RIGHT COLUMN */}
<div className="flex-1 flex flex-col gap-4 min-h-0 overflow-y-auto">
{/* Observatories Box */}
<div className="flex flex-col h-full">
<div className="flex justify-between items-end">
<label className="block text-sm font-medium text-gray-700 mb-1">Observatories:</label>
<input
type="text"
className="ml-2 text-xs border rounded px-2 py-1"
placeholder="Search by name/location/id..."
value={observatoryQuery}
onChange={(e) => setObservatoryQuery(e.target.value)}
style={{maxWidth: "55%"}}
/>
</div>
<div
className="border rounded px-2 py-1 mt-1 bg-gray-50 flex-1"
style={{
minHeight: 160,
maxHeight: 230,
overflowY: "auto"
}}
>
{searchedObservatories.length === 0 ? (
<div className="text-xs text-gray-400 text-center py-2">No observatories</div>
) : ( ) : (
<span className="text-gray-400">None</span> searchedObservatories.map((obs) => (
<label key={obs.id} className="block text-xs py-1">
<input
type="checkbox"
className="mr-2"
checked={editScientist.observatoryIds.includes(obs.id)}
onChange={() => handleObservatoryCheck(obs.id)}
/>
#{obs.id} {obs.name} ({obs.location})
</label>
))
)} )}
</div> </div>
</div>
{/* Earthquakes Box */}
<div className="flex flex-col h-full">
<div className="flex justify-between items-end">
<label className="block text-sm font-medium text-gray-700 mb-1">Earthquakes:</label>
<input
type="text"
className="ml-2 text-xs border rounded px-2 py-1"
placeholder="Search ID or code..."
value={earthquakeQuery}
onChange={(e) => setEarthquakeQuery(e.target.value)}
style={{maxWidth: "55%"}}
/>
</div>
<div
className="border rounded px-2 py-1 mt-1 bg-gray-50 flex-1"
style={{
minHeight: 160,
maxHeight: 230,
overflowY: "auto"
}}
>
{searchedEarthquakes.length === 0 ? (
<div className="text-xs text-gray-400 text-center py-2">No earthquakes</div>
) : (
searchedEarthquakes.map((eq) => (
<label key={eq.id} className="block text-xs py-1">
<input
type="checkbox"
className="mr-2"
checked={editScientist.earthquakeIds.includes(eq.id)}
onChange={() => handleEarthquakeCheck(eq.id)}
/>
#{eq.id} ({eq.code}) {eq.location}
</label>
))
)} )}
</div> </div>
<div className="flex gap-2 justify-end pt-8"> </div>
{isAdmin && ( {/* Artefacts Box */}
<> <div className="flex flex-col h-full">
<button type="button" onClick={handleDeleteScientist} <div className="flex justify-between items-end">
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white font-semibold rounded-lg shadow transition"> <label className="block text-sm font-medium text-gray-700 mb-1">Artefacts:</label>
<input
type="text"
className="ml-2 text-xs border rounded px-2 py-1"
placeholder="Search ID or name..."
value={artefactQuery}
onChange={(e) => setArtefactQuery(e.target.value)}
style={{maxWidth: "55%"}}
/>
</div>
<div
className="border rounded px-2 py-1 mt-1 bg-gray-50 flex-1"
style={{
minHeight: 160,
maxHeight: 230,
overflowY: "auto"
}}
>
{searchedArtefacts.length === 0 ? (
<div className="text-xs text-gray-400 text-center py-2">No artefacts</div>
) : (
searchedArtefacts.map((a) => (
<label key={a.id} className="block text-xs py-1">
<input
type="checkbox"
className="mr-2"
checked={editScientist.artefactIds.includes(a.id)}
onChange={() => handleArtefactCheck(a.id)}
/>
#{a.id} {a.name} ({a.type})
</label>
))
)}
</div>
</div>
</div>
</div>
{/* BUTTONS */}
<div className="flex justify-end gap-2 pt-4">
<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 Delete
</button> </button>
<button type="submit" <button
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold shadow transition ml-3"> 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 Update
</button> </button>
</>
)}
{readOnly && (
<button type="button"
onClick={()=>setRequestOpen(true)}
className="px-4 py-2 rounded-lg bg-yellow-500 hover:bg-yellow-600 text-white font-semibold shadow transition ml-3"
>
Request Change
</button>
)}
</div> </div>
</form> </form>
</div>
) : ( ) : (
<div className="text-center text-gray-400 mt-16 text-lg">Select a scientist...</div> <div className="text-center text-gray-400 mt-16 text-lg">Select a scientist...</div>
)} )}

View File

@ -1,81 +1,46 @@
"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(
"/api/observatories",
fetcher
);
// todo add in earthquake events
const observatoryEvents = useMemo( const observatoryEvents = useMemo(
() => () =>
data && data.observatories data && data.observatories
? data.observatories ? data.observatories
.map((x: Observatory): GeologicalEvent & { isFunctional: boolean } => ({ .map(
(x: Observatory): GeologicalEvent => ({
id: x.id.toString(), id: x.id.toString(),
title: ` ${x.name}`, title: `New Observatory - ${x.name}`,
longitude: x.longitude, longitude: x.longitude,
latitude: x.latitude, latitude: x.latitude,
isFunctional: x.isFunctional, // <-- include this!
text1: "", text1: "",
text2: getRelativeDate(x.dateEstablished), text2: getRelativeDate(x.dateEstablished),
date: x.dateEstablished, date: x.dateEstablished,
})) })
.sort(
(a: GeologicalEvent, b: GeologicalEvent) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
) )
.sort((a: GeologicalEvent, b: GeologicalEvent) => new Date(b.date).getTime() - new Date(a.date).getTime()) // Remove Date conversion
: [], : [],
[data] [data]
); );
const handleLogClick = () => {
if (canLogObservatory) {
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">
@ -91,7 +56,7 @@ export default function Observatories() {
<Sidebar <Sidebar
logTitle="Observatory Mapping" logTitle="Observatory Mapping"
logSubtitle="Record and search observatories - time/date set-up, location, scientists and recent earthquakes" logSubtitle="Record and search observatories - time/date set-up, location, scientists and recent earthquakes"
recentsTitle="New Observatories" recentsTitle="Observatory Events"
events={observatoryEvents} events={observatoryEvents}
selectedEventId={selectedEventId} selectedEventId={selectedEventId}
setSelectedEventId={setSelectedEventId} setSelectedEventId={setSelectedEventId}
@ -99,17 +64,6 @@ export default function Observatories() {
setHoveredEventId={setHoveredEventId} setHoveredEventId={setHoveredEventId}
button1Name="Log a New Observatory" button1Name="Log a New Observatory"
button2Name="Search Observatories" button2Name="Search Observatories"
onButton1Click={handleLogClick}
button1Disabled={!canLogObservatory}
/>
<LogObservatoryModal
open={logModalOpen}
onClose={() => setLogModalOpen(false)}
onSuccess={() => mutate()}
/>
<NoAccessModal
open={noAccessModalOpen}
onClose={() => setNoAccessModalOpen(false)}
/> />
</div> </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,60 +1,18 @@
"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(
"/api/earthquakes",
createPoster({ rangeDaysPrev: 6 })
);
// Take 5 most recent
const recents = (data?.earthquakes ?? [])
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, 5);
return ( return (
<main className="min-h-screen text-black"> <main className="min-h-screen text-black">
<div className="w-full relative"> <div className="w-full relative">
<div> <div className="">
<Image height={2000} width={2000} alt="Background Image" src="/lava_flows.jpg" /> <Image height={2000} width={2000} alt="Background Image" src="/lava_flows.jpg"></Image>
</div> </div>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-black/10 to-black/40"></div> <div className="absolute inset-0 bg-gradient-to-b from-transparent via-black/10 to-black/40"></div>
<div className="absolute inset-0 top-[30%]"> <div className="absolute inset-0 top-[30%]">
<Image className="mx-auto" height={300} width={1000} alt="Title Image" src="/tremortrackertext.png" /> <Image className="mx-auto" height={300} width={1000} alt="Title Image" src="/tremortrackertext.png"></Image>
</div> </div>
</div> </div>
<p className="mt-2"></p> <p className="mt-2"></p>
@ -70,14 +28,14 @@ export default function Home() {
href="/observatories" href="/observatories"
className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300"
> >
<Image height={100} width={100} src="/observe.png" alt="Research Icon" className="h-40 w-40 mb-4" /> <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> <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"> <p className="text-md text-black text-center max-w-xs opacity-90">
Find recently active observatories, and newly opened/closed sites Find recently active observatories, and newly opened/closed sites
</p> </p>
</Link> </Link>
<Link href="/shop" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300"> <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="/artefact.png" alt="Technology Icon" className="h-40 w-40 mb-4" /> <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> <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"> <p className="text-md text-black text-center max-w-xs opacity-90">
View or purchase recently discovered artefacts from seismic events View or purchase recently discovered artefacts from seismic events
@ -87,8 +45,8 @@ export default function Home() {
<p className="mt-18"></p> <p className="mt-18"></p>
<section className="min-h-screen text-black"> <section className="min-h-screen text-black">
<div className="w-full relative z-40"> <div className="w-full relative z-40">
<div> <div className="">
<Image height={2500} width={2000} alt="Background Image" src="/earthquakesMap.jpg" /> <Image height={2500} width={2000} alt="Background Image" src="/earthquakesMap.jpg"></Image>
</div> </div>
<div className="border absolute top-0 inset-0 bg-gradient-to-b from-transparent via-black/10 to-black/40"> <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"> <section className="relative z-10 flex flex-col items-center text-center w-full px-4 py-14 mt-6">
@ -126,54 +84,25 @@ export default function Home() {
</section> </section>
<p className="mt-20"></p> <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"> <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"> <h1 className="text-3xl md:text-3xl font-bold text-black drop-shadow-lg mb-3 tracking-tight">Recent Earthquake Events</h1>
Recent Earthquake Events
</h1>
<p className="text-lg md:text-xl text-black drop-shadow-md"> <p className="text-lg md:text-xl text-black drop-shadow-md">
Learn about the most recent earthquake events from around the world: Learn about the most recent earthquake events from around the world:
</p> </p>
</section> </section>
<p className="mt-6"></p> <p className="mt-6"></p>
<div className="mx-auto w-5/6 px-2"> <div className="mx-auto w-5/6 px-2 border border-black divide-y bg-white bg-opacity-90 rounded-xl shadow-md">
{error && ( {["Earthquake 1", "Earthquake 2", "Earthquake 3", "Earthquake 4", "Earthquake 5"].map((name) => (
<div className="border rounded-xl bg-white bg-opacity-90 shadow-md p-4 mb-2"> <div className="px-5 py-5" key={name}>
<p>Failed to load earthquakes.</p> <p className="ml-3">{name}</p>
</div> <p></p>
)}
{isLoading && (
<div className="border rounded-xl bg-white bg-opacity-90 shadow-md p-4 mb-2">
<p>Loading...</p>
</div>
)}
{!isLoading && recents.length === 0 && (
<div className="border rounded-xl bg-white bg-opacity-90 shadow-md p-4 mb-2">
<p>No earthquakes found.</p>
</div>
)}
<div className="flex flex-col gap-4">
{recents.map((eq) => (
<div
key={eq.code}
className="flex items-center justify-between p-4 bg-white rounded-xl shadow border"
>
<div>
<div className="font-semibold">
Earthquake in {eq.location || (eq.code && eq.code.split("-")[2])}
</div>
<div className="text-sm text-gray-500">{getRelativeDate(eq.date)}</div>
</div>
<MagnitudeNumber magnitude={eq.magnitude} />
</div> </div>
))} ))}
</div> </div>
</div>
<p className="mt-20"></p> <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"> <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"> <h1 className="text-3xl md:text-3xl font-bold text-black drop-shadow-lg mb-3 tracking-tight">Contact Information</h1>
Find Out More!
</h1>
<p className="text-lg md:text-xl text-black drop-shadow-md"> <p className="text-lg md:text-xl text-black drop-shadow-md">
Explore more of our website... Learn about Tremor Tracker's mission, our team or contact us directly:
</p> </p>
</section> </section>
<p className="mt-2"></p> <p className="mt-2"></p>
@ -203,12 +132,110 @@ export default function Home() {
<p className="mt-10"></p> <p className="mt-10"></p>
<section style={{ height: 500 }} className="text-black"> <section style={{ height: 500 }} className="text-black">
<div className="w-full relative overflow-hidden z=10"> <div className="w-full relative overflow-hidden z=10">
<div className="flex justify-center"> <div className="">
<Image height={400} width={800} alt="Background Image" src="/team.PNG" /> <Image height={1000} width={2000} alt="Background Image" src="/scientists.png"></Image>
</div> </div>
<BottomFooter /> <BottomFooter />
</div> </div>
</section> </section>
</main> </main>
); );
// return (
// <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
// <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
// <Image
// className="dark:invert"
// src="/next.svg"
// alt="Next.js logo"
// width={180}
// height={38}
// priority
// />
// <ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
// <li className="mb-2">
// Get started by editing{" "}
// <code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
// src/app/page.tsx
// </code>
// .
// </li>
// <li>Save and see your changes instantly.</li>
// </ol>
// <div className="flex gap-4 items-center flex-col sm:flex-row">
// <a
// className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
// href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
// target="_blank"
// rel="noopener noreferrer"
// >
// <Image
// className="dark:invert"
// src="/vercel.svg"
// alt="Vercel logomark"
// width={20}
// height={20}
// />
// Deploy now
// </a>
// <a
// 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"
// href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
// target="_blank"
// rel="noopener noreferrer"
// >
// Read our docs
// </a>
// </div>
// </main>
// <footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
// <a
// className="flex items-center gap-2 hover:underline hover:underline-offset-4"
// href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
// target="_blank"
// rel="noopener noreferrer"
// >
// <Image
// aria-hidden
// src="/file.svg"
// alt="File icon"
// width={16}
// height={16}
// />
// Learn
// </a>
// <a
// className="flex items-center gap-2 hover:underline hover:underline-offset-4"
// href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
// target="_blank"
// rel="noopener noreferrer"
// >
// <Image
// aria-hidden
// src="/window.svg"
// alt="Window icon"
// width={16}
// height={16}
// />
// Examples
// </a>
// <a
// className="flex items-center gap-2 hover:underline hover:underline-offset-4"
// href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
// target="_blank"
// rel="noopener noreferrer"
// >
// <Image
// aria-hidden
// src="/globe.svg"
// alt="Globe icon"
// width={16}
// height={16}
// />
// Go to nextjs.org →
// </a>
// </footer>
// </div>
// );
} }

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; return;
} }
} else {
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}
/> />
{user?.email && ( ) : null}
<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,7 +1,5 @@
"use client"; "use client";
import BottomFooter from "@components/BottomFooter";
const teamMembers = [ const teamMembers = [
{ {
name: "Tim Howitz", name: "Tim Howitz",
title: "Chief Crack Inspector", title: "Chief Crack Inspector",
@ -30,32 +28,12 @@ const teamMembers = [
"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!", "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", image: "/Lukeshanthescientist.PNG",
}, },
{
name: "Stuart Nicholson",
title: "Chief Earthquake Enthusiast",
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.",
image: "/stuart.png",
},
{
name: "Athena",
title: "Chief Software Engineer",
description: "Athena is responsible for making all software dreams come true. <3",
image: "/athena.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={{ style={{ backgroundImage: "url('tectonicPlates.png')", backgroundSize: "cover", backgroundPosition: "center" }}
backgroundImage: "url('tectonicPlates.png')",
backgroundSize: "cover",
backgroundPosition: "center"
}}
> >
{/* Overlay */} {/* Overlay */}
<div className="absolute inset-0 bg-black bg-opacity-50 pointer-events-none"></div> <div className="absolute inset-0 bg-black bg-opacity-50 pointer-events-none"></div>
@ -91,8 +69,5 @@ export default function Page() {
))} ))}
</div> </div>
</div> </div>
<BottomFooter />
</>
); );
} }

View File

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

@ -112,11 +112,7 @@ 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,14 +134,10 @@ 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" ||
(user.role === "SCIENTIST" && user.scientist?.level === "SENIOR")
) && (
<div className="flex h-full mr-5"> <div className="flex h-full mr-5">
<ManagementNavbarButton name="Scientist Management" href="/management" /> <ManagementNavbarButton name="Scientist Management" href="/management"></ManagementNavbarButton>
</div> </div>
)
)} )}
{user && user.role === "ADMIN" && ( {user && user.role === "ADMIN" && (
<div className="flex h-full mr-5"> <div className="flex h-full mr-5">

View File

@ -1,7 +1,10 @@
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;
@ -14,13 +17,12 @@ interface SidebarProps {
button1Name: string; button1Name: string;
button2Name: string; button2Name: string;
onButton2Click?: () => void; onButton2Click?: () => void;
onButton1Click?: () => void;
button1Disabled?: boolean;
} }
function MagnitudeNumber({ magnitude }: { magnitude: number }) { function MagnitudeNumber({ magnitude }: { magnitude: number }) {
const magnitudeStr = magnitude.toFixed(1); const magnitudeStr = magnitude.toFixed(1);
const [whole, decimal] = magnitudeStr.split("."); const [whole, decimal] = magnitudeStr.split(".");
return ( return (
<div className="relative" style={{ color: getMagnitudeColor(magnitude) }}> <div className="relative" style={{ color: getMagnitudeColor(magnitude) }}>
<TbHexagon size={40} className="drop-shadow-sm" /> <TbHexagon size={40} className="drop-shadow-sm" />
@ -35,6 +37,8 @@ function MagnitudeNumber({ magnitude }: { magnitude: number }) {
); );
} }
// todo change sidebar event highlighting on selection
export default function Sidebar({ export default function Sidebar({
logTitle, logTitle,
logSubtitle, logSubtitle,
@ -47,10 +51,9 @@ export default function Sidebar({
button1Name, button1Name,
button2Name, button2Name,
onButton2Click, onButton2Click,
onButton1Click,
button1Disabled = false,
}: SidebarProps) { }: SidebarProps) {
const eventsContainerRef = useRef<HTMLDivElement>(null); const eventsContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (selectedEventId && eventsContainerRef.current) { if (selectedEventId && eventsContainerRef.current) {
const selectedEventElement = eventsContainerRef.current.querySelector(`[data-event-id="${selectedEventId}"]`); const selectedEventElement = eventsContainerRef.current.querySelector(`[data-event-id="${selectedEventId}"]`);
@ -69,20 +72,13 @@ export default function Sidebar({
<div className="px-6 pb-8 border-b border-neutral-200"> <div className="px-6 pb-8 border-b border-neutral-200">
<h2 className="text-2xl font-bold text-neutral-800 mb-2">{logTitle}</h2> <h2 className="text-2xl font-bold text-neutral-800 mb-2">{logTitle}</h2>
<p className="text-sm text-neutral-600 leading-relaxed">{logSubtitle}</p> <p className="text-sm text-neutral-600 leading-relaxed">{logSubtitle}</p>
<button
className={`mt-4 w-full py-2 px-4 rounded-lg transition-colors duration-200 font-medium
${button1Disabled
? "bg-gray-300 text-gray-500 cursor-not-allowed"
: "bg-blue-600 hover:bg-blue-700 text-white"
}`}
onClick={onButton1Click}
type="button"
tabIndex={button1Disabled ? -1 : 0} <Link href="/">
aria-disabled={button1Disabled ? "true" : "false"} <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">
>
{button1Name} {button1Name}
</button> </button>
</Link>
{/* "Search Earthquakes" should NOT be wrapped in a Link! */}
<button <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" 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} onClick={onButton2Click}

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,13 +1,9 @@
{ {
"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",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@ -18,43 +14,21 @@
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",
"incremental": true, "incremental": true,
"baseUrl": "src",
"plugins": [ "plugins": [
{ {
"name": "next" "name": "next"
} }
], ],
"paths": { "paths": {
"@components/*": [ "@components/*": ["./src/components/*"],
"./components/*" "@hooks/*": ["./src/hooks/*"],
], "@utils/*": ["./src/utils/*"],
"@hooks/*": [ "@appTypes/*": ["./src/types/*"],
"./hooks/*" "@zod/*": ["./src/zod/*"],
], "@prismaclient": ["./src/generated/prisma/client"],
"@utils/*": [ "@/*": ["./src/*"]
"./utils/*"
],
"@appTypes/*": [
"./types/*"
],
"@zod/*": [
"./zod/*"
],
"@prismaclient": [
"./generated/prisma/client"
],
"@/*": [
"./*"
]
} }
}, },
"include": [ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"next-env.d.ts", "exclude": ["node_modules"]
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }