diff --git a/package-lock.json b/package-lock.json index ee0daed..1ed5173 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "body-parser": "^2.2.0", "csv-parse": "^5.6.0", "csv-parser": "^3.2.0", + "date-fns": "^4.1.0", "dotenv": "^16.5.0", "easy-peasy": "^6.1.0", "express": "^5.1.0", @@ -27,9 +28,11 @@ "lodash": "^4.17.21", "mapbox-gl": "^3.10.0", "next": "^15.1.7", + "next-auth": "^4.24.11", "path": "^0.12.7", "prisma": "^6.4.1", "react": "^19.1.0", + "react-datepicker": "^8.4.0", "react-dom": "^19.1.0", "react-icons": "^5.5.0", "react-leaflet": "^5.0.0", @@ -246,6 +249,59 @@ "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": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1015,6 +1071,15 @@ "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": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2493,6 +2558,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "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": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -2783,6 +2857,16 @@ "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": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -5596,6 +5680,47 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.11", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz", + "integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==", + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.2", + "next": "^12.2.5 || ^13 || ^14 || ^15", + "nodemailer": "^6.6.5", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/next-auth/node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -5634,6 +5759,12 @@ "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": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5766,6 +5897,15 @@ "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": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -5787,6 +5927,51 @@ "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": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6138,6 +6323,28 @@ "integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==", "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6148,6 +6355,12 @@ "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": { "version": "6.8.2", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.8.2.tgz", @@ -6324,6 +6537,21 @@ "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": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", @@ -7344,6 +7572,12 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "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": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", @@ -7832,6 +8066,15 @@ "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", "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": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -8068,6 +8311,12 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "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": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", diff --git a/package.json b/package.json index 5c8e561..0c79f56 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "body-parser": "^2.2.0", "csv-parse": "^5.6.0", "csv-parser": "^3.2.0", + "date-fns": "^4.1.0", "dotenv": "^16.5.0", "easy-peasy": "^6.1.0", "express": "^5.1.0", @@ -30,9 +31,11 @@ "lodash": "^4.17.21", "mapbox-gl": "^3.10.0", "next": "^15.1.7", + "next-auth": "^4.24.11", "path": "^0.12.7", "prisma": "^6.4.1", "react": "^19.1.0", + "react-datepicker": "^8.4.0", "react-dom": "^19.1.0", "react-icons": "^5.5.0", "react-leaflet": "^5.0.0", diff --git a/public/Athena.PNG b/public/Athena.PNG new file mode 100644 index 0000000..f99dba2 Binary files /dev/null and b/public/Athena.PNG differ diff --git a/public/StuartEnthusiast.PNG b/public/StuartEnthusiast.PNG new file mode 100644 index 0000000..5fdf34d Binary files /dev/null and b/public/StuartEnthusiast.PNG differ diff --git a/public/artefact.png b/public/artefact.png index f64abca..2245f84 100644 Binary files a/public/artefact.png and b/public/artefact.png differ diff --git a/public/crack1.png b/public/crack1.png new file mode 100644 index 0000000..f366469 Binary files /dev/null and b/public/crack1.png differ diff --git a/public/crack2.png b/public/crack2.png new file mode 100644 index 0000000..f6ca3e1 Binary files /dev/null and b/public/crack2.png differ diff --git a/public/lava.jpg b/public/lava.jpg new file mode 100644 index 0000000..ea8a87b Binary files /dev/null and b/public/lava.jpg differ diff --git a/public/observe.png b/public/observe.png new file mode 100644 index 0000000..1e34bf5 Binary files /dev/null and b/public/observe.png differ diff --git a/public/pulsatingMap.jpg b/public/pulsatingMap.jpg new file mode 100644 index 0000000..c1e64d7 Binary files /dev/null and b/public/pulsatingMap.jpg differ diff --git a/public/stuart.PNG b/public/stuart.PNG new file mode 100644 index 0000000..087812f Binary files /dev/null and b/public/stuart.PNG differ diff --git a/public/team.PNG b/public/team.PNG new file mode 100644 index 0000000..8e990a4 Binary files /dev/null and b/public/team.PNG differ diff --git a/src/app/administrator/page.tsx b/src/app/administrator/page.tsx index 49fd4ec..2d14294 100644 --- a/src/app/administrator/page.tsx +++ b/src/app/administrator/page.tsx @@ -1,386 +1,569 @@ "use client"; -import React, { useRef, useState } from "react"; +import React, { useRef, useState, useEffect } from "react"; +import { useStoreState } from "@hooks/store"; +// --- Types and labels --- type Role = "ADMIN" | "GUEST" | "SCIENTIST"; const roleLabels: Record = { - ADMIN: "Admin", - GUEST: "Guest", - SCIENTIST: "Scientist", + ADMIN: "Admin", + GUEST: "Guest", + SCIENTIST: "Scientist", }; type User = { - id: number; - email: string; - name: string; - role: Role; - password: string; - createdAt: string; + id: number; + email: string; + name: string; + role: Role; + 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[] = [ - { 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: "bob@example.com", - name: "Bob Brown", - role: "SCIENTIST", - password: "secret3", - createdAt: "2024-06-21T12:13:45Z", - id: 3, - }, - { - email: "alice@example.com", - name: "Alice Johnson", - role: "GUEST", - password: "secret4", - createdAt: "2024-06-20T18:43:20Z", - id: 4, - }, - { email: "eve@example.com", name: "Eve Black", role: "ADMIN", password: "secret5", createdAt: "2024-06-20T19:37:10Z", id: 5 }, - { email: "dave@example.com", name: "Dave Clark", role: "GUEST", password: "pw", createdAt: "2024-06-19T08:39:10Z", id: 6 }, - { email: "fred@example.com", name: "Fred Fox", role: "GUEST", password: "pw", createdAt: "2024-06-19T09:11:52Z", id: 7 }, - { email: "ginny@example.com", name: "Ginny Hall", role: "SCIENTIST", password: "pw", createdAt: "2024-06-17T14:56:27Z", id: 8 }, - { email: "harry@example.com", name: "Harry Lee", role: "ADMIN", password: "pw", createdAt: "2024-06-16T19:28:11Z", id: 9 }, - { email: "ivy@example.com", name: "Ivy Volt", role: "ADMIN", password: "pw", createdAt: "2024-06-15T21:04:05Z", id: 10 }, - { email: "kate@example.com", name: "Kate Moss", role: "SCIENTIST", password: "pw", createdAt: "2024-06-14T11:16:35Z", id: 11 }, - { email: "leo@example.com", name: "Leo Garrison", role: "GUEST", password: "pw", createdAt: "2024-06-12T08:02:51Z", id: 12 }, - { email: "isaac@example.com", name: "Isaac Yang", role: "GUEST", password: "pw", createdAt: "2024-06-12T15:43:29Z", id: 13 }, + { + email: "users-loading@admin.api", + name: "Loading Users", + role: "ADMIN", + createdAt: "Check admin api and frontend", + id: 0, + }, ]; const sortFields = [ - // Sort box options - { label: "Name", value: "name" }, - { label: "Email", value: "email" }, + { label: "Name", value: "name" }, + { label: "Email", value: "email" }, ] as const; - type SortField = (typeof sortFields)[number]["value"]; type SortDir = "asc" | "desc"; const dirLabels: Record = { asc: "ascending", desc: "descending" }; const fieldLabels: Record = { name: "Name", email: "Email" }; +// =========== THE PAGE ============= export default function AdminPage() { - const [users, setUsers] = useState(initialUsers); - const [selectedEmail, setSelectedEmail] = useState(null); + // ---- All hooks at the top! + const user = useStoreState((state) => state.user); - // Local edit state for SCIENTIST form - const [editUser, setEditUser] = useState(null); - // Reset editUser when the selected user changes - React.useEffect(() => { - if (!selectedEmail) setEditUser(null); - else { - const user = users.find((u) => u.email === selectedEmail); - setEditUser(user ? { ...user } : null); - } - }, [selectedEmail, users]); + const [selectedEmail, setSelectedEmail] = useState(null); + const [users, setUsers] = useState(initialUsers); + const [addOpen, setAddOpen] = useState(false); + const [addForm, setAddForm] = useState<{ name: string; email: string; role: Role; password: string }>({ + name: "", + email: "", + role: "SCIENTIST", + password: "", + }); + const [addError, setAddError] = useState(null); + const [addLoading, setAddLoading] = useState(false); + const [editUser, setEditUser] = useState(null); + const [searchField, setSearchField] = useState<"name" | "email">("name"); + const [searchText, setSearchText] = useState(""); + const [roleFilter, setRoleFilter] = useState("all"); + const [sortField, setSortField] = useState("name"); + const [sortDir, setSortDir] = useState("asc"); + const [newPassword, setNewPassword] = useState(""); + const [filterDropdownOpen, setFilterDropdownOpen] = useState(false); + const [sortDropdownOpen, setSortDropdownOpen] = useState(false); + const filterDropdownRef = useRef(null); + const sortDropdownRef = useRef(null); + const [showEmailTooltip, setShowEmailTooltip] = useState(false); - // Search/filter/sort state - const [searchField, setSearchField] = useState<"name" | "email">("name"); - const [searchText, setSearchText] = useState(""); - const [roleFilter, setRoleFilter] = useState("all"); - const [sortField, setSortField] = useState("name"); - const [sortDir, setSortDir] = useState("asc"); - // Dropdown states - const [filterDropdownOpen, setFilterDropdownOpen] = useState(false); - const [sortDropdownOpen, setSortDropdownOpen] = useState(false); - const filterDropdownRef = useRef(null); - const sortDropdownRef = useRef(null); + useEffect(() => { + async function fetchUsers() { + try { + const res = await fetch("/api/admin"); + 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); + else { + const user = users.find((u) => u.email === selectedEmail); + setEditUser(user ? { ...user } : null); + } + }, [selectedEmail, users]); + // Filtering, searching, sorting logic + const filteredUsers = users.filter( + (user) => roleFilter === "all" || user.role === roleFilter + ); + const searchedUsers = filteredUsers.filter((user) => + user[searchField].toLowerCase().includes(searchText.toLowerCase()) + ); + const sortedUsers = [...searchedUsers].sort((a, b) => { + let cmp = a[sortField].localeCompare(b[sortField]); + return sortDir === "asc" ? cmp : -cmp; + }); + async function handleAddUser(e: React.FormEvent) { + e.preventDefault(); + setAddError(null); + if (!addForm.name || !addForm.email || !addForm.password) { + setAddError("All fields are required."); + return; + } + try { + setAddLoading(true); + const res = await fetch("/api/admin", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(addForm), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error || "Failed to add user"); + } + const data = await res.json(); + setUsers((prev) => [...prev, data.user]); + setAddOpen(false); + setAddForm({ name: "", email: "", role: "SCIENTIST", password: "" }); + } catch (err: any) { + setAddError(err?.message || "Unknown error"); + } finally { + setAddLoading(false); + } + } + const handleEditChange = ( + e: React.ChangeEvent, + ) => { + if (!editUser) return; + const { name, value } = e.target; + setEditUser((prev) => (prev ? { ...prev, [name]: value } : null)); + }; + const handlePasswordChange = ( + e: React.ChangeEvent, + ) => { + if (!editUser) return; + const { name, value } = e.target; + setEditUser((prev) => (prev ? { ...prev, [name]: value } : null)); + }; + const selectedUser = users.find((u) => u.email === selectedEmail); + const isEditChanged = React.useMemo(() => { + if (!editUser || !selectedUser) return false; + return ( + editUser.name !== selectedUser.name || + editUser.role !== selectedUser.role || + newPassword + ); + }, [editUser, selectedUser, newPassword]); + async function updateUserOnServer(user: User, password: string) { + const body: any = { + id: user.id, + name: user.name, + role: user.role, + }; + if (password.trim() !== "") { + body.password = password; + } + const res = await fetch("/api/admin", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error("Failed to update user"); + const data = await res.json(); + return data.user as User; + } + const handleUpdate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!editUser) return; + try { + const updated = await updateUserOnServer(editUser, newPassword); + setUsers((prev) => + prev.map((u) => (u.id === updated.id ? { ...updated } : u)) + ); + setNewPassword(""); + } catch (err) { + console.error("Failed to update user:", err); + } + }; + const handleDelete = async () => { + if (!selectedUser) return; + if ( + !window.confirm( + `Are you sure you want to delete "${selectedUser.name}"? This cannot be undone.`, + ) + ) + return; + try { + const res = await fetch("/api/admin", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: selectedUser.id }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error || "Failed to delete user"); + setUsers((prev) => + prev.filter((u) => u.email !== selectedUser.email) + ); + setSelectedEmail(null); + setEditUser(null); + } catch (err: any) { + alert(err?.message || "Delete failed!"); + } + }; + const allRoles: Role[] = ["ADMIN", "GUEST", "SCIENTIST"]; - React.useEffect(() => { - 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); - }, []); + // --- ADMIN ONLY: + if (!user || user.role !== "ADMIN") { + return ( +
+

+ Unauthorized Access +

+
You do not have access to this page.
+
+ ); + } - // Filtering, searching, sorting logic - const filteredUsers = users.filter((user) => roleFilter === "all" || user.role === roleFilter); - const searchedUsers = filteredUsers.filter((user) => user[searchField].toLowerCase().includes(searchText.toLowerCase())); - const sortedUsers = [...searchedUsers].sort((a, b) => { - let cmp = a[sortField].localeCompare(b[sortField]); - return sortDir === "asc" ? cmp : -cmp; - }); - - // Form input change handler - const handleEditChange = (e: React.ChangeEvent) => { - if (!editUser) return; - const { name, value } = e.target; - setEditUser((prev) => (prev ? { ...prev, [name]: value } : null)); - }; - - // Update button logic (compare original selectedUser and editUser) - const selectedUser = users.find((u) => u.email === selectedEmail); - const isEditChanged = React.useMemo(() => { - if (!editUser || !selectedUser) return false; - // Compare primitive fields - return ( - editUser.name !== selectedUser.name || editUser.role !== selectedUser.role || editUser.password !== selectedUser.password - ); - }, [editUser, selectedUser]); - - // Update/save changes - const handleUpdate = (e: React.FormEvent) => { - e.preventDefault(); - if (!editUser) return; - setUsers((prev) => prev.map((u) => (u.email === editUser.email ? { ...editUser } : u))); - // todo create receiving api route - // todo send to api route - // After successful update, update selectedUser local state - // (editUser will auto-sync due to useEffect on users) - }; - - // Delete user logic - const handleDelete = () => { - if (!selectedUser) return; - if (!window.confirm(`Are you sure you want to delete "${selectedUser.name}"? This cannot be undone.`)) return; - setUsers((prev) => prev.filter((u) => u.email !== selectedUser.email)); - setSelectedEmail(null); - setEditUser(null); - }; - - const allRoles: Role[] = ["ADMIN", "GUEST", "SCIENTIST"]; - - // Tooltip handling for email field - const [showEmailTooltip, setShowEmailTooltip] = useState(false); - - return ( -
-
- {/* SIDEBAR */} -
-
- {/* Search Bar */} -
- setSearchText(e.target.value)} - /> - -
- {/* Filter and Sort Buttons */} -
- {/* Filter */} -
- +
+
+ {/* Filter */} +
+ - {filterDropdownOpen && ( -
- + {filterDropdownOpen && ( +
+ - {allRoles.map((role) => ( - + {allRoles.map((role) => ( + - ))} -
- )} -
- {/* Sort */} -
- - {sortDropdownOpen && ( -
- {sortFields.map((opt) => ( - + ))} +
+ )} +
+ {/* Sort */} +
+ + {sortDropdownOpen && ( +
+ {sortFields.map((opt) => ( + - ))} -
- )} -
- {/* Asc/Desc Toggle */} - -
- {/* Sort status text */} - - Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]} - - {/* USERS LIST: full height, scrollable */} -
    - {sortedUsers.map((user) => ( -
  • setSelectedEmail(user.email)} - className={`rounded-lg cursor-pointer border - ${selectedEmail === user.email ? "bg-blue-100 border-blue-400" : "hover:bg-gray-200 border-transparent"} - transition px-2 py-1 mb-1`} - > -
    - {user.name} - {roleLabels[user.role]} -
    -
    - {user.email} -
    -
  • - ))} - {sortedUsers.length === 0 &&
  • No users found.
  • } -
-
-
- {/* MAIN PANEL */} -
- {editUser ? ( -
-

Edit User

-
-
- - {editUser.createdAt} -
-
- - {editUser.id} -
-
- - setShowEmailTooltip(true)} - onMouseLeave={() => setShowEmailTooltip(false)} - /> - {/* Custom tooltip */} - {showEmailTooltip && ( -
- This field cannot be changed.
- To change the email, delete and re-add the user. -
- )} -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- ) : ( -
Select a user...
- )} -
-
-
- ); -} + > + {opt.label} + + ))} +
+ )} +
+ {/* Asc/Desc Toggle */} + + {/* ADD BUTTON */} + + + + Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]} + + {/* USERS LIST */} +
    + {sortedUsers.map((user) => ( +
  • setSelectedEmail(user.email)} + className={`rounded-lg cursor-pointer border + ${selectedEmail === user.email ? "bg-blue-100 border-blue-400" : "hover:bg-gray-200 border-transparent"} + transition px-2 py-1 mb-1`} + > +
    + {user.name} + {roleLabels[user.role]} +
    +
    + {user.email} +
    +
  • + ))} + {sortedUsers.length === 0 &&
  • No users found.
  • } +
+ + + {/* MAIN PANEL */} +
+ {/* Add User Modal */} + {addOpen && ( +
+
+

Add New User

+
+
+ + setAddForm(f => ({ ...f, email: e.target.value }))} + /> +
+
+ + setAddForm(f => ({ ...f, name: e.target.value }))} + /> +
+
+ + +
+
+ + setAddForm(f => ({ ...f, password: e.target.value }))} + /> +
+ {addError &&
{addError}
} +
+ + +
+
+
+
+ )} + + {/* Edit User Panel */} + {editUser ? ( +
+

Edit User

+
+
+ + {editUser.createdAt} +
+
+ + {editUser.id} +
+
+ + setShowEmailTooltip(true)} + onMouseLeave={() => setShowEmailTooltip(false)} + /> + {showEmailTooltip && ( +
+ This field cannot be changed.
+ To change the email, delete and re-add the user. +
+ )} +
+
+ + +
+
+ + +
+
+ + setNewPassword(e.target.value)} + /> +
+
+ + +
+
+
+ ) : ( +
+ Select a user... +
+ )} +
+ + + ); +} \ No newline at end of file diff --git a/src/app/api/admin/route.ts b/src/app/api/admin/route.ts new file mode 100644 index 0000000..b131add --- /dev/null +++ b/src/app/api/admin/route.ts @@ -0,0 +1,126 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { prisma } from "@utils/prisma"; +import { env } from "@utils/env"; +import { verifyJwt } from "@utils/verifyJwt"; +import bcryptjs from "bcryptjs"; +import { z } from "zod"; + +// Helper +async function getUserFromRequest() { + const cookieStore = cookies(); + const token = (await cookieStore).get("jwt")?.value; + if (!token) return null; + const payload = await verifyJwt({ token, secret: env.JWT_SECRET_KEY }); + if (!payload?.userId) return null; + const user = await prisma.user.findUnique({ + where: { id: payload.userId as number }, + select: { id: true, role: true }, + }); + return user; +} + +export async function GET() { + try { + const user = await getUserFromRequest(); + if (!user || user.role !== "ADMIN") { + return NextResponse.json({ error: "Not authorized" }, { status: 403 }); + } + + const users = await prisma.user.findMany({ + select: { id: true, email: true, name: true, role: true, createdAt: true }, + }); + const cleanedUsers = users.map(u => ({ + ...u, + createdAt: u.createdAt instanceof Date ? u.createdAt.toISOString() : u.createdAt, + })); + return NextResponse.json({ users: cleanedUsers }, { status: 200 }); + } catch (error) { + console.error("Error fetching users:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} + +export async function PUT(request: Request) { + try { + const user = await getUserFromRequest(); + if (!user || user.role !== "ADMIN") { + return NextResponse.json({ error: "Not authorized" }, { status: 403 }); + } + const body = await request.json(); + const { id, name, role, password } = body; + const updateData: any = { name, role }; + if (typeof password === "string" && password.trim() !== "") { + updateData.passwordHash = await bcryptjs.hash(password, 10); + } + const updated = await prisma.user.update({ + where: { id }, + data: updateData, + }); + return NextResponse.json({ user: updated }, { status: 200 }); + } catch (error) { + console.error("Update error:", error); + return NextResponse.json({ error: "Update failed" }, { status: 500 }); + } + } + +export async function POST(request: Request) { + try { + const user = await getUserFromRequest(); + if (!user || user.role !== "ADMIN") { + return NextResponse.json({ error: "Not authorized" }, { status: 403 }); + } + const body = await request.json(); + // Validate input (simple for demo, use zod or similar in prod) + const schema = z.object({ + email: z.string().email(), + name: z.string().min(1), + role: z.enum(["ADMIN", "SCIENTIST", "GUEST"]), + password: z.string().min(6) + }); + const { email, name, role, password } = schema.parse(body); + + // Check uniqueness + const exists = await prisma.user.findUnique({ where: { email } }); + if (exists) { + return NextResponse.json({ error: "Email already exists" }, { status: 409 }); + } + + const passwordHash = await bcryptjs.hash(password, 10); + const created = await prisma.user.create({ + data: { + email, + name, + role, + passwordHash, + }, + select: { id: true, email: true, name: true, role: true, createdAt: true }, + }); + + return NextResponse.json({ user: { ...created, createdAt: created.createdAt instanceof Date ? created.createdAt.toISOString() : created.createdAt } }, { status: 201 }); + } catch (error: any) { + console.error("Create user error:", error); + return NextResponse.json({ error: error?.message ?? "Failed to create user" }, { status: 400 }); + } +} + +export async function DELETE(request: Request) { + try { + const user = await getUserFromRequest(); + if (!user || user.role !== "ADMIN") { + return NextResponse.json({ error: "Not authorized" }, { status: 403 }); + } + const body = await request.json(); + const { id } = body; + if (typeof id !== "number" || isNaN(id)) { + return NextResponse.json({ error: "Invalid id" }, { status: 400 }); + } + await prisma.user.delete({ + where: { id } + }); + return NextResponse.json({ success: true }, { status: 200 }); + } catch (error: any) { + console.error("Delete error:", error); + return NextResponse.json({ error: error.message || "Delete failed" }, { status: 500 }); + } + } \ No newline at end of file diff --git a/src/app/api/earthquakes/log/route.ts b/src/app/api/earthquakes/log/route.ts new file mode 100644 index 0000000..f6404ca --- /dev/null +++ b/src/app/api/earthquakes/log/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@utils/prisma"; + +// Generates code using only the country, and highest id in DB for numbering +async function generateEarthquakeCode(type: string, country: string) { + const typeLetter = type.trim().charAt(0).toUpperCase(); + // Remove non-alphanumeric for the country part + const countrySlug = (country || "Unknown").replace(/[^\w]/gi, ""); + // Use highest DB id to find the latest added earthquake's code number + const last = await prisma.earthquake.findFirst({ + orderBy: { id: "desc" }, + select: { code: true } + }); + let num = 10000; + if (last?.code) { + const parts = last.code.split("-"); + const lastNum = parseInt(parts[parts.length - 1], 10); + if (!isNaN(lastNum)) num = lastNum + 1; + } + return `E${typeLetter}-${countrySlug}-${num.toString().padStart(5, "0")}`; +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { date, magnitude, type, location, latitude, longitude, depth, country } = body; + const creatorId = 1; + if (!date || !magnitude || !type || !location || !latitude || !longitude || !depth || !country) { + return NextResponse.json({ error: "Missing fields" }, { status: 400 }); + } + if (+magnitude > 10) { + return NextResponse.json({ error: "Magnitude cannot exceed 10" }, { status: 400 }); + } + const code = await generateEarthquakeCode(type, country); + const eq = await prisma.earthquake.create({ + data: { + date: new Date(date), + code, + magnitude: +magnitude, + type, + location, // "city, country" + latitude: +latitude, + longitude: +longitude, + depth, + creatorId, + } + }); + return NextResponse.json({ id: eq.id, code }, { status: 201 }); + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/earthquakes/search/route.ts b/src/app/api/earthquakes/search/route.ts new file mode 100644 index 0000000..70a7592 --- /dev/null +++ b/src/app/api/earthquakes/search/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function POST(req: NextRequest) { + try { + const { query } = await req.json(); + + // Nothing to search + if (!query || typeof query !== "string" || query.trim().length === 0) { + // Return recent earthquakes if no search string + const earthquakes = await prisma.earthquake.findMany({ + orderBy: { date: "desc" }, + take: 30, + }); + return NextResponse.json({ earthquakes }); + } + + // Simple search: code, location, magnitude (add more fields as desired) + const q = query.trim(); + + const earthquakes = await prisma.earthquake.findMany({ + where: { + OR: [ + { code: { contains: q, } }, + { location: { contains: q, } }, + { + magnitude: Number.isNaN(Number(q)) + ? undefined + : Number(q), + }, + // optionally add more fields + ], + }, + orderBy: { date: "desc" }, + take: 30, + }); + + return NextResponse.json({ earthquakes }); + } catch (e: any) { + console.error("Earthquake search error:", e); + return NextResponse.json( + { error: "Failed to search earthquakes." }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/management/request/route.ts b/src/app/api/management/request/route.ts new file mode 100644 index 0000000..784d42a --- /dev/null +++ b/src/app/api/management/request/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@utils/prisma"; + +export async function POST(req: Request) { + try { + const { requestType, requestingUserId, scientistId, comment } = await req.json(); + const request = await prisma.request.create({ + data: { + requestType, + requestingUser: { connect: { id: requestingUserId } }, + outcome: "IN_PROGRESS", + // Optionally you can connect to Scientist via an inline relation if you have a foreign key + // If the model has comment or details fields, add it! + }, + }); + return NextResponse.json({ request }, { status: 201 }); + } catch (error) { + console.error("Request create error:", error); + return NextResponse.json({ error: "Failed to create request" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/management/route.ts b/src/app/api/management/route.ts new file mode 100644 index 0000000..6c58ad4 --- /dev/null +++ b/src/app/api/management/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@utils/prisma"; + +// GET all scientists (with user, superior.user, subordinates) +export async function GET() { + try { + const scientists = await prisma.scientist.findMany({ + include: { + user: true, + superior: { include: { user: true } }, + subordinates: true, + }, + }); + return NextResponse.json({ scientists }, { status: 200 }); + } catch (error) { + console.error("Error fetching scientists:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} + +// CREATE scientist +export async function POST(req: Request) { + try { + const { name, level, userId, superiorId } = await req.json(); + const scientist = await prisma.scientist.create({ + data: { + name, + level, + user: { connect: { id: userId } }, + superior: superiorId ? { connect: { id: superiorId } } : undefined, + }, + include: { + user: true, + superior: { include: { user: true } }, + subordinates: true, + }, + }); + return NextResponse.json({ scientist }, { status: 201 }); + } catch (error) { + console.error("Scientist create error:", error); + return NextResponse.json({ error: "Failed to create scientist" }, { status: 500 }); + } +} + +// UPDATE scientist +export async function PUT(req: Request) { + try { + const { id, name, level, userId, superiorId } = await req.json(); + const updatedScientist = await prisma.scientist.update({ + where: { id }, + data: { + name, + level, + user: { connect: { id: userId } }, + superior: superiorId ? { connect: { id: superiorId } } : { disconnect: true }, + }, + include: { + user: true, + superior: { include: { user: true } }, + subordinates: true, + }, + }); + return NextResponse.json({ scientist: updatedScientist }, { status: 200 }); + } catch (error) { + console.error("Update error:", error); + return NextResponse.json({ error: "Update failed" }, { status: 500 }); + } +} + +// DELETE scientist +export async function DELETE(req: Request) { + try { + const { id } = await req.json(); + await prisma.scientist.delete({ where: { id } }); + return NextResponse.json({ success: true }, { status: 200 }); + } catch (error) { + console.error("Delete error:", error); + return NextResponse.json({ error: "Delete failed" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/observatories/log/route.ts b/src/app/api/observatories/log/route.ts new file mode 100644 index 0000000..0651cd1 --- /dev/null +++ b/src/app/api/observatories/log/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@utils/prisma"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { + name, location, latitude, longitude, dateEstablished, + dateClosed, isFunctional + } = body; + const creatorId = 1; // (Set per logged-in user if desired) + if ( + !name || !location || latitude == null || longitude == null || + !dateEstablished || isFunctional == null + ) { + return NextResponse.json({ error: "Missing fields" }, { status: 400 }); + } + const created = await prisma.observatory.create({ + data: { + name, + location, + latitude: +latitude, + longitude: +longitude, + dateEstablished: new Date(dateEstablished), + isFunctional, + seismicSensorOnline: false, // You could add this to modal if you want + creatorId + } + }); + return NextResponse.json({ id: created.id, name: created.name }, { status: 201 }); + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/users/all/route.ts b/src/app/api/users/all/route.ts new file mode 100644 index 0000000..766a151 --- /dev/null +++ b/src/app/api/users/all/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@utils/prisma"; + +export async function GET() { + try { + const users = await prisma.user.findMany({ + include: { + scientist: true, // So you know if the user already has a scientist + } + }); + return NextResponse.json({ users }, { status: 200 }); + } catch (error) { + console.error("Error fetching all users:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/contact-us/page.tsx b/src/app/contact-us/page.tsx index d527b37..68e9abd 100644 --- a/src/app/contact-us/page.tsx +++ b/src/app/contact-us/page.tsx @@ -1,28 +1,150 @@ "use client"; import Image from "next/image"; -import React, { useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; + import BottomFooter from "@components/BottomFooter"; const ContactUs = () => { - const [formData, setFormData] = useState({ - name: "", - email: "", - message: "", - }); + // Form/modal + const [formData, setFormData] = useState({ name: "", email: "", message: "" }); + const [isModalOpen, setIsModalOpen] = useState(false); - const handleChange = (e: { target: { name: any; value: any } }) => { + // 1. Lava (Instagram): state & timer + const [lavaActive, setLavaActive] = useState(false); + const lavaTimeout = useRef(null); + + // 2. Pulsating Map (Facebook): state & timer + const [pulsatingActive, setPulsatingActive] = useState(false); + const pulsatingTimeout = useRef(null); + + // 3. Shake (LinkedIn): state & timer + const [shaking, setShaking] = useState(false); + const shakeTimeout = useRef(null); + + // 4. Crack & Collapse (X): state & timer + const [showCracks, setShowCracks] = useState(false); + const [collapse, setCollapse] = useState(false); + const crackTimeout = useRef(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) => { setFormData({ ...formData, [e.target.name]: e.target.value }); }; - - const handleSubmit = (e: { preventDefault: () => void }) => { + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); console.log("Form submitted with data:", formData); - alert("Thank you for reaching out! We will get back to you soon."); + setIsModalOpen(true); setFormData({ name: "", email: "", message: "" }); }; return ( -
+
+ {/* Lava Flood Overlay */} + {lavaActive && ( +
+ Lava flood +
+ )} + {/* Pulsating Overlay */} + {pulsatingActive && ( +
+ Pulsating Map +
+ )} + {/* Crack & Collapse Overlay */} + {(showCracks || collapse) && ( +
+ + +
+ )} + {/* Modal: Submit Success */} + {isModalOpen && ( +
+
+

Thank You!

+

Thank you for submitting a message. We will be responding via our email.

+ +
+
+ )} { className="border border-neutral-300 absolute z-10 -top-20" src="/tsunamiWaves.jpg" /> - {/* Overlay for readability */} -
+
{/* Container */}
{/* Header */} @@ -41,8 +162,6 @@ const ContactUs = () => { 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.

- - {/* Content Section */}
{/* Contact Form Section */}
@@ -62,7 +181,6 @@ const ContactUs = () => { required />
-
-
-
- {/* Contact Details Section */}

Get in Touch

@@ -120,23 +235,51 @@ const ContactUs = () => {

Address

-

1 Swentown Row, Greenwich, London, SE3 0FQ

+

1 Sweentown Row, Greenwich, London, SE3 0FQ

Follow Us

- +
); }; diff --git a/src/app/earthquakes/page.tsx b/src/app/earthquakes/page.tsx index 7a0ea80..45e3d8f 100644 --- a/src/app/earthquakes/page.tsx +++ b/src/app/earthquakes/page.tsx @@ -1,5 +1,4 @@ "use client"; - import { useMemo, useState } from "react"; import useSWR from "swr"; import Map from "@components/Map"; @@ -8,152 +7,133 @@ import { createPoster } from "@utils/axiosHelpers"; import { Earthquake } from "@prismaclient"; import { getRelativeDate } from "@utils/formatters"; import GeologicalEvent from "@appTypes/Event"; -import axios from "axios"; +import EarthquakeSearchModal from "@components/EarthquakeSearchModal"; +import EarthquakeLogModal from "@components/EarthquakeLogModal"; // If you use a separate log modal +import { useStoreState } from "@hooks/store"; -// todo (optional) add in filtering of map earthquakes - -// --- 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); - }; - - if (!open) return null; - return ( -
-
- -

Search Earthquakes

-
- setSearch(e.target.value)} - className="flex-grow px-3 py-2 border rounded" - required - /> - -
-
- {results.length === 0 && !loading && search !== "" &&

No results found.

} -
    - {results.map((eq) => ( -
  • { - onSelect(eq); - onClose(); - }} - tabIndex={0} - > -
    - {eq.code} {eq.location}{" "} - {new Date(eq.date).toLocaleDateString()} -
    -
    = 7 ? "bg-red-500" : eq.magnitude >= 6 ? "bg-orange-400" : "bg-yellow-400" - }`} - > - {eq.magnitude} -
    -
  • - ))} -
-
-
-
- ); +// Optional: "No Access Modal" - as in your original +function NoAccessModal({ open, onClose }) { + if (!open) return null; + return ( +
+
+ +

Access Denied

+

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

+ +
+
+ ); } -// --- MAIN PAGE COMPONENT --- + export default function Earthquakes() { - const [selectedEventId, setSelectedEventId] = useState(""); - const [hoveredEventId, setHoveredEventId] = useState(""); + const [selectedEventId, setSelectedEventId] = useState(""); + const [hoveredEventId, setHoveredEventId] = useState(""); + const [searchModalOpen, setSearchModalOpen] = useState(false); + const [logModalOpen, setLogModalOpen] = useState(false); + const [noAccessModalOpen, setNoAccessModalOpen] = useState(false); - // Search modal state - const [searchModalOpen, setSearchModalOpen] = useState(false); + // Your user/role logic + const user = useStoreState((state) => state.user); + const role: "GUEST" | "SCIENTIST" | "ADMIN" = user?.role ?? "GUEST"; + const canLogEarthquake = role === "SCIENTIST" || role === "ADMIN"; - // Fetch recent earthquakes as before - const { data, error, isLoading } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 5 })); + // Fetch earthquakes (10 days recent) + const { data, error, isLoading, mutate } = useSWR( + "/api/earthquakes", + createPoster({ rangeDaysPrev: 10 }) + ); - // Prepare events for maps/sidebar - const earthquakeEvents = useMemo( - () => - data && data.earthquakes - ? data.earthquakes - .map( - (x: Earthquake): GeologicalEvent => ({ - id: x.code, - title: `Earthquake in ${x.code.split("-")[2]}`, - magnitude: x.magnitude, - longitude: x.longitude, - latitude: x.latitude, - text1: "", - text2: getRelativeDate(x.date), - date: x.date, - }) - ) - .sort((a: GeologicalEvent, b: GeologicalEvent) => new Date(b.date).getTime() - new Date(a.date).getTime()) - : [], - [data] - ); + // Shape for Map/Sidebar + const earthquakeEvents = useMemo( + () => + data && data.earthquakes + ? data.earthquakes + .map( + (x: Earthquake): GeologicalEvent => ({ + id: x.code, + title: `Earthquake in ${x.location || (x.code && x.code.split("-")[2])}`, + magnitude: x.magnitude, + longitude: x.longitude, + latitude: x.latitude, + text1: "", + text2: getRelativeDate(x.date), + date: x.date, + }) + ) + .sort( + (a: GeologicalEvent, b: GeologicalEvent) => + new Date(b.date).getTime() - new Date(a.date).getTime() + ) + : [], + [data] + ); - // Optional: show details of selected search result (not implemented here) - // const [selectedSearchResult, setSelectedSearchResult] = useState(null); + // Handler for log + const handleLogClick = () => { + if (canLogEarthquake) { + setLogModalOpen(true); + } else { + setNoAccessModalOpen(true); + } + }; - return ( -
-
- -
- setSearchModalOpen(true)} // <-- important! - /> - setSearchModalOpen(false)} - onSelect={(eq) => { - setSelectedEventId(eq.code); // select on map/sidebar - // setSelectedSearchResult(eq); // you can use this if you want to show detail modal - }} - /> -
- ); -} + return ( +
+
+ +
+ setSearchModalOpen(true)} + button1Disabled={!canLogEarthquake} + /> + {/* ---- SEARCH MODAL ---- */} + setSearchModalOpen(false)} + onSelect={(eq) => setSelectedEventId(eq.code)} + /> + {/* ---- LOGGING MODAL ---- */} + setLogModalOpen(false)} + onSuccess={() => mutate()} + /> + {/* ---- NO ACCESS ---- */} + setNoAccessModalOpen(false)} + /> +
+ ); +} \ No newline at end of file diff --git a/src/app/earthquakes/search/route.ts b/src/app/earthquakes/search/route.ts deleted file mode 100644 index 48ca6c9..0000000 --- a/src/app/earthquakes/search/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NextResponse } from "next/server"; -import { prisma } from "@utils/prisma"; - -export async function POST(req: Request) { - try { - const { query } = await req.json(); - - // Find earthquakes where either code or location matches (case-insensitive) - const earthquakes = await prisma.earthquake.findMany({ - where: { - OR: [ - { code: { contains: query, mode: "insensitive" } }, - { location: { contains: query, mode: "insensitive" } } - ], - }, - orderBy: { date: "desc" }, - take: 20, // limit results - }); - - return NextResponse.json({ earthquakes, message: "Success" }); - } catch (error) { - console.error("Error in earthquake search", error); - return NextResponse.json({ message: "Internal Server Error" }, { status: 500 }); - } -} \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index 2ab11b8..5fe3514 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -64,4 +64,167 @@ body { .icon-link:focus p { color: #111; /* 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; } \ No newline at end of file diff --git a/src/app/learn/page.tsx b/src/app/learn/page.tsx index ad0542f..b11f775 100644 --- a/src/app/learn/page.tsx +++ b/src/app/learn/page.tsx @@ -55,7 +55,7 @@ export default function LearnPage() {
  • First aid kit and emergency medication
  • Food (non-perishable)
  • Bottled water
  • -
  • Torch (flashlight)
  • +
  • Torch
  • Satellite phone
  • Warm clothing and blankets
  • diff --git a/src/app/management/page.tsx b/src/app/management/page.tsx index 6c2f53c..53be8b0 100644 --- a/src/app/management/page.tsx +++ b/src/app/management/page.tsx @@ -1,177 +1,200 @@ "use client"; -import React, { useRef, useState } from "react"; - +import React, { useRef, useState, useEffect } from "react"; +import { useStoreState } from "@hooks/store"; +// --- Types --- type Level = "JUNIOR" | "SENIOR"; const levelLabels: Record = { JUNIOR: "Junior", SENIOR: "Senior" }; -type User = { id: number; email: string; name: string; }; -type Earthquakes = { id: number; code: string; location: string; }; -type Observatory = { id: number; name: string; location: string; }; -type Artefact = { id: number; name: string; type: string; }; +type User = { + id: number; + name: string; + email: string; + role?: string; + scientist?: Scientist | null; +}; type Scientist = { id: number; createdAt: string; name: string; level: Level; user: User; - userId: User["id"]; + userId: number; superior: Scientist | null; - superiorId: Scientist["id"] | null; + superiorId: number | null; subordinates: Scientist[]; - earthquakes: Earthquakes[]; - earthquakeIds: number[]; - observatories: Observatory[]; - observatoryIds: number[]; - artefacts: Artefact[]; - artefactIds: number[]; }; -const users: User[] = [ - { 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[] = [ +// --- Helpers --- +const initialScientists: Scientist[] = [ { - id: 1, - createdAt: "2024-06-01T09:00:00Z", - name: "Dr. John Junior", + id: 0, + name: "Loading Scientist", level: "JUNIOR", - user: users[0], - userId: 1, - 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, + createdAt: "", + user: { id: 0, name: "Loading...", email: "--" }, + userId: 0, superior: null, superiorId: null, 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 = [ { label: "Name", value: "name" }, - { label: "Level", value: "level" }, + { label: "Level", value: "level" } ] as const; type SortField = (typeof sortFields)[number]["value"]; type SortDir = "asc" | "desc"; const dirLabels: Record = { asc: "ascending", desc: "descending" }; const fieldLabels: Record = { name: "Name", level: "Level" }; -export default function Scientist() { - const [scientists, setScientists] = useState(scientistList); +// --- Updated RequestModal (only level/removal, no comment) +type RequestModalProps = { + open: boolean; + onClose: () => void; + requestingUserId: number; + scientist?: Scientist | null; +}; +function RequestModal({ open, onClose, requestingUserId, scientist }: RequestModalProps) { + const [requestType, setRequestType] = useState("CHANGE_LEVEL"); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(null); + const [error, setError] = useState(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 ? ( +
    +
    +

    Request Action

    +
    +
    + + +
    + {error &&
    {error}
    } + {success &&
    {success}
    } +
    + + +
    +
    +
    +
    + ) : null; +} +// ======================================================== +export default function ScientistManagementPage() { + // All hooks first + const user = useStoreState((state)=>state.user); + const [scientists, setScientists] = useState(initialScientists); + const [allUsers, setAllUsers] = useState([]); const [selectedId, setSelectedId] = useState(null); const [editScientist, setEditScientist] = useState(null); - React.useEffect(() => { + const [addOpen, setAddOpen] = useState(false); + const [requestOpen, setRequestOpen] = useState(false); + const [addForm, setAddForm] = useState<{ name: string; level: Level; userId: number }>({ + name: "", + level: "JUNIOR", + userId: 0, + }); + const [addError, setAddError] = useState(null); + const [addLoading, setAddLoading] = useState(false); + const [searchField, setSearchField] = useState("name"); + const [searchText, setSearchText] = useState(""); + const [levelFilter, setLevelFilter] = useState("all"); + const [sortField, setSortField] = useState("name"); + const [sortDir, setSortDir] = useState("asc"); + const [success, setSuccess] = useState(null); + const [error, setError] = useState(null); + const [filterDropdownOpen, setFilterDropdownOpen] = useState(false); + const [sortDropdownOpen, setSortDropdownOpen] = useState(false); + const filterDropdownRef = useRef(null); + const sortDropdownRef = useRef(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); else { const sc = scientists.find((x) => x.id === selectedId); setEditScientist(sc ? { ...sc } : null); } }, [selectedId, scientists]); - const [searchField, setSearchField] = useState("name"); - const [searchText, setSearchText] = useState(""); - const [levelFilter, setLevelFilter] = useState("all"); - const [sortField, setSortField] = useState("name"); - const [sortDir, setSortDir] = useState("asc"); - const [filterDropdownOpen, setFilterDropdownOpen] = useState(false); - const [sortDropdownOpen, setSortDropdownOpen] = useState(false); - const filterDropdownRef = useRef(null); - const sortDropdownRef = useRef(null); - React.useEffect(() => { + 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); @@ -179,151 +202,114 @@ export default function Scientist() { document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, []); - const filtered = scientists.filter((s) => levelFilter === "all" || s.level === levelFilter); - const searched = filtered.filter((s) => s[searchField].toLowerCase().includes(searchText.toLowerCase())); + const filtered = scientists.filter(s => levelFilter === "all" || s.level === levelFilter); + const searched = filtered.filter(s => String(s[searchField]).toLowerCase().includes(searchText.toLowerCase())); const sorted = [...searched].sort((a, b) => { - let cmp = a[sortField].localeCompare(b[sortField]); + let cmp = String(a[sortField]).localeCompare(String(b[sortField])); return sortDir === "asc" ? cmp : -cmp; }); 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) => { if (!editScientist) return; const { name, value } = e.target; if (name === "superiorId") { - const supId = value === "" ? null : Number(value); - setEditScientist((prev) => - prev - ? { - ...prev, - superiorId: supId, - superior: supId ? scientists.find((s) => s.id === supId) ?? null : null, - } - : null - ); - } else if (name === "level") { - setEditScientist((prev) => (prev ? { ...prev, level: value as Level } : null)); + const supId = value === "" ? null : Number(value); + setEditScientist(prev => prev ? { + ...prev, + superiorId: supId, + superior: supId ? scientists.find((s) => s.id === supId) ?? null : null + } : null); + } else if (name === "level") { + setEditScientist(prev => prev ? { ...prev, level: value as Level } : null); } else if (name === "userId") { - const user = users.find((u) => u.id === Number(value)); - setEditScientist((prev) => (prev && user ? { ...prev, user, userId: user.id } : prev)); + const user = allUsers.find((u) => u.id === Number(value)); + setEditScientist(prev => (prev && user ? { ...prev, user, userId: user.id } : prev)); } else { - setEditScientist((prev) => (prev ? { ...prev, [name]: value } : null)); + setEditScientist(prev => prev ? { ...prev, [name]: value } : null); } }; - function handleArtefactCheck(id: number) { - if (!editScientist) return; - let nextIds = editScientist.artefactIds.includes(id) - ? editScientist.artefactIds.filter((ai) => ai !== id) - : [...editScientist.artefactIds, id]; - setEditScientist((prev) => - prev - ? { - ...prev, - artefactIds: nextIds, - artefacts: allArtefacts.filter((a) => nextIds.includes(a.id)), - } - : null - ); - } - 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) => { + async function handleAddScientist(e: React.FormEvent) { 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; - 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 - ) + try { + const updatedScientist = await updateScientistOnServer(editScientist); + setScientists(prev => prev.map(sci => sci.id === updatedScientist.id ? updatedScientist : sci)); + setEditScientist(updatedScientist); + setSuccess("Scientist updated!"); + } catch { + setError("Couldn't update scientist"); + } + }; + 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 ( +
    +

    Unauthorized Access

    +
    You do not have access to this page.
    +
    ); - }; - 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 (
    @@ -348,15 +334,14 @@ export default function Scientist() {
    - {/* Filter dropdown */}
    )}
    - {/* sort dropdown */}
    + {isAdmin && ( + + )}
    Scientists sorted by {fieldLabels[sortField]} {dirLabels[sortDir]} @@ -457,247 +455,181 @@ export default function Scientist() {
    - {/* MAIN PANEL */} -
    - {editScientist ? ( -
    - {/* Heading */} -
    Edit Scientist
    -
    -
    - {/* LEFT COLUMN */} -
    -
    -
    Created at
    -
    {editScientist.createdAt}
    -
    -
    -
    ID
    -
    {editScientist.id}
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - -
    - {editScientist.subordinates.length > 0 - ? editScientist.subordinates.map((s) => ( - - {s.name} - - )) - : None - } -
    -
    -
    - {/* RIGHT COLUMN */} -
    - {/* Observatories Box */} -
    -
    - - setObservatoryQuery(e.target.value)} - style={{maxWidth: "55%"}} - /> -
    -
    - {searchedObservatories.length === 0 ? ( -
    No observatories
    - ) : ( - searchedObservatories.map((obs) => ( - - )) - )} -
    -
    - {/* Earthquakes Box */} -
    -
    - - setEarthquakeQuery(e.target.value)} - style={{maxWidth: "55%"}} - /> -
    -
    - {searchedEarthquakes.length === 0 ? ( -
    No earthquakes
    - ) : ( - searchedEarthquakes.map((eq) => ( - - )) - )} -
    -
    - {/* Artefacts Box */} -
    -
    - - setArtefactQuery(e.target.value)} - style={{maxWidth: "55%"}} - /> -
    -
    - {searchedArtefacts.length === 0 ? ( -
    No artefacts
    - ) : ( - searchedArtefacts.map((a) => ( - - )) - )} -
    -
    -
    + {/* Add Scientist Modal */} + {addOpen && ( +
    +
    +

    Add New Scientist

    + +
    + + setAddForm(f => ({ ...f, name: e.target.value }))} + />
    - {/* BUTTONS */} -
    -
    +
    + + +
    + {addError &&
    {addError}
    } +
    + - + + 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"} +
    - +
    +
    + )} + {/* Request Modal */} + setRequestOpen(false)} + requestingUserId={user?.id} + scientist={editScientist} + /> + {/* MAIN PANEL */} +
    + {editScientist ? ( +
    +

    Scientist Details

    + {!!success &&
    {success}
    } + {!!error &&
    {error}
    } +
    + + +
    +
    + + +
    +
    + + +
    +
    + + {isAdmin ? ( + ) : ( -
    Select a scientist...
    +
    + {editScientist.superior && editScientist.superior.user ? ( + <> + {editScientist.superior.name} + + ({editScientist.superior.user.email}) + + + ) : ( + None + )} +
    )} +
    +
    + {isAdmin && ( + <> + + + + )} + {readOnly && ( + + )} +
    +
    + ) : ( +
    Select a scientist...
    + )}
    diff --git a/src/app/observatories/page.tsx b/src/app/observatories/page.tsx index b496a09..6a1ac17 100644 --- a/src/app/observatories/page.tsx +++ b/src/app/observatories/page.tsx @@ -1,70 +1,116 @@ "use client"; -import { useMemo } from "react"; - -import { useState } from "react"; +import { useState, useMemo } from "react"; import useSWR from "swr"; - import Sidebar from "@/components/Sidebar"; import Map from "@components/Map"; +import LogObservatoryModal from "@/components/LogObservatoryModal"; // Adjust if your path is different import { fetcher } from "@utils/axiosHelpers"; import { Observatory } from "@prismaclient"; import { getRelativeDate } from "@utils/formatters"; import GeologicalEvent from "@appTypes/Event"; +import { useStoreState } from "@hooks/store"; -// todo add in showing of observatory stats when searching -// todo add in deleting observatory when searching -// todo add in changing colour of observatory icons if non-functional +function NoAccessModal({ open, onClose }) { + if (!open) return null; + return ( +
    +
    + +

    No Access

    +

    Sorry, You do not have access rights, please log in or contact an Admin.

    + +
    +
    + ); +} export default function Observatories() { - const [selectedEventId, setSelectedEventId] = useState(""); - const [hoveredEventId, setHoveredEventId] = useState(""); - const { data, error, isLoading } = useSWR("/api/observatories", fetcher); + const [selectedEventId, setSelectedEventId] = useState(""); + const [hoveredEventId, setHoveredEventId] = useState(""); + const [logModalOpen, setLogModalOpen] = useState(false); + 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"; - // todo add in earthquake events - const observatoryEvents = useMemo( - () => - data && data.observatories - ? data.observatories - .map( - (x: Observatory): GeologicalEvent => ({ - id: x.id.toString(), - title: `New Observatory - ${x.name}`, - longitude: x.longitude, - latitude: x.latitude, - text1: "", - text2: getRelativeDate(x.dateEstablished), - date: x.dateEstablished, - }) - ) - .sort((a: GeologicalEvent, b: GeologicalEvent) => new Date(b.date).getTime() - new Date(a.date).getTime()) // Remove Date conversion - : [], - [data] - ); + const { data, error, isLoading, mutate } = useSWR( + "/api/observatories", + fetcher + ); - return ( -
    -
    - -
    - -
    - ); + const observatoryEvents = useMemo( + () => + data && data.observatories + ? data.observatories + .map((x: Observatory): GeologicalEvent & { isFunctional: boolean } => ({ + id: x.id.toString(), + title: ` ${x.name}`, + longitude: x.longitude, + latitude: x.latitude, + isFunctional: x.isFunctional, // <-- include this! + text1: "", + text2: getRelativeDate(x.dateEstablished), + date: x.dateEstablished, + })) + .sort( + (a: GeologicalEvent, b: GeologicalEvent) => + new Date(b.date).getTime() - new Date(a.date).getTime() + ) + : [], + [data] + ); + + const handleLogClick = () => { + if (canLogObservatory) { + setLogModalOpen(true); + } else { + setNoAccessModalOpen(true); + } + }; + + return ( +
    +
    + +
    + + setLogModalOpen(false)} + onSuccess={() => mutate()} + /> + setNoAccessModalOpen(false)} + /> +
    + ); } diff --git a/src/app/our-mission/page.tsx b/src/app/our-mission/page.tsx index 3116340..3b35e4b 100644 --- a/src/app/our-mission/page.tsx +++ b/src/app/our-mission/page.tsx @@ -1,20 +1,21 @@ "use client"; import Image from "next/image"; +import BottomFooter from "@components/BottomFooter"; function OurMission() { return (
    {/* Overlay for Readability */} -
    + {/*
    */} {/* Centered content */} -
    +
    {/* Title & Mission Statement */}
    -

    Our Mission

    -

    Earthquake awareness accessible for everyone

    +

    Our Mission

    +

    Earthquake awareness accessible for everyone

    {/* Content Area */}
    @@ -23,6 +24,15 @@ function OurMission() { and why earthquakes occur to enable better preparation and recovery. Education, cutting-edge research, and innovative technology combine together.

    +
    + Tremor Tracker Logo +

    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 @@ -53,6 +63,7 @@ function OurMission() {

    +
    ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 4f69519..2bcd8ea 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,241 +1,214 @@ +"use client"; import Image from "next/image"; import Link from "next/link"; - +import { TbHexagon } from "react-icons/tb"; +import useSWR from "swr"; 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 ( +
    + +
    +
    + {whole} + . + {decimal} +
    +
    +
    + ); +} export default function Home() { - return ( -
    -
    -
    - Background Image -
    -
    -
    - Title Image -
    -
    -

    -
    - - Education Icon -

    Earthquakes

    -

    - Log new earthquakes with their required details or search past seismic events -

    - - - Research Icon -

    Observatories

    -

    - Find recently active observatories, and newly opened/closed sites -

    - - - Technology Icon -

    Artefacts

    -

    - View or purchase recently discovered artefacts from seismic events -

    - -
    -

    -
    -
    -
    - Background Image -
    -
    -
    -

    - Welcome to Tremor Tracker -

    -

    - TremorTracker is a non-profit website and research company, that aims to provide true, reliable data. Our mission - is seismic education and preparation for all -

    -

    -

    What is an earthquake?

    -

    - Earthquakes are a shaking of the earth's surface caused by a sudden release of energy underground. They can range - in size, from tiny trembles to large quakes, which can cause destruction and even tsunamis. Hundreds of - earthquakes happen every day—but most are too small to feel. -

    -

    -

    - How do we log earthquakes? -

    -

    - What information are we interested in? -

    -

    info

    -

    -

    - What are observatories? -

    -

    What is their role?

    -

    info

    -
    -
    -
    -
    -

    -
    -

    Recent Earthquake Events

    -

    - Learn about the most recent earthquake events from around the world: -

    -
    -

    -
    - {["Earthquake 1", "Earthquake 2", "Earthquake 3", "Earthquake 4", "Earthquake 5"].map((name) => ( -
    -

    {name}

    -

    -
    - ))} -
    -

    -
    -

    Contact Information

    -

    - Learn about Tremor Tracker's mission, our team or contact us directly: -

    -
    -

    -
    - - Education Icon -

    Contact us directly

    -

    - Visit our socials or leave us a message via phone or email. -

    - - - Research Icon -

    Our Mission

    -

    - Find out more about our purpose and the features we offer. -

    - - - Technology Icon -

    Meet the Team

    -

    - Learn about our team leads and their responsibilities. -

    - -
    -

    -
    -
    -
    - Background Image -
    - -
    -
    -
    - ); + 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 ( - //
    - //
    - // Next.js logo - //
      - //
    1. - // Get started by editing{" "} - // - // src/app/page.tsx - // - // . - //
    2. - //
    3. Save and see your changes instantly.
    4. - //
    - - // - //
    - // - //
    - // ); -} + return ( +
    +
    +
    + Background Image +
    +
    +
    + Title Image +
    +
    +

    +
    + + Education Icon +

    Earthquakes

    +

    + Log new earthquakes with their required details or search past seismic events +

    + + + Research Icon +

    Observatories

    +

    + Find recently active observatories, and newly opened/closed sites +

    + + + Technology Icon +

    Artefacts

    +

    + View or purchase recently discovered artefacts from seismic events +

    + +
    +

    +
    +
    +
    + Background Image +
    +
    +
    +

    + Welcome to Tremor Tracker +

    +

    + TremorTracker is a non-profit website and research company, that aims to provide true, reliable data. Our mission + is seismic education and preparation for all +

    +

    +

    What is an earthquake?

    +

    + Earthquakes are a shaking of the earth's surface caused by a sudden release of energy underground. They can range + in size, from tiny trembles to large quakes, which can cause destruction and even tsunamis. Hundreds of + earthquakes happen every day—but most are too small to feel. +

    +

    +

    + How do we log earthquakes? +

    +

    + What information are we interested in? +

    +

    info

    +

    +

    + What are observatories? +

    +

    What is their role?

    +

    info

    +
    +
    +
    +
    +

    +
    +

    + Recent Earthquake Events +

    +

    + Learn about the most recent earthquake events from around the world: +

    +
    +

    +
    + {error && ( +
    +

    Failed to load earthquakes.

    +
    + )} + {isLoading && ( +
    +

    Loading...

    +
    + )} + {!isLoading && recents.length === 0 && ( +
    +

    No earthquakes found.

    +
    + )} +
    + {recents.map((eq) => ( +
    +
    +
    + Earthquake in {eq.location || (eq.code && eq.code.split("-")[2])} +
    +
    {getRelativeDate(eq.date)}
    +
    + +
    + ))} +
    +
    +

    +
    +

    + Find Out More! +

    +

    + Explore more of our website... +

    +
    +

    +
    + + Education Icon +

    Contact us directly

    +

    + Visit our socials or leave us a message via phone or email. +

    + + + Research Icon +

    Our Mission

    +

    + Find out more about our purpose and the features we offer. +

    + + + Technology Icon +

    Meet the Team

    +

    + Learn about our team leads and their responsibilities. +

    + +
    +

    +
    +
    +
    + Background Image +
    + +
    +
    +
    + ); +} \ No newline at end of file diff --git a/src/app/shop/page.tsx b/src/app/shop/page.tsx index 4365800..a838498 100644 --- a/src/app/shop/page.tsx +++ b/src/app/shop/page.tsx @@ -1,43 +1,41 @@ "use client"; import Image from "next/image"; -import { Dispatch, SetStateAction, useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { ExtendedArtefact } from "@appTypes/ApiTypes"; import { Currency } from "@appTypes/StoreModel"; import BottomFooter from "@components/BottomFooter"; import { useStoreState } from "@hooks/store"; -// todo hide from shop after purchase - export default function Shop() { const [artefacts, setArtefacts] = useState([]); const [hiddenArtefactIds, setHiddenArtefactIds] = useState([]); const [loading, setLoading] = useState(true); + + const [cart, setCart] = useState([]); + const [showCartModal, setShowCartModal] = useState(false); + const user = useStoreState((state) => state.user); - // 3. Fetch from your API route and map data to fit your existing fields useEffect(() => { async function fetchArtefacts() { setLoading(true); try { - // todo only show only non-required artefacts const res = await fetch("/api/artefacts"); const data = await res.json(); - const transformed = data.artefact.map((a: any) => ({ id: a.id, name: a.name, description: a.description, - location: a.warehouseArea, // your database + location: a.warehouseArea, earthquakeID: a.earthquakeId?.toString() ?? "", - observatory: a.type ?? "", // if you want to display type + observatory: a.type ?? "", dateReleased: a.createdAt ? new Date(a.createdAt).toLocaleDateString() : "", image: "/artefactImages/" + (a.imageName || "NoImageFound.PNG"), - price: a.shopPrice ?? 100, // fallback price if not in DB + price: a.shopPrice ?? 100, })); setArtefacts(transformed); } catch (e) { - // Optionally handle error console.error("Failed to fetch artefacts", e); } finally { setLoading(false); @@ -47,9 +45,10 @@ export default function Shop() { }, []); const [currentPage, setCurrentPage] = useState(1); - const [selectedArtefact, setSelectedArtefact] = useState(null); + const [selectedArtefact, setSelectedArtefact] = useState(null); const [showPaymentModal, setShowPaymentModal] = useState(false); - const [artefactToBuy, setArtefactToBuy] = useState(null); + const [artefactToBuy, setArtefactToBuy] = useState(null); + const [cartCheckout, setCartCheckout] = useState(false); // true = checkout cart (not single artefact) const [showThankYouModal, setShowThankYouModal] = useState(false); const [orderNumber, setOrderNumber] = useState(null); @@ -93,11 +92,14 @@ export default function Shop() {
    ); } + function Modal({ artefact }: { artefact: ExtendedArtefact }) { if (!artefact) return null; const handleOverlayClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) setSelectedArtefact(null); }; + const inCart = cart.some((a) => a.id === artefact.id); + return (
    {artefact.earthquakeID}

    {artefact.observatory}

    {artefact.dateReleased}

    -
    +
    +
    @@ -138,15 +156,99 @@ export default function Shop() { ); } - function PaymentModal({ artefact, onClose }: { artefact: Artefact; onClose: () => void }) { + function CartModal() { + const total = cart.reduce((sum, art) => sum + art.price, 0); + + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) setShowCartModal(false); + }; + + return ( +
    +
    +
    +

    Your Cart

    + +
    + {cart.length === 0 ? ( +

    Your cart is empty.

    + ) : ( + <> +
      + {cart.map((art) => ( +
    • +
      + {art.name} +
      +
      +

      {art.name}

      +

      {art.location}

      +
      +

      + {currencyTickers[selectedCurrency]} + {convertPrice(art.price, selectedCurrency)} +

      + +
    • + ))} +
    +
    + Total: + + {currencyTickers[selectedCurrency]} + {convertPrice(total, selectedCurrency)} + +
    +
    + +
    + + )} +
    +
    + ); + } + + function PaymentModal({ + artefact, + onClose, + cartItems, + }: { + artefact?: ExtendedArtefact; + onClose: () => void; + cartItems?: ExtendedArtefact[]; + }) { const [cardNumber, setCardNumber] = useState(""); const [expiry, setExpiry] = useState(""); const [cvc, setCvc] = useState(""); const [name, setName] = useState(""); - const [email, setEmail] = useState(""); + const [email, setEmail] = useState(user?.email || ""); const [remember, setRemember] = useState(false); const [error, setError] = useState(""); + const artefactsToBuy = artefact ? [artefact] : cartItems || []; + const total = artefactsToBuy.reduce((sum, art) => sum + art.price, 0); + function validateEmail(email: string) { return ( email.includes("@") && @@ -162,18 +264,13 @@ export default function Shop() { function validateExpiry(exp: string) { return /^\d{2}\/\d{2}$/.test(exp); } - function handlePay() { setError(""); - if (email || user?.email) { - if (!validateEmail(email)) { - setError("Please enter a valid email ending"); - return; - } - } else { + const paymentEmail = user?.email || email; + if (!validateEmail(paymentEmail)) { + setError("Please enter a valid email"); return; } - if (!validateCardNumber(cardNumber)) { setError("Card number must be 12-19 digits."); return; @@ -186,46 +283,51 @@ export default function Shop() { setError("CVC must be 3 or 4 digits."); return; } - - setHiddenArtefactIds((ids) => [...ids, artefact.id]); - + // remove all artefacts that were bought (works for both cart and single) + setHiddenArtefactIds((ids) => [...ids, ...artefactsToBuy.map((a) => a.id)]); // todo create receiving 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(); setOrderNumber(genOrder()); onClose(); setShowThankYouModal(true); + setCart((c) => c.filter((a) => !artefactsToBuy.map((x) => x.id).includes(a.id))); } const handleOverlayClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) onClose(); }; return (
    -

    Buy {artefact.name}

    - {/* ...Image... */} +

    + Checkout {artefact ? artefact.name : artefactsToBuy.length + " item(s)"} + {!artefact && ({artefactsToBuy.map((x) => x.name).join(", ")})} +

    { e.preventDefault(); handlePay(); }} > - {!user ? ( - setEmail(e.target.value)} - type="email" - required - autoFocus - /> - ) : null} + {/* Email autofill */} + setEmail(e.target.value)} + type="email" + required + autoFocus + disabled={!!user?.email} + /> + {user?.email && ( +

    + Signed in as {user.email} +

    + )} {error &&

    {error}

    } +
    + Total: + + {currencyTickers[selectedCurrency]} + {convertPrice(total, selectedCurrency)} + +

    Artefact Shop @@ -350,19 +481,22 @@ export default function Shop() {

    {selectedArtefact && } - {artefactToBuy && showPaymentModal && ( + {showCartModal && } + {showPaymentModal && (cartCheckout || artefactToBuy) && ( { setShowPaymentModal(false); setArtefactToBuy(null); + setCartCheckout(false); }} /> )} {showThankYouModal && orderNumber && ( setShowThankYouModal(false)} /> )} - {!selectedArtefact && !showPaymentModal && !showThankYouModal && ( + {!selectedArtefact && !showPaymentModal && !showThankYouModal && !showCartModal && (
    diff --git a/src/app/the-team/page.tsx b/src/app/the-team/page.tsx index 2422329..ab43e28 100644 --- a/src/app/the-team/page.tsx +++ b/src/app/the-team/page.tsx @@ -1,73 +1,98 @@ "use client"; +import BottomFooter from "@components/BottomFooter"; const teamMembers = [ + + { + name: "Tim Howitz", + title: "Chief Crack Inspector", + description: + "Tim is responsible for analysing structures on a day-to-day basis. In his home life he is a keen outdoors enthusiast, with three kids and a dog.", + image: "/Timthescientist.PNG", + }, + { + name: "Emily Neighbour", + title: "Chief Software Engineer", + description: + "Emily focuses on vital earthquake prediction models. In her personal life, her hobbies include knitting, birdwatching, and becoming a mute.", + image: "/Emilythescientist.PNG", + }, + { + name: "Izzy Patterson", + title: "Chief Geologist", + description: + "Izzy's team are responsible for inspecting the rocks that make up our planet. For enjoyment she likes to look at rocks, sometimes she likes to lick them.", + image: "/Izzythescientist.PNG", + }, + { + name: "Lukeshan Thananchayan", + title: "Chief Duster", + description: + "Lukeshan and his team look at the dust particles created by an earthquake and how to minimise their damage. For pleasure, he likes to play Monopoly on repeat. Maybe one day he'll get a hotel!", + image: "/Lukeshanthescientist.PNG", + }, + { - name: "Tim Howitz", - title: "Chief Crack Inspector", + name: "Stuart Nicholson", + title: "Chief Earthquake Enthusiast", description: - "Tim is responsible for analysing structures on a day-to-day basis. In his home life he is a keen outdoors enthusiast, with three kids and a dog.", - image: "/Timthescientist.PNG", + "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: "Emily Neighbour", + name: "Athena", title: "Chief Software Engineer", - description: - "Emily focuses on vital earthquake prediction models. In her personal life, her hobbies include knitting, birdwatching, and becoming a mute.", - image: "/Emilythescientist.PNG", - }, - { - name: "Izzy Patterson", - title: "Chief Geologist", - description: - "Izzy's team are responsible for inspecting the rocks that make up our planet. For enjoyment she likes to look at rocks, sometimes she likes to lick them.", - image: "/Izzythescientist.PNG", - }, - { - name: "Lukeshan Thananchayan", - title: "Chief Duster", - description: - "Lukeshan and his team look at the dust particles created by an earthquake and how to minimise their damage. For pleasure, he likes to play Monopoly on repeat. Maybe one day he'll get a hotel!", - image: "/Lukeshanthescientist.PNG", + description: "Athena is responsible for making all software dreams come true. <3", + image: "/athena.PNG", }, ]; + export default function Page() { - return ( -
    - {/* Overlay */} -
    - {/* Header */} -
    -

    Meet the Team

    -

    - Our world-class scientists and engineers drive innovation across the globe. Meet our four department heads: -

    -
    - {/* Team Members Section */} -
    - {teamMembers.map((member, index) => ( -
    - {/* Image */} -
    -
    -
    - {member.name} -
    -
    -
    - {/* Text Content */} -
    -

    {member.name}

    -

    {member.title}

    -

    {member.description}

    -
    -
    - ))} -
    -
    - ); + return ( + <> +
    + {/* Overlay */} +
    + {/* Header */} +
    +

    Meet the Team

    +

    + Our world-class scientists and engineers drive innovation across the globe. Meet our four department heads: +

    +
    + {/* Team Members Section */} +
    + {teamMembers.map((member, index) => ( +
    + {/* Image */} +
    +
    +
    + {member.name} +
    +
    +
    + {/* Text Content */} +
    +

    {member.name}

    +

    {member.title}

    +

    {member.description}

    +
    +
    + ))} +
    +
    + + + + ); } diff --git a/src/components/BottomFooter.tsx b/src/components/BottomFooter.tsx index 80f133c..fcb9f57 100644 --- a/src/components/BottomFooter.tsx +++ b/src/components/BottomFooter.tsx @@ -1,74 +1,178 @@ -// components/Footer.tsx +import React, { useCallback, useRef, useState } from "react"; import Link from "next/link"; -import { FaFacebook, FaLinkedin, FaTwitter, FaYoutube } from "react-icons/fa"; -export default function Footer() { - return ( -
    -
    - {/* Useful Links */} -
    -

    Useful links

    -
      -
    • - - Gov.UK guidance - -
    • -
    • - - Privacy policy - -
    • -
    • - - Cookies policy - -
    • -
    -
    - {/* Donate Section */} -
    -

    Donate

    -

    - We are a nonprofit entirely funded by your donations, every penny helps provide life saving information. -

    - - Donate Now - -
    -
    - {/* Bottom bar */} -
    - {/* Bottom left: Copyright */} - - © TremorTracker 2025 - - {/* Bottom right: Social icons */} -
    - Follow us on -
    - {/* Replace src with your icon URLs, or use next/image if preferred */} - - instagram - - - linkedin - - - X - -
    -
    -
    -
    - ); -} +export default function BottomFooter() { + // ig easter egg + const [lavaActive, setLavaActive] = useState(false); + const lavaTimeout = useRef(null); + + // LinkedIn easter egg + const [shaking, setShaking] = useState(false); + const shakeTimeout = useRef(null); + + // x easter egg + const [showCracks, setShowCracks] = useState(false); + const [collapse, setCollapse] = useState(false); + const crackTimeout = useRef(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 ( + <> + {/* Lava Flood Overlay */} + {lavaActive && ( +
    + Lava flood +
    + )} + + {/* Crack & Collapse Overlay */} + {(showCracks || collapse) && ( +
    + + + {/* Add more cracks for extra effect if you wish */} +
    + )} + + {/* Footer */} +
    +
    + {/* Useful Links */} +
    +

    Useful links

    +
      +
    • + + Gov.UK guidance + +
    • +
    • + + Privacy policy + +
    • +
    • + + Cookies policy + +
    • +
    +
    + + {/* Donate Section */} +
    +

    Donate

    +

    + We are a nonprofit entirely funded by your donations, every penny helps provide life saving information. +

    + + Donate Now + +
    +
    + + {/* Bottom bar */} +
    +
    + TremorTracker logo +
    + + © TremorTracker 2025 + +
    + Follow us on + +
    +
    +
    + + ); +} \ No newline at end of file diff --git a/src/components/EarthquakeLogModal.tsx b/src/components/EarthquakeLogModal.tsx new file mode 100644 index 0000000..ce9d30f --- /dev/null +++ b/src/components/EarthquakeLogModal.tsx @@ -0,0 +1,248 @@ +"use client"; +import { useState } from "react"; +import DatePicker from "react-datepicker"; +import "react-datepicker/dist/react-datepicker.css"; + +const typeOptions = [ + { value: "volcanic", label: "Volcanic" }, + { value: "tectonic", label: "Tectonic" }, + { value: "collapse", label: "Collapse" }, + { value: "explosion", label: "Explosion" } +]; + +export default function EarthquakeLogModal({ open, onClose, onSuccess }) { + const [date, setDate] = useState(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(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 ( +
    +
    + +
    +

    + Thank you for logging an earthquake! +

    +
    The Earthquake Identifier is
    +
    {successCode}
    +
    +
    +
    + ); + } + + return ( +
    +
    + +

    Log Earthquake

    + +
    + + setDate(date)} + className="border rounded px-3 py-2 w-full" + dateFormat="yyyy-MM-dd" + maxDate={new Date()} + showMonthDropdown + showYearDropdown + dropdownMode="select" + required + /> +
    +
    + + { + const val = e.target.value; + if (parseFloat(val) > 10) return; + setMagnitude(val); + }} + required + /> +
    +
    + + +
    +
    + + + (Use Lat/Lon then press Enter for reverse lookup) + + setCity(e.target.value)} + required + /> +
    +
    + + setCountry(e.target.value)} + required + /> +
    +
    +
    + + handleLatLonChange(e.target.value, longitude)} + placeholder="e.g. 36.12" + step="any" + required + /> +
    +
    + + handleLatLonChange(latitude, e.target.value)} + placeholder="e.g. -115.17" + step="any" + required + /> +
    +
    +
    + + setDepth(e.target.value)} + placeholder="e.g. 10 km" + required + /> +
    + + +
    +
    + ); +} \ No newline at end of file diff --git a/src/components/EarthquakeSearchModal.tsx b/src/components/EarthquakeSearchModal.tsx new file mode 100644 index 0000000..d433bd0 --- /dev/null +++ b/src/components/EarthquakeSearchModal.tsx @@ -0,0 +1,245 @@ +"use client"; +import { useState, useEffect, useMemo } from "react"; +import axios from "axios"; + +export type Earthquake = { + id: string; + code: string; + magnitude: number; + location: string; + date: string; + longitude: number; + latitude: number; +}; + +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString(); +} + +const COLUMNS = [ + { label: "Code", key: "code", className: "font-mono font-bold" }, + { label: "Location", key: "location" }, + { label: "Magnitude", key: "magnitude", numeric: true }, + { label: "Date", key: "date" }, +]; + +export default function EarthquakeSearchModal({ + open, + onClose, + onSelect, +}: { + open: boolean; + onClose: () => void; + onSelect: (eq: Earthquake) => void; +}) { + const [search, setSearch] = useState(""); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + // 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 ( +
    +
    + +

    Search Earthquakes

    +
    { + e.preventDefault(); + doSearch(); + }} + className="flex gap-2 mb-3" + > + setSearch(e.target.value)} + className="flex-grow px-3 py-2 border rounded" + disabled={loading} + /> + + +
    + {error && ( +
    {error}
    + )} + {/* Filter Row */} +
    +
    + {COLUMNS.map((col) => ( + + 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} + /> + ))} +
    +
    + {/* Results Table */} +
    + + + + {COLUMNS.map((col) => ( + + ))} + + + + {sortedRows.length === 0 && !loading && ( + + + + )} + {sortedRows.map(eq => ( + { + onSelect(eq); + onClose(); + }} + tabIndex={0} + > + + + + + + ))} + +
    + 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" ? " ↑" : " ↓")} +
    + No results found. +
    {eq.code}{eq.location}{eq.magnitude}{formatDate(eq.date)}
    +
    +
    +
    + ); +} \ No newline at end of file diff --git a/src/components/LogObservatoryModal.tsx b/src/components/LogObservatoryModal.tsx new file mode 100644 index 0000000..fdb8d44 --- /dev/null +++ b/src/components/LogObservatoryModal.tsx @@ -0,0 +1,223 @@ +"use client"; +import { useState } from "react"; +import DatePicker from "react-datepicker"; +import "react-datepicker/dist/react-datepicker.css"; + +const yesNo = [ + { value: true, label: "Yes" }, + { value: false, label: "No" } +]; + +export default function LogObservatoryModal({ open, onClose, onSuccess }) { + const [name, setName] = useState(""); + const [isOpen, setIsOpen] = useState("true"); + const [dateOpened, setDateOpened] = useState(new Date()); + const [dateClosed, setDateClosed] = useState(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 ( +
    +
    + +
    +

    Thank you for logging an observatory!

    +
    The Observatory is now being shown as {success.name}
    +
    +
    +
    + ); + } + + return ( +
    +
    + +

    Log Observatory

    +
    +
    + + setName(e.target.value)} required + /> +
    +
    + + +
    +
    + + setDateOpened(date)} + className="border rounded px-3 py-2 w-full" + dateFormat="yyyy-MM-dd" + showMonthDropdown + showYearDropdown + dropdownMode="select" + required + /> +
    + {isOpen === "false" && ( +
    + + setDateClosed(date)} + className="border rounded px-3 py-2 w-full" + dateFormat="yyyy-MM-dd" + showMonthDropdown + showYearDropdown + dropdownMode="select" + required + /> +
    + )} +
    + + + (Use Lat/Lon then press Enter for reverse lookup) + + setCity(e.target.value)} + required + /> +
    +
    + + setCountry(e.target.value)} + required + /> +
    +
    +
    + + handleLatLonChange(e.target.value, longitude)} + placeholder="e.g. 36.12" + step="any" + required + /> +
    +
    + + handleLatLonChange(latitude, e.target.value)} + placeholder="e.g. -115.17" + step="any" + required + /> +
    +
    + +
    +
    +
    + ); +} \ No newline at end of file diff --git a/src/components/Map.tsx b/src/components/Map.tsx index 2a4094a..9472d62 100644 --- a/src/components/Map.tsx +++ b/src/components/Map.tsx @@ -111,8 +111,12 @@ function MapComponent({ } const observatoryElement = document.createElement("div"); - const root = createRoot(observatoryElement); - root.render(); + const root = createRoot(observatoryElement); + root.render( + + ); quakeElement.appendChild(pulseElement); quakeElement.appendChild(dotElement); diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 9da51ba..4d654b6 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -134,11 +134,15 @@ export default function Navbar({}: // currencySelector,
    )} - {user && (user.role === "SCIENTIST" || user.role === "ADMIN") && ( -
    - -
    - )} + {user && ( + (user.role === "ADMIN" || + (user.role === "SCIENTIST" && user.scientist?.level === "SENIOR") + ) && ( +
    + +
    + ) + )} {user && user.role === "ADMIN" && (
    diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 98bfa62..27f5c22 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,120 +1,124 @@ -import Link from "next/link"; import React, { Dispatch, SetStateAction, useEffect, useRef } from "react"; import { TbHexagon } from "react-icons/tb"; - import GeologicalEvent from "@appTypes/Event"; import getMagnitudeColor from "@utils/getMagnitudeColour"; - interface SidebarProps { - logTitle: string; - logSubtitle: string; - recentsTitle: string; - events: GeologicalEvent[]; - selectedEventId: GeologicalEvent["id"]; - setSelectedEventId: Dispatch>; - hoveredEventId: GeologicalEvent["id"]; - setHoveredEventId: Dispatch>; - button1Name: string; - button2Name: string; - onButton2Click?: () => void; + logTitle: string; + logSubtitle: string; + recentsTitle: string; + events: GeologicalEvent[]; + selectedEventId: GeologicalEvent["id"]; + setSelectedEventId: Dispatch>; + hoveredEventId: GeologicalEvent["id"]; + setHoveredEventId: Dispatch>; + button1Name: string; + button2Name: string; + onButton2Click?: () => void; + onButton1Click?: () => void; + button1Disabled?: boolean; } function MagnitudeNumber({ magnitude }: { magnitude: number }) { - const magnitudeStr = magnitude.toFixed(1); - const [whole, decimal] = magnitudeStr.split("."); - - return ( -
    - -
    -
    - {whole} - . - {decimal} -
    -
    -
    - ); + const magnitudeStr = magnitude.toFixed(1); + const [whole, decimal] = magnitudeStr.split("."); + return ( +
    + +
    +
    + {whole} + . + {decimal} +
    +
    +
    + ); } -// todo change sidebar event highlighting on selection - export default function Sidebar({ - logTitle, - logSubtitle, - recentsTitle, - events, - selectedEventId, - setSelectedEventId, - hoveredEventId, - setHoveredEventId, - button1Name, - button2Name, - onButton2Click, + logTitle, + logSubtitle, + recentsTitle, + events, + selectedEventId, + setSelectedEventId, + hoveredEventId, + setHoveredEventId, + button1Name, + button2Name, + onButton2Click, + onButton1Click, + button1Disabled = false, }: SidebarProps) { - const eventsContainerRef = useRef(null); + const eventsContainerRef = useRef(null); + useEffect(() => { + if (selectedEventId && eventsContainerRef.current) { + const selectedEventElement = eventsContainerRef.current.querySelector(`[data-event-id="${selectedEventId}"]`); + if (selectedEventElement) { + selectedEventElement.scrollIntoView({ + block: "center", + behavior: "smooth", + }); + } + } + }, [selectedEventId]); - useEffect(() => { - if (selectedEventId && eventsContainerRef.current) { - const selectedEventElement = eventsContainerRef.current.querySelector(`[data-event-id="${selectedEventId}"]`); - if (selectedEventElement) { - selectedEventElement.scrollIntoView({ - block: "center", - behavior: "smooth", - }); - } - } - }, [selectedEventId]); - - return ( -
    -
    -
    -

    {logTitle}

    -

    {logSubtitle}

    - - - - - {/* "Search Earthquakes" should NOT be wrapped in a Link! */} - -
    -
    -

    {recentsTitle}

    -
    -
    -
    - {events.map((event) => ( - - ))} -
    -
    -
    -
    - ); -} + return ( +
    +
    +
    +

    {logTitle}

    +

    {logSubtitle}

    + + +
    +
    +

    {recentsTitle}

    +
    +
    +
    + {events.map((event) => ( + + ))} +
    +
    +
    +
    + ); +} \ No newline at end of file diff --git a/src/databases/Users.csv b/src/databases/Users.csv new file mode 100644 index 0000000..2671d2d --- /dev/null +++ b/src/databases/Users.csv @@ -0,0 +1,21 @@ +name,email,password,level +undefined,undefined,undefined,undefined +undefined,undefined,undefined,undefined +undefined,undefined,undefined,undefined +undefined,undefined,undefined,undefined +undefined,undefined,undefined,undefined +undefined,undefined,undefined,undefined +undefined,undefined,undefined,undefined +undefined,undefined,undefined,undefined +undefined,undefined,undefined,undefined +undefined,undefined,undefined,undefined +undefined,undefined,undefined,undefined +undefined,undefined,undefined,undefined +undefined,undefined,undefined,undefined +undefined,undefined,undefined,undefined +undefined,undefined,undefined,undefined +undefined,undefined,undefined,undefined +undefined,undefined,undefined,undefined +undefined,undefined,undefined,undefined +undefined,undefined,undefined,undefined +undefined,undefined,undefined,undefined \ No newline at end of file diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..a6efed3 --- /dev/null +++ b/src/lib/prisma.ts @@ -0,0 +1,12 @@ +// src/lib/prisma.ts +import { PrismaClient } from "@prisma/client"; + +const globalForPrisma = global as unknown as { prisma: PrismaClient }; + +export const prisma = + globalForPrisma.prisma || + new PrismaClient({ + log: ["query", "error", "warn"], + }); + +if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 36eff5f..debe833 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,34 +1,60 @@ { - "compilerOptions": { - "moduleResolution": "node", // Use "node" module resolution strategy - "forceConsistentCasingInFileNames": true, - "target": "ESNext", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "ESNext", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@components/*": ["./src/components/*"], - "@hooks/*": ["./src/hooks/*"], - "@utils/*": ["./src/utils/*"], - "@appTypes/*": ["./src/types/*"], - "@zod/*": ["./src/zod/*"], - "@prismaclient": ["./src/generated/prisma/client"], - "@/*": ["./src/*"] - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} + "compilerOptions": { + "moduleResolution": "node", + "forceConsistentCasingInFileNames": true, + "target": "ESNext", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "ESNext", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "baseUrl": "src", + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@components/*": [ + "./components/*" + ], + "@hooks/*": [ + "./hooks/*" + ], + "@utils/*": [ + "./utils/*" + ], + "@appTypes/*": [ + "./types/*" + ], + "@zod/*": [ + "./zod/*" + ], + "@prismaclient": [ + "./generated/prisma/client" + ], + "@/*": [ + "./*" + ] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file