Compare commits

..

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

41 changed files with 1692 additions and 3775 deletions

249
package-lock.json generated
View File

@ -16,7 +16,6 @@
"body-parser": "^2.2.0",
"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",
@ -28,11 +27,9 @@
"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",
@ -249,59 +246,6 @@
"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",
@ -1071,15 +1015,6 @@
"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",
@ -2558,15 +2493,6 @@
"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",
@ -2857,16 +2783,6 @@
"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",
@ -5680,47 +5596,6 @@
}
}
},
"node_modules/next-auth": {
"version": "4.24.11",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz",
"integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==",
"license": "ISC",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@panva/hkdf": "^1.0.2",
"cookie": "^0.7.0",
"jose": "^4.15.5",
"oauth": "^0.9.15",
"openid-client": "^5.4.0",
"preact": "^10.6.3",
"preact-render-to-string": "^5.1.19",
"uuid": "^8.3.2"
},
"peerDependencies": {
"@auth/core": "0.34.2",
"next": "^12.2.5 || ^13 || ^14 || ^15",
"nodemailer": "^6.6.5",
"react": "^17.0.2 || ^18 || ^19",
"react-dom": "^17.0.2 || ^18 || ^19"
},
"peerDependenciesMeta": {
"@auth/core": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/next-auth/node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@ -5759,12 +5634,6 @@
"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",
@ -5897,15 +5766,6 @@
"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",
@ -5927,51 +5787,6 @@
"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",
@ -6323,28 +6138,6 @@
"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",
@ -6355,12 +6148,6 @@
"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",
@ -6537,21 +6324,6 @@
"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",
@ -7572,12 +7344,6 @@
"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",
@ -8066,15 +7832,6 @@
"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",
@ -8311,12 +8068,6 @@
"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",

View File

@ -19,7 +19,6 @@
"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",
@ -31,11 +30,9 @@
"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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 673 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -1,569 +1,386 @@
"use client";
import React, { useRef, useState, useEffect } from "react";
import { useStoreState } from "@hooks/store";
import React, { useRef, useState } from "react";
// --- Types and labels ---
type Role = "ADMIN" | "GUEST" | "SCIENTIST";
const roleLabels: Record<Role, string> = {
ADMIN: "Admin",
GUEST: "Guest",
SCIENTIST: "Scientist",
ADMIN: "Admin",
GUEST: "Guest",
SCIENTIST: "Scientist",
};
type User = {
id: number;
email: string;
name: string;
role: Role;
createdAt: string;
id: number;
email: string;
name: string;
role: Role;
password: string;
createdAt: string;
};
// todo add fulfilling of requests
// todo create api route to get users, with auth for only admin
// todo add management of only junior scientists if senior scientist
// todo (optional) add display of each user's previous orders when selecting them
const initialUsers: User[] = [
{
email: "users-loading@admin.api",
name: "Loading Users",
role: "ADMIN",
createdAt: "Check admin api and frontend",
id: 0,
},
{ 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 },
];
const sortFields = [
{ label: "Name", value: "name" },
{ label: "Email", value: "email" },
// Sort box options
{ label: "Name", value: "name" },
{ label: "Email", value: "email" },
] as const;
type SortField = (typeof sortFields)[number]["value"];
type SortDir = "asc" | "desc";
const dirLabels: Record<SortDir, string> = { asc: "ascending", desc: "descending" };
const fieldLabels: Record<SortField, string> = { name: "Name", email: "Email" };
// =========== THE PAGE =============
export default function AdminPage() {
// ---- All hooks at the top!
const user = useStoreState((state) => state.user);
const [users, setUsers] = useState<User[]>(initialUsers);
const [selectedEmail, setSelectedEmail] = useState<string | null>(null);
const [selectedEmail, setSelectedEmail] = useState<string | null>(null);
const [users, setUsers] = useState<User[]>(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<string | null>(null);
const [addLoading, setAddLoading] = useState(false);
const [editUser, setEditUser] = useState<User | null>(null);
const [searchField, setSearchField] = useState<"name" | "email">("name");
const [searchText, setSearchText] = useState("");
const [roleFilter, setRoleFilter] = useState<Role | "all">("all");
const [sortField, setSortField] = useState<SortField>("name");
const [sortDir, setSortDir] = useState<SortDir>("asc");
const [newPassword, setNewPassword] = useState<string>("");
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
const filterDropdownRef = useRef<HTMLDivElement>(null);
const sortDropdownRef = useRef<HTMLDivElement>(null);
const [showEmailTooltip, setShowEmailTooltip] = useState(false);
// Local edit state for SCIENTIST form
const [editUser, setEditUser] = useState<User | null>(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]);
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<HTMLInputElement | HTMLSelectElement>,
) => {
if (!editUser) return;
const { name, value } = e.target;
setEditUser((prev) => (prev ? { ...prev, [name]: value } : null));
};
const handlePasswordChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
if (!editUser) return;
const { name, value } = e.target;
setEditUser((prev) => (prev ? { ...prev, [name]: value } : null));
};
const selectedUser = users.find((u) => u.email === selectedEmail);
const isEditChanged = React.useMemo(() => {
if (!editUser || !selectedUser) return false;
return (
editUser.name !== selectedUser.name ||
editUser.role !== selectedUser.role ||
newPassword
);
}, [editUser, selectedUser, newPassword]);
async function updateUserOnServer(user: User, password: string) {
const body: any = {
id: user.id,
name: user.name,
role: user.role,
};
if (password.trim() !== "") {
body.password = password;
}
const res = await fetch("/api/admin", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) throw new Error("Failed to update user");
const data = await res.json();
return data.user as User;
}
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!editUser) return;
try {
const updated = await updateUserOnServer(editUser, newPassword);
setUsers((prev) =>
prev.map((u) => (u.id === updated.id ? { ...updated } : u))
);
setNewPassword("");
} catch (err) {
console.error("Failed to update user:", err);
}
};
const handleDelete = async () => {
if (!selectedUser) return;
if (
!window.confirm(
`Are you sure you want to delete "${selectedUser.name}"? This cannot be undone.`,
)
)
return;
try {
const res = await fetch("/api/admin", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: selectedUser.id }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error || "Failed to delete user");
setUsers((prev) =>
prev.filter((u) => u.email !== selectedUser.email)
);
setSelectedEmail(null);
setEditUser(null);
} catch (err: any) {
alert(err?.message || "Delete failed!");
}
};
const allRoles: Role[] = ["ADMIN", "GUEST", "SCIENTIST"];
// Search/filter/sort state
const [searchField, setSearchField] = useState<"name" | "email">("name");
const [searchText, setSearchText] = useState("");
const [roleFilter, setRoleFilter] = useState<Role | "all">("all");
const [sortField, setSortField] = useState<SortField>("name");
const [sortDir, setSortDir] = useState<SortDir>("asc");
// Dropdown states
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
const filterDropdownRef = useRef<HTMLDivElement>(null);
const sortDropdownRef = useRef<HTMLDivElement>(null);
// --- ADMIN ONLY:
if (!user || user.role !== "ADMIN") {
return (
<div className="flex items-center justify-center min-h-[70vh] flex-col">
<h1 className="text-2xl font-bold text-red-500 mb-4">
Unauthorized Access
</h1>
<div className="text-gray-600">You do not have access to this page.</div>
</div>
);
}
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);
}, []);
// --- Render admin UI
return (
<div className="flex flex-col h-full">
<div className="flex h-full overflow-hidden bg-gray-50">
{/* SIDEBAR */}
<div className="w-80 h-full border-r border-neutral-200 bg-neutral-100 flex flex-col rounded-l-xl shadow-sm">
<div className="p-4 flex flex-col h-full">
{/* Search, filter, sort controls ... (your code unchanged) */}
<div className="mb-3 flex gap-2">
<input
className="flex-1 border rounded-lg px-2 py-1 text-sm"
placeholder={`Search by ${searchField}`}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
<button
type="button"
className="border rounded-lg px-2 py-1 text-sm bg-white hover:bg-neutral-100 transition font-semibold"
style={{ width: "80px" }}
onClick={() => setSearchField((field) => (field === "name" ? "email" : "name"))}
title={`Switch to searching by ${searchField === "name" ? "Email" : "Name"}`}
>
{searchField === "name" ? "Email" : "Name"}
</button>
</div>
<div className="flex gap-2 items-center mb-2">
{/* Filter */}
<div className="relative" ref={filterDropdownRef}>
<button
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
${roleFilter !== "all"
? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700"
: "bg-white text-gray-700 border hover:bg-neutral-200"
}
// 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<HTMLInputElement | HTMLSelectElement>) => {
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 (
<div className="flex flex-col h-full">
<div className="flex h-full overflow-hidden bg-gray-50">
{/* SIDEBAR */}
<div className="w-80 h-full border-r border-neutral-200 bg-neutral-100 flex flex-col rounded-l-xl shadow-sm">
<div className="p-4 flex flex-col h-full">
{/* Search Bar */}
<div className="mb-3 flex gap-2">
<input
className="flex-1 border rounded-lg px-2 py-1 text-sm"
placeholder={`Search by ${searchField}`}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
<button
type="button"
className="border rounded-lg px-2 py-1 text-sm bg-white hover:bg-neutral-100 transition font-semibold"
style={{ width: "80px" }} // fixed width, adjust as needed
onClick={() => setSearchField((field) => (field === "name" ? "email" : "name"))}
title={`Switch to searching by ${searchField === "name" ? "Email" : "Name"}`}
>
{searchField === "name" ? "Email" : "Name"}
</button>
</div>
{/* Filter and Sort Buttons */}
<div className="flex gap-2 items-center mb-2">
{/* Filter */}
<div className="relative" ref={filterDropdownRef}>
<button
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
${
roleFilter !== "all"
? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700"
: "bg-white text-gray-700 border hover:bg-neutral-200"
}
`}
onClick={() => setFilterDropdownOpen((v) => !v)}
type="button"
>
Filter{" "}
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{filterDropdownOpen && (
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 py-1">
<button
onClick={() => {
setRoleFilter("all");
setFilterDropdownOpen(false);
}}
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
onClick={() => setFilterDropdownOpen((v) => !v)}
type="button"
>
Filter{" "}
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{filterDropdownOpen && (
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 py-1">
<button
onClick={() => {
setRoleFilter("all");
setFilterDropdownOpen(false);
}}
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
${roleFilter === "all" ? "font-bold text-blue-600" : ""}`}
>
All
</button>
{allRoles.map((role) => (
<button
key={role}
onClick={() => {
setRoleFilter(role);
setFilterDropdownOpen(false);
}}
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
>
All
</button>
{allRoles.map((role) => (
<button
key={role}
onClick={() => {
setRoleFilter(role);
setFilterDropdownOpen(false);
}}
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
${roleFilter === role ? "font-bold text-blue-600" : ""}`}
>
{roleLabels[role]}
</button>
))}
</div>
)}
</div>
{/* Sort */}
<div className="relative" ref={sortDropdownRef}>
<button
className="px-3 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
onClick={() => setSortDropdownOpen((v) => !v)}
type="button"
>
Sort{" "}
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{sortDropdownOpen && (
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 ">
{sortFields.map((opt) => (
<button
key={opt.value}
onClick={() => {
setSortField(opt.value);
setSortDropdownOpen(false);
}}
className={`w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100 last:border-0
>
{roleLabels[role]}
</button>
))}
</div>
)}
</div>
{/* Sort */}
<div className="relative" ref={sortDropdownRef}>
<button
className="px-3 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
onClick={() => setSortDropdownOpen((v) => !v)}
type="button"
>
Sort{" "}
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{sortDropdownOpen && (
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 ">
{sortFields.map((opt) => (
<button
key={opt.value}
onClick={() => {
setSortField(opt.value);
setSortDropdownOpen(false);
}}
className={`w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100 last:border-0
${sortField === opt.value ? "font-bold text-blue-600" : ""}`}
>
{opt.label}
</button>
))}
</div>
)}
</div>
{/* Asc/Desc Toggle */}
<button
className="ml-2 px-2 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}
title={sortDir === "asc" ? "Ascending" : "Descending"}
type="button"
>
{sortDir === "asc" ? "↑" : "↓"}
</button>
{/* ADD BUTTON */}
<button
className="ml-2 px-2 py-1 rounded-lg bg-green-600 hover:bg-green-700 text-white font-bold flex items-center shadow transition duration-150"
type="button"
style={{ minWidth: 36, minHeight: 36 }}
onClick={() => setAddOpen(true)}
disabled={addOpen}
title="Add user"
>
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2.2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m7-7H5" />
</svg>
</button>
</div>
<small className="text-xs text-gray-500 mb-2 px-1">
Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
</small>
{/* USERS LIST */}
<ul className="overflow-y-auto flex-1 pr-1">
{sortedUsers.map((user) => (
<li
key={user.email}
onClick={() => 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`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium truncate">{user.name}</span>
<span className="ml-1 text-xs px-2 py-0.5 rounded-lg bg-gray-200 text-gray-700">{roleLabels[user.role]}</span>
</div>
<div className="flex items-center justify-between mt-0.5">
<span className="text-xs text-gray-600 truncate">{user.email}</span>
</div>
</li>
))}
{sortedUsers.length === 0 && <li className="text-gray-400 text-center py-6">No users found.</li>}
</ul>
</div>
</div>
{/* MAIN PANEL */}
<div className="flex-1 p-24 bg-white overflow-y-auto">
{/* Add User Modal */}
{addOpen && (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-30">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-sm w-full relative">
<h3 className="text-lg font-bold mb-4">Add New User</h3>
<form onSubmit={handleAddUser} className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
className="w-full border px-2 py-1 rounded-lg"
type="email"
required
value={addForm.email}
onChange={e => setAddForm(f => ({ ...f, email: e.target.value }))}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
className="w-full border px-2 py-1 rounded-lg"
type="text"
required
value={addForm.name}
onChange={e => setAddForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Role</label>
<select
className="w-full border px-2 py-1 rounded-lg"
value={addForm.role}
onChange={e => setAddForm(f => ({ ...f, role: e.target.value as Role }))}
>
{allRoles.map(role => (
<option value={role} key={role}>{roleLabels[role]}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
className="w-full border px-2 py-1 rounded-lg"
type="text"
required
value={addForm.password}
onChange={e => setAddForm(f => ({ ...f, password: e.target.value }))}
/>
</div>
{addError && <div className="text-red-600 text-xs">{addError}</div>}
<div className="flex gap-2 justify-end pt-2">
<button
type="button"
className="px-3 py-1 rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 transition"
onClick={() => setAddOpen(false)}
disabled={addLoading}
>
Cancel
</button>
<button
type="submit"
className={`px-3 py-1 rounded-lg text-white font-semibold ${addLoading ? "bg-green-400" : "bg-green-600 hover:bg-green-700"}`}
disabled={addLoading}
>
{addLoading ? "Adding..." : "Add"}
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit User Panel */}
{editUser ? (
<div className="max-w-lg mx-auto bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-bold mb-6">Edit User</h2>
<form className="space-y-4" onSubmit={handleUpdate}>
<div className="flex items-center gap-2 mb-2">
<label className="text-sm font-medium text-gray-700">
Account Creation Time:
</label>
<span className="text-sm text-gray-500">{editUser.createdAt}</span>
</div>
<div className="flex items-center gap-2 mb-2">
<label className="text-sm font-medium text-gray-700">
Account ID Number:
</label>
<span className="text-sm text-gray-500">{editUser.id}</span>
</div>
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-1">
Email (unique):
</label>
<input
className="w-full border px-3 py-2 rounded-lg outline-none bg-gray-100 cursor-not-allowed"
type="email"
name="email"
value={editUser.email}
readOnly
onMouseEnter={() => setShowEmailTooltip(true)}
onMouseLeave={() => setShowEmailTooltip(false)}
/>
{showEmailTooltip && (
<div className="absolute left-0 top-full mt-1 z-10 w-max max-w-xs bg-gray-800 text-gray-100 text-xs px-3 py-2 rounded-md shadow-lg border border-gray-700">
This field cannot be changed. <br />
To change the email, delete and re-add the user.
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name:
</label>
<input
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
type="text"
name="name"
value={editUser.name}
onChange={handleEditChange}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Role:
</label>
<select
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
name="role"
value={editUser.role}
onChange={handleEditChange}
>
{allRoles.map((role) => (
<option key={role} value={role}>
{roleLabels[role]}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password:
</label>
<input
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
type="text"
name="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="flex gap-2 justify-end pt-6">
<button
type="button"
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white font-semibold rounded-lg shadow transition"
onClick={handleDelete}
>
Delete
</button>
<button
type="submit"
className={`px-4 py-2 rounded-lg font-semibold transition
${
isEditChanged
? "bg-blue-600 hover:bg-blue-700 text-white shadow"
: "bg-gray-300 text-gray-500 cursor-not-allowed"
}`}
disabled={!isEditChanged}
>
Update
</button>
</div>
</form>
</div>
) : (
<div className="text-center text-gray-400 mt-16 text-lg">
Select a user...
</div>
)}
</div>
</div>
</div>
);
>
{opt.label}
</button>
))}
</div>
)}
</div>
{/* Asc/Desc Toggle */}
<button
className="ml-2 px-2 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}
title={sortDir === "asc" ? "Ascending" : "Descending"}
type="button"
>
{sortDir === "asc" ? "↑" : "↓"}
</button>
</div>
{/* Sort status text */}
<small className="text-xs text-gray-500 mb-2 px-1">
Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
</small>
{/* USERS LIST: full height, scrollable */}
<ul className="overflow-y-auto flex-1 pr-1">
{sortedUsers.map((user) => (
<li
key={user.email}
onClick={() => 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`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium truncate">{user.name}</span>
<span className="ml-1 text-xs px-2 py-0.5 rounded-lg bg-gray-200 text-gray-700">{roleLabels[user.role]}</span>
</div>
<div className="flex items-center justify-between mt-0.5">
<span className="text-xs text-gray-600 truncate">{user.email}</span>
</div>
</li>
))}
{sortedUsers.length === 0 && <li className="text-gray-400 text-center py-6">No users found.</li>}
</ul>
</div>
</div>
{/* MAIN PANEL */}
<div className="flex-1 p-24 bg-white overflow-y-auto">
{editUser ? (
<div className="max-w-lg mx-auto bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-bold mb-6">Edit User</h2>
<form className="space-y-4" onSubmit={handleUpdate}>
<div className="flex items-center gap-2 mb-2">
<label className="text-sm font-medium text-gray-700">Account Creation Time:</label>
<span className="text-sm text-gray-500">{editUser.createdAt}</span>
</div>
<div className="flex items-center gap-2 mb-2">
<label className="text-sm font-medium text-gray-700">Account ID Number:</label>
<span className="text-sm text-gray-500">{editUser.id}</span>
</div>
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-1">Email (unique):</label>
<input
className="w-full border px-3 py-2 rounded-lg outline-none bg-gray-100 cursor-not-allowed"
type="email"
name="email"
value={editUser.email}
readOnly
onMouseEnter={() => setShowEmailTooltip(true)}
onMouseLeave={() => setShowEmailTooltip(false)}
/>
{/* Custom tooltip */}
{showEmailTooltip && (
<div className="absolute left-0 top-full mt-1 z-10 w-max max-w-xs bg-gray-800 text-gray-100 text-xs px-3 py-2 rounded-md shadow-lg border border-gray-700">
This field cannot be changed. <br />
To change the email, delete and re-add the user.
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name:</label>
<input
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
type="text"
name="name"
value={editUser.name}
onChange={handleEditChange}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Role:</label>
<select
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
name="role"
value={editUser.role}
onChange={handleEditChange}
>
{allRoles.map((role) => (
<option key={role} value={role}>
{roleLabels[role]}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password:</label>
<input
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
type="text"
name="password"
value={editUser.password}
onChange={handleEditChange}
/>
</div>
<div className="flex gap-2 justify-end pt-6">
<button
type="button"
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white font-semibold rounded-lg shadow transition"
onClick={handleDelete}
>
Delete
</button>
<button
type="submit"
className={`px-4 py-2 rounded-lg font-semibold transition
${
isEditChanged
? "bg-blue-600 hover:bg-blue-700 text-white shadow"
: "bg-gray-300 text-gray-500 cursor-not-allowed"
}`}
disabled={!isEditChanged}
>
Update
</button>
</div>
</form>
</div>
) : (
<div className="text-center text-gray-400 mt-16 text-lg">Select a user...</div>
)}
</div>
</div>
</div>
);
}

View File

@ -1,126 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { prisma } from "@utils/prisma";
import { env } from "@utils/env";
import { verifyJwt } from "@utils/verifyJwt";
import bcryptjs from "bcryptjs";
import { z } from "zod";
// Helper
async function getUserFromRequest() {
const cookieStore = cookies();
const token = (await cookieStore).get("jwt")?.value;
if (!token) return null;
const payload = await verifyJwt({ token, secret: env.JWT_SECRET_KEY });
if (!payload?.userId) return null;
const user = await prisma.user.findUnique({
where: { id: payload.userId as number },
select: { id: true, role: true },
});
return user;
}
export async function GET() {
try {
const user = await getUserFromRequest();
if (!user || user.role !== "ADMIN") {
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
}
const users = await prisma.user.findMany({
select: { id: true, email: true, name: true, role: true, createdAt: true },
});
const cleanedUsers = users.map(u => ({
...u,
createdAt: u.createdAt instanceof Date ? u.createdAt.toISOString() : u.createdAt,
}));
return NextResponse.json({ users: cleanedUsers }, { status: 200 });
} catch (error) {
console.error("Error fetching users:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
export async function PUT(request: Request) {
try {
const user = await getUserFromRequest();
if (!user || user.role !== "ADMIN") {
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
}
const body = await request.json();
const { id, name, role, password } = body;
const updateData: any = { name, role };
if (typeof password === "string" && password.trim() !== "") {
updateData.passwordHash = await bcryptjs.hash(password, 10);
}
const updated = await prisma.user.update({
where: { id },
data: updateData,
});
return NextResponse.json({ user: updated }, { status: 200 });
} catch (error) {
console.error("Update error:", error);
return NextResponse.json({ error: "Update failed" }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const user = await getUserFromRequest();
if (!user || user.role !== "ADMIN") {
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
}
const body = await request.json();
// Validate input (simple for demo, use zod or similar in prod)
const schema = z.object({
email: z.string().email(),
name: z.string().min(1),
role: z.enum(["ADMIN", "SCIENTIST", "GUEST"]),
password: z.string().min(6)
});
const { email, name, role, password } = schema.parse(body);
// Check uniqueness
const exists = await prisma.user.findUnique({ where: { email } });
if (exists) {
return NextResponse.json({ error: "Email already exists" }, { status: 409 });
}
const passwordHash = await bcryptjs.hash(password, 10);
const created = await prisma.user.create({
data: {
email,
name,
role,
passwordHash,
},
select: { id: true, email: true, name: true, role: true, createdAt: true },
});
return NextResponse.json({ user: { ...created, createdAt: created.createdAt instanceof Date ? created.createdAt.toISOString() : created.createdAt } }, { status: 201 });
} catch (error: any) {
console.error("Create user error:", error);
return NextResponse.json({ error: error?.message ?? "Failed to create user" }, { status: 400 });
}
}
export async function DELETE(request: Request) {
try {
const user = await getUserFromRequest();
if (!user || user.role !== "ADMIN") {
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
}
const body = await request.json();
const { id } = body;
if (typeof id !== "number" || isNaN(id)) {
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
}
await prisma.user.delete({
where: { id }
});
return NextResponse.json({ success: true }, { status: 200 });
} catch (error: any) {
console.error("Delete error:", error);
return NextResponse.json({ error: error.message || "Delete failed" }, { status: 500 });
}
}

View File

@ -1,52 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@utils/prisma";
// Generates code using only the country, and highest id in DB for numbering
async function generateEarthquakeCode(type: string, country: string) {
const typeLetter = type.trim().charAt(0).toUpperCase();
// Remove non-alphanumeric for the country part
const countrySlug = (country || "Unknown").replace(/[^\w]/gi, "");
// Use highest DB id to find the latest added earthquake's code number
const last = await prisma.earthquake.findFirst({
orderBy: { id: "desc" },
select: { code: true }
});
let num = 10000;
if (last?.code) {
const parts = last.code.split("-");
const lastNum = parseInt(parts[parts.length - 1], 10);
if (!isNaN(lastNum)) num = lastNum + 1;
}
return `E${typeLetter}-${countrySlug}-${num.toString().padStart(5, "0")}`;
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { date, magnitude, type, location, latitude, longitude, depth, country } = body;
const creatorId = 1;
if (!date || !magnitude || !type || !location || !latitude || !longitude || !depth || !country) {
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
}
if (+magnitude > 10) {
return NextResponse.json({ error: "Magnitude cannot exceed 10" }, { status: 400 });
}
const code = await generateEarthquakeCode(type, country);
const eq = await prisma.earthquake.create({
data: {
date: new Date(date),
code,
magnitude: +magnitude,
type,
location, // "city, country"
latitude: +latitude,
longitude: +longitude,
depth,
creatorId,
}
});
return NextResponse.json({ id: eq.id, code }, { status: 201 });
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 });
}
}

View File

@ -1,46 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function POST(req: NextRequest) {
try {
const { query } = await req.json();
// Nothing to search
if (!query || typeof query !== "string" || query.trim().length === 0) {
// Return recent earthquakes if no search string
const earthquakes = await prisma.earthquake.findMany({
orderBy: { date: "desc" },
take: 30,
});
return NextResponse.json({ earthquakes });
}
// Simple search: code, location, magnitude (add more fields as desired)
const q = query.trim();
const earthquakes = await prisma.earthquake.findMany({
where: {
OR: [
{ code: { contains: q, } },
{ location: { contains: q, } },
{
magnitude: Number.isNaN(Number(q))
? undefined
: Number(q),
},
// optionally add more fields
],
},
orderBy: { date: "desc" },
take: 30,
});
return NextResponse.json({ earthquakes });
} catch (e: any) {
console.error("Earthquake search error:", e);
return NextResponse.json(
{ error: "Failed to search earthquakes." },
{ status: 500 }
);
}
}

View File

@ -1,21 +0,0 @@
import { NextResponse } from "next/server";
import { prisma } from "@utils/prisma";
export async function POST(req: Request) {
try {
const { requestType, requestingUserId, scientistId, comment } = await req.json();
const request = await prisma.request.create({
data: {
requestType,
requestingUser: { connect: { id: requestingUserId } },
outcome: "IN_PROGRESS",
// Optionally you can connect to Scientist via an inline relation if you have a foreign key
// If the model has comment or details fields, add it!
},
});
return NextResponse.json({ request }, { status: 201 });
} catch (error) {
console.error("Request create error:", error);
return NextResponse.json({ error: "Failed to create request" }, { status: 500 });
}
}

View File

@ -1,80 +0,0 @@
import { NextResponse } from "next/server";
import { prisma } from "@utils/prisma";
// GET all scientists (with user, superior.user, subordinates)
export async function GET() {
try {
const scientists = await prisma.scientist.findMany({
include: {
user: true,
superior: { include: { user: true } },
subordinates: true,
},
});
return NextResponse.json({ scientists }, { status: 200 });
} catch (error) {
console.error("Error fetching scientists:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
// CREATE scientist
export async function POST(req: Request) {
try {
const { name, level, userId, superiorId } = await req.json();
const scientist = await prisma.scientist.create({
data: {
name,
level,
user: { connect: { id: userId } },
superior: superiorId ? { connect: { id: superiorId } } : undefined,
},
include: {
user: true,
superior: { include: { user: true } },
subordinates: true,
},
});
return NextResponse.json({ scientist }, { status: 201 });
} catch (error) {
console.error("Scientist create error:", error);
return NextResponse.json({ error: "Failed to create scientist" }, { status: 500 });
}
}
// UPDATE scientist
export async function PUT(req: Request) {
try {
const { id, name, level, userId, superiorId } = await req.json();
const updatedScientist = await prisma.scientist.update({
where: { id },
data: {
name,
level,
user: { connect: { id: userId } },
superior: superiorId ? { connect: { id: superiorId } } : { disconnect: true },
},
include: {
user: true,
superior: { include: { user: true } },
subordinates: true,
},
});
return NextResponse.json({ scientist: updatedScientist }, { status: 200 });
} catch (error) {
console.error("Update error:", error);
return NextResponse.json({ error: "Update failed" }, { status: 500 });
}
}
// DELETE scientist
export async function DELETE(req: Request) {
try {
const { id } = await req.json();
await prisma.scientist.delete({ where: { id } });
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("Delete error:", error);
return NextResponse.json({ error: "Delete failed" }, { status: 500 });
}
}

View File

@ -1,34 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@utils/prisma";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const {
name, location, latitude, longitude, dateEstablished,
dateClosed, isFunctional
} = body;
const creatorId = 1; // (Set per logged-in user if desired)
if (
!name || !location || latitude == null || longitude == null ||
!dateEstablished || isFunctional == null
) {
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
}
const created = await prisma.observatory.create({
data: {
name,
location,
latitude: +latitude,
longitude: +longitude,
dateEstablished: new Date(dateEstablished),
isFunctional,
seismicSensorOnline: false, // You could add this to modal if you want
creatorId
}
});
return NextResponse.json({ id: created.id, name: created.name }, { status: 201 });
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 });
}
}

View File

@ -1,16 +0,0 @@
import { NextResponse } from "next/server";
import { prisma } from "@utils/prisma";
export async function GET() {
try {
const users = await prisma.user.findMany({
include: {
scientist: true, // So you know if the user already has a scientist
}
});
return NextResponse.json({ users }, { status: 200 });
} catch (error) {
console.error("Error fetching all users:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}

View File

@ -1,150 +1,28 @@
"use client";
import Image from "next/image";
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useState } from "react";
import BottomFooter from "@components/BottomFooter";
const ContactUs = () => {
// Form/modal
const [formData, setFormData] = useState({ name: "", email: "", message: "" });
const [isModalOpen, setIsModalOpen] = useState(false);
const [formData, setFormData] = useState({
name: "",
email: "",
message: "",
});
// 1. Lava (Instagram): state & timer
const [lavaActive, setLavaActive] = useState(false);
const lavaTimeout = useRef<NodeJS.Timeout | null>(null);
// 2. Pulsating Map (Facebook): state & timer
const [pulsatingActive, setPulsatingActive] = useState(false);
const pulsatingTimeout = useRef<NodeJS.Timeout | null>(null);
// 3. Shake (LinkedIn): state & timer
const [shaking, setShaking] = useState(false);
const shakeTimeout = useRef<NodeJS.Timeout | null>(null);
// 4. Crack & Collapse (X): state & timer
const [showCracks, setShowCracks] = useState(false);
const [collapse, setCollapse] = useState(false);
const crackTimeout = useRef<NodeJS.Timeout | null>(null);
// Lava flood handler (top-down flood)
const handleInstagramClick = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setLavaActive(true);
if (lavaTimeout.current) clearTimeout(lavaTimeout.current);
lavaTimeout.current = setTimeout(() => setLavaActive(false), 2000);
}, []);
// Pulsating Map handler
const handleFacebookClick = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setPulsatingActive(true);
if (pulsatingTimeout.current) clearTimeout(pulsatingTimeout.current);
pulsatingTimeout.current = setTimeout(() => setPulsatingActive(false), 2000);
}, []);
// LinkedIn shake handler
const handleLinkedInClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
if (shaking) return;
setShaking(true);
const body = document.body;
body.classList.remove("shake-screen");
void body.offsetWidth;
body.classList.add("shake-screen");
if (shakeTimeout.current) clearTimeout(shakeTimeout.current);
shakeTimeout.current = setTimeout(() => {
setShaking(false);
body.classList.remove("shake-screen");
}, 1000);
},
[shaking]
);
// X (crack and collapse) handler
const handleXClick = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setShowCracks(true);
if (crackTimeout.current) clearTimeout(crackTimeout.current);
crackTimeout.current = setTimeout(() => {
setCollapse(true);
setTimeout(() => {
setShowCracks(false);
setCollapse(false);
}, 1500);
}, 1000);
}, []);
// Clean up timeouts and shake class
useEffect(() => {
return () => {
if (lavaTimeout.current) clearTimeout(lavaTimeout.current);
if (pulsatingTimeout.current) clearTimeout(pulsatingTimeout.current);
if (shakeTimeout.current) clearTimeout(shakeTimeout.current);
if (crackTimeout.current) clearTimeout(crackTimeout.current);
document.body.classList.remove("shake-screen");
};
}, []);
// Form handlers
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const handleChange = (e: { target: { name: any; value: any } }) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = (e: { preventDefault: () => void }) => {
e.preventDefault();
console.log("Form submitted with data:", formData);
setIsModalOpen(true);
alert("Thank you for reaching out! We will get back to you soon.");
setFormData({ name: "", email: "", message: "" });
};
return (
<div className="min-h-screen relative text-white border border-black ">
{/* Lava Flood Overlay */}
{lavaActive && (
<div className="lava-flood-overlay lava-active fixed inset-0 z-[60] flex items-center justify-center">
<img
src="/lava.jpg"
alt="Lava flood"
draggable={false}
className="w-full min-h-screen object-cover opacity-80 pointer-events-none"
/>
</div>
)}
{/* Pulsating Overlay */}
{pulsatingActive && (
<div className="pulsating-map-overlay pulsating-active fixed inset-0 z-[60] flex items-center justify-center">
<img
src="/pulsatingMap.jpg"
alt="Pulsating Map"
draggable={false}
className="w-full min-h-screen object-cover opacity-80 pointer-events-none"
/>
</div>
)}
{/* Crack & Collapse Overlay */}
{(showCracks || collapse) && (
<div
className={`crack-overlay fixed inset-0 z-[60] flex items-center justify-center${collapse ? " crack-collapse" : ""}`}
>
<img className="crack crack1 absolute w-3/4" src="/crack1.png" alt="" />
<img className="crack crack2 absolute w-3/4" src="/crack2.png" alt="" />
</div>
)}
{/* Modal: Submit Success */}
{isModalOpen && (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-sm w-full text-center">
<h2 className="text-2xl font-bold mb-4 text-neutral-800">Thank You!</h2>
<p className="text-neutral-700 mb-6">Thank you for submitting a message. We will be responding via our email.</p>
<button
className="bg-blue-600 text-white px-5 py-2 rounded-lg font-medium hover:bg-blue-700 transition"
onClick={() => setIsModalOpen(false)}
>
Close
</button>
</div>
</div>
)}
<div className="h-full relative text-white border border-black overflow-hidden">
<Image
height={5000}
width={5000}
@ -152,8 +30,9 @@ const ContactUs = () => {
className="border border-neutral-300 absolute z-10 -top-20"
src="/tsunamiWaves.jpg"
/>
{/* Overlay for readability */}
<div className="relative w-full min-h-screen bg-gradient-to-b from-black/80 via-black/40 to-black/20 flex flex-col items-center z-20">
<div className="absolute overflow-hidden w-full h-full bg-gradient-to-b from-black/80 via-black/40 to-black/20 flex flex-col items-center z-20">
{/* Container */}
<div className="max-w-4xl mx-auto p-5 mt-20">
{/* Header */}
@ -162,6 +41,8 @@ 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.
</p>
{/* Content Section */}
<div className="flex flex-col md:flex-row gap-6">
{/* Contact Form Section */}
<div className="flex-1 bg-white bg-opacity-90 text-neutral-800 rounded-lg shadow-lg p-6">
@ -181,6 +62,7 @@ const ContactUs = () => {
required
/>
</div>
<div className="mb-4">
<label htmlFor="email" className="block text-neutral-700 font-medium mb-2">
Email
@ -196,6 +78,7 @@ const ContactUs = () => {
required
/>
</div>
<div className="mb-4">
<label htmlFor="message" className="block text-neutral-700 font-medium mb-2">
Message
@ -212,6 +95,7 @@ const ContactUs = () => {
style={{ resize: "none" }}
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 transition duration-200"
@ -220,6 +104,7 @@ const ContactUs = () => {
</button>
</form>
</div>
{/* Contact Details Section */}
<div className="w-[45%] bg-white bg-opacity-90 text-neutral-800 rounded-lg shadow-lg p-6">
<h2 className="text-2xl font-bold text-neutral-800 mb-4">Get in Touch</h2>
@ -235,51 +120,23 @@ const ContactUs = () => {
</div>
<div className="mb-4">
<h3 className="text-neutral-700 font-bold font-large">Address</h3>
<p className="text-neutral-600 font-medium">1 Sweentown Row, Greenwich, London, SE3 0FQ</p>
<p className="text-neutral-600 font-medium">1 Swentown Row, Greenwich, London, SE3 0FQ</p>
</div>
<h2 className="text-xl font-bold text-neutral-800 mb-4 mt-6">Follow Us</h2>
<div className="flex justify-around items-center">
{/* Instagram: Lava Flood */}
<a
href="#"
className="w-20 h-20 text-blue-600 hover:text-blue-800 transition duration-200"
aria-label="Instagram"
onClick={handleInstagramClick}
style={{ cursor: "pointer" }}
>
<a href="#" className="w-20 h-20 text-blue-600 hover:text-blue-800 transition duration-200">
<span className="sr-only">Instagram</span>
<Image height={200} width={200} alt="Logo" className="z-10" src="/insta.webp" />
</a>
{/* Facebook: Pulsating Map */}
<a
href="#"
className="w-20 h-20 text-blue-600 hover:text-blue-800 transition duration-200"
aria-label="Facebook"
onClick={handleFacebookClick}
style={{ cursor: "pointer" }}
>
<a href="#" className="w-20 h-20 text-blue-600 hover:text-blue-800 transition duration-200">
<span className="sr-only">Facebook</span>
<Image height={200} width={200} alt="Logo" className="z-10" src="/facebook.webp" />
</a>
{/* X: Crack & Collapse */}
<a
href="#"
className="w-20 h-20 p-4 text-blue-600 hover:text-blue-800 transition duration-200"
aria-label="X"
onClick={handleXClick}
style={{ cursor: "pointer" }}
>
<a href="#" className="w-20 h-20 p-4 text-blue-600 hover:text-blue-800 transition duration-200">
<span className="sr-only">X</span>
<Image height={200} width={200} alt="Logo" className="z-10 rounded-lg" src="/x_logo.jpg" />
</a>
{/* LinkedIn: Shake */}
<a
href="#"
className="w-20 h-20 flex items-center text-blue-600 hover:text-blue-800 transition duration-200"
aria-label="LinkedIn"
onClick={handleLinkedInClick}
style={{ cursor: "pointer" }}
>
<a href="#" className="w-20 h-20 flex items-center text-blue-600 hover:text-blue-800 transition duration-200">
<span className="sr-only">LinkedIn</span>
<Image height={200} width={200} alt="Logo" className="z-10" src="/linkedIn.png" />
</a>
@ -288,7 +145,7 @@ const ContactUs = () => {
</div>
</div>
</div>
<BottomFooter />
<BottomFooter />
</div>
);
};

View File

@ -1,4 +1,5 @@
"use client";
import { useMemo, useState } from "react";
import useSWR from "swr";
import Map from "@components/Map";
@ -7,133 +8,152 @@ import { createPoster } from "@utils/axiosHelpers";
import { Earthquake } from "@prismaclient";
import { getRelativeDate } from "@utils/formatters";
import GeologicalEvent from "@appTypes/Event";
import EarthquakeSearchModal from "@components/EarthquakeSearchModal";
import EarthquakeLogModal from "@components/EarthquakeLogModal"; // If you use a separate log modal
import { useStoreState } from "@hooks/store";
import axios from "axios";
// Optional: "No Access Modal" - as in your original
function NoAccessModal({ open, onClose }) {
if (!open) return null;
return (
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-xs w-full text-center relative">
<button
onClick={onClose}
className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg"
aria-label="Close"
>
&times;
</button>
<h2 className="font-bold text-xl mb-4">Access Denied</h2>
<p className="text-gray-600 mb-3">
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
</p>
<button
onClick={onClose}
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 mt-2"
>OK</button>
</div>
</div>
);
// 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 (
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
<div className="bg-white rounded-lg shadow-lg p-6 max-w-lg w-full relative">
<button onClick={onClose} className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg">
&times;
</button>
<h2 className="font-bold text-xl mb-4">Search Earthquakes</h2>
<form onSubmit={handleSearch} className="flex gap-2 mb-4">
<input
type="text"
placeholder="e.g. Mexico, EV-7.4-Mexico-00035"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-grow px-3 py-2 border rounded"
required
/>
<button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
{loading ? "Searching..." : "Search"}
</button>
</form>
<div>
{results.length === 0 && !loading && search !== "" && <p className="text-gray-400 text-sm">No results found.</p>}
<ul>
{results.map((eq) => (
<li
key={eq.id}
className="border-b py-2 cursor-pointer hover:bg-gray-50 flex items-center justify-between"
onClick={() => {
onSelect(eq);
onClose();
}}
tabIndex={0}
>
<div>
<strong>{eq.code}</strong> <span>{eq.location}</span>{" "}
<span className="text-xs text-gray-500">{new Date(eq.date).toLocaleDateString()}</span>
</div>
<div
className={`rounded-full px-2 py-1 ml-2 text-white font-semibold ${
eq.magnitude >= 7 ? "bg-red-500" : eq.magnitude >= 6 ? "bg-orange-400" : "bg-yellow-400"
}`}
>
{eq.magnitude}
</div>
</li>
))}
</ul>
</div>
</div>
</div>
);
}
// --- MAIN PAGE COMPONENT ---
export default function Earthquakes() {
const [selectedEventId, setSelectedEventId] = useState("");
const [hoveredEventId, setHoveredEventId] = useState("");
const [searchModalOpen, setSearchModalOpen] = useState(false);
const [logModalOpen, setLogModalOpen] = useState(false);
const [noAccessModalOpen, setNoAccessModalOpen] = useState(false);
const [selectedEventId, setSelectedEventId] = useState("");
const [hoveredEventId, setHoveredEventId] = useState("");
// Your user/role logic
const user = useStoreState((state) => state.user);
const role: "GUEST" | "SCIENTIST" | "ADMIN" = user?.role ?? "GUEST";
const canLogEarthquake = role === "SCIENTIST" || role === "ADMIN";
// Search modal state
const [searchModalOpen, setSearchModalOpen] = useState(false);
// Fetch earthquakes (10 days recent)
const { data, error, isLoading, mutate } = useSWR(
"/api/earthquakes",
createPoster({ rangeDaysPrev: 10 })
);
// Fetch recent earthquakes as before
const { data, error, isLoading } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 5 }));
// 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]
);
// 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]
);
// Handler for log
const handleLogClick = () => {
if (canLogEarthquake) {
setLogModalOpen(true);
} else {
setNoAccessModalOpen(true);
}
};
// Optional: show details of selected search result (not implemented here)
// const [selectedSearchResult, setSelectedSearchResult] = useState(null);
return (
<div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden">
<div className="flex-grow h-full">
<Map
events={earthquakeEvents}
selectedEventId={selectedEventId}
setSelectedEventId={setSelectedEventId}
hoveredEventId={hoveredEventId}
setHoveredEventId={setHoveredEventId}
mapType="Earthquakes"
/>
</div>
<Sidebar
logTitle="Log an Earthquake"
logSubtitle="Record new earthquakes - time/date, location, magnitude, observatory and scientists"
recentsTitle="Recent Earthquakes"
events={earthquakeEvents}
selectedEventId={selectedEventId}
setSelectedEventId={setSelectedEventId}
hoveredEventId={hoveredEventId}
setHoveredEventId={setHoveredEventId}
button1Name="Log an Earthquake"
button2Name="Search Earthquakes"
onButton1Click={handleLogClick}
onButton2Click={() => setSearchModalOpen(true)}
button1Disabled={!canLogEarthquake}
/>
{/* ---- SEARCH MODAL ---- */}
<EarthquakeSearchModal
open={searchModalOpen}
onClose={() => setSearchModalOpen(false)}
onSelect={(eq) => setSelectedEventId(eq.code)}
/>
{/* ---- LOGGING MODAL ---- */}
<EarthquakeLogModal
open={logModalOpen}
onClose={() => setLogModalOpen(false)}
onSuccess={() => mutate()}
/>
{/* ---- NO ACCESS ---- */}
<NoAccessModal
open={noAccessModalOpen}
onClose={() => setNoAccessModalOpen(false)}
/>
</div>
);
return (
<div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden">
<div className="flex-grow h-full">
<Map
events={earthquakeEvents}
selectedEventId={selectedEventId}
setSelectedEventId={setSelectedEventId}
hoveredEventId={hoveredEventId}
setHoveredEventId={setHoveredEventId}
mapType="Earthquakes"
/>
</div>
<Sidebar
logTitle="Log an Earthquake"
logSubtitle="Record new earthquakes - time/date, location, magnitude, observatory and scientists"
recentsTitle="Recent Earthquakes"
events={earthquakeEvents}
selectedEventId={selectedEventId}
setSelectedEventId={setSelectedEventId}
hoveredEventId={hoveredEventId}
setHoveredEventId={setHoveredEventId}
button1Name="Log an Earthquake"
button2Name="Search Earthquakes"
onButton2Click={() => setSearchModalOpen(true)} // <-- important!
/>
<EarthquakeSearchModal
open={searchModalOpen}
onClose={() => setSearchModalOpen(false)}
onSelect={(eq) => {
setSelectedEventId(eq.code); // select on map/sidebar
// setSelectedSearchResult(eq); // you can use this if you want to show detail modal
}}
/>
</div>
);
}

View File

@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { prisma } from "@utils/prisma";
export async function POST(req: Request) {
try {
const { query } = await req.json();
// Find earthquakes where either code or location matches (case-insensitive)
const earthquakes = await prisma.earthquake.findMany({
where: {
OR: [
{ code: { contains: query, mode: "insensitive" } },
{ location: { contains: query, mode: "insensitive" } }
],
},
orderBy: { date: "desc" },
take: 20, // limit results
});
return NextResponse.json({ earthquakes, message: "Success" });
} catch (error) {
console.error("Error in earthquake search", error);
return NextResponse.json({ message: "Internal Server Error" }, { status: 500 });
}
}

View File

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

View File

@ -55,7 +55,7 @@ export default function LearnPage() {
<li>First aid kit and emergency medication</li>
<li>Food (non-perishable)</li>
<li>Bottled water</li>
<li>Torch</li>
<li>Torch (flashlight)</li>
<li>Satellite phone</li>
<li>Warm clothing and blankets</li>
</ul>

File diff suppressed because it is too large Load Diff

View File

@ -1,116 +1,70 @@
"use client";
import { useState, useMemo } from "react";
import { useMemo } from "react";
import { useState } 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";
function NoAccessModal({ open, onClose }) {
if (!open) return null;
return (
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-xs w-full text-center relative">
<button
onClick={onClose}
className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg"
aria-label="Close"
>&times;</button>
<h2 className="font-bold text-xl mb-4">No Access</h2>
<p className="text-gray-600 mb-3">Sorry, You do not have access rights, please log in or contact an Admin.</p>
<button
onClick={onClose}
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 mt-2"
>OK</button>
</div>
</div>
);
}
// 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
export default function Observatories() {
const [selectedEventId, setSelectedEventId] = useState("");
const [hoveredEventId, setHoveredEventId] = useState("");
const [logModalOpen, setLogModalOpen] = useState(false);
const [noAccessModalOpen, setNoAccessModalOpen] = useState(false);
const [selectedEventId, setSelectedEventId] = useState("");
const [hoveredEventId, setHoveredEventId] = useState("");
const { data, error, isLoading } = useSWR("/api/observatories", fetcher);
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
);
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 (
<div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden">
<div className="flex-grow h-full">
<Map
events={observatoryEvents}
selectedEventId={selectedEventId}
setSelectedEventId={setSelectedEventId}
hoveredEventId={hoveredEventId}
setHoveredEventId={setHoveredEventId}
mapType="observatories"
/>
</div>
<Sidebar
logTitle="Observatory Mapping"
logSubtitle="Record and search observatories - time/date set-up, location, scientists and recent earthquakes"
recentsTitle="New Observatories"
events={observatoryEvents}
selectedEventId={selectedEventId}
setSelectedEventId={setSelectedEventId}
hoveredEventId={hoveredEventId}
setHoveredEventId={setHoveredEventId}
button1Name="Log a New Observatory"
button2Name="Search Observatories"
onButton1Click={handleLogClick}
button1Disabled={!canLogObservatory}
/>
<LogObservatoryModal
open={logModalOpen}
onClose={() => setLogModalOpen(false)}
onSuccess={() => mutate()}
/>
<NoAccessModal
open={noAccessModalOpen}
onClose={() => setNoAccessModalOpen(false)}
/>
</div>
);
return (
<div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden">
<div className="flex-grow h-full">
<Map
events={observatoryEvents}
selectedEventId={selectedEventId}
setSelectedEventId={setSelectedEventId}
hoveredEventId={hoveredEventId}
setHoveredEventId={setHoveredEventId}
mapType="observatories"
/>
</div>
<Sidebar
logTitle="Observatory Mapping"
logSubtitle="Record and search observatories - time/date set-up, location, scientists and recent earthquakes"
recentsTitle="Observatory Events"
events={observatoryEvents}
selectedEventId={selectedEventId}
setSelectedEventId={setSelectedEventId}
hoveredEventId={hoveredEventId}
setHoveredEventId={setHoveredEventId}
button1Name="Log a New Observatory"
button2Name="Search Observatories"
/>
</div>
);
}

View File

@ -1,21 +1,20 @@
"use client";
import Image from "next/image";
import BottomFooter from "@components/BottomFooter";
function OurMission() {
return (
<div
className="relative bg-fixed bg-cover bg-center text-white "
className="relative h-full bg-fixed bg-cover bg-center text-white "
style={{ backgroundImage: "url('destruction.jpg')", overflow: "hidden" }}
>
{/* Overlay for Readability */}
{/*<div className="absolute inset-0 bg-black bg-opacity-50"></div>*/}
<div className="absolute inset-0 bg-black bg-opacity-50"></div>
{/* Centered content */}
<div className="relative z-20 flex flex-col items-center justify-center py-auto">
<div className="relative z-20 flex flex-col items-center justify-center h-full py-auto">
{/* Title & Mission Statement */}
<div className="mb-10 flex flex-col items-center">
<h1 className="text-4xl font-bold text-center tracking-tight mb-2 drop-shadow-lg text-black">Our Mission</h1>
<p className="text-lg text-center max-w-2xl text-gray-800 drop-shadow-md">Earthquake awareness accessible for everyone</p>
<h1 className="text-4xl font-bold text-center tracking-tight mb-2 drop-shadow-lg">Our Mission</h1>
<p className="text-lg text-center max-w-2xl text-white drop-shadow-md">Earthquake awareness accessible for everyone</p>
</div>
{/* Content Area */}
<div className="max-w-5xl w-full p-8 bg-white bg-opacity-90 shadow-xl rounded-3xl">
@ -24,15 +23,6 @@ function OurMission() {
and why earthquakes occur to enable better preparation and recovery. Education, cutting-edge research, and innovative
technology combine together.
</p>
<div className="flex justify-center mb-6">
<Image
src="/logo.png"
width={100} // Adjust as needed
height={100} // Adjust as needed
alt="Tremor Tracker Logo"
className="h-200 w-200 object-contain"
/>
</div>
<p className="text-xl text-black leading-relaxed mb-8 max-w-3xl mx-auto">
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
@ -63,7 +53,6 @@ function OurMission() {
</div>
</div>
</div>
<BottomFooter />
</div>
);
}

View File

@ -1,214 +1,241 @@
"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 (
<div className="relative" style={{ color: getMagnitudeColor(magnitude) }}>
<TbHexagon size={40} className="drop-shadow-sm" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex items-baseline font-mono font-bold tracking-tight">
<span className="text-xl -mr-1">{whole}</span>
<span className="text-xs ml-[1.5px] -mr-[2.5px]">.</span>
<span className="text-xs -mr-[1px]">{decimal}</span>
</div>
</div>
</div>
);
}
export default function Home() {
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 (
<main className="min-h-screen text-black">
<div className="w-full relative">
<div className="">
<Image height={2000} width={2000} alt="Background Image" src="/lava_flows.jpg"></Image>
</div>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-black/10 to-black/40"></div>
<div className="absolute inset-0 top-[30%]">
<Image className="mx-auto" height={300} width={1000} alt="Title Image" src="/tremortrackertext.png"></Image>
</div>
</div>
<p className="mt-2"></p>
<div className="flex flex-col md:flex-row md:justify-evenly gap-6 mt-2">
<Link href="/earthquakes" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
<Image height={100} width={100} src="/earthquake.png" alt="Education Icon" className="h-40 w-40 mb-4" />
<h3 className="text-xl font-bold text-black mb-4">Earthquakes</h3>
<p className="text-md text-black text-center max-w-xs opacity-90">
Log new earthquakes with their required details or search past seismic events
</p>
</Link>
<Link
href="/observatories"
className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300"
>
<Image height={100} width={100} src="/observatory.jpg" alt="Research Icon" className="h-40 w-40 mb-4" />
<h3 className="text-xl font-bold text-black mb-4">Observatories</h3>
<p className="text-md text-black text-center max-w-xs opacity-90">
Find recently active observatories, and newly opened/closed sites
</p>
</Link>
<Link href="/shop" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
<Image height={100} width={100} src="/artifactIcon.jpg" alt="Technology Icon" className="h-40 w-40 mb-4" />
<h3 className="text-xl font-bold text-black mb-4">Artefacts</h3>
<p className="text-md text-black text-center max-w-xs opacity-90">
View or purchase recently discovered artefacts from seismic events
</p>
</Link>
</div>
<p className="mt-18"></p>
<section className="min-h-screen text-black">
<div className="w-full relative z-40">
<div className="">
<Image height={2500} width={2000} alt="Background Image" src="/earthquakesMap.jpg"></Image>
</div>
<div className="border absolute top-0 inset-0 bg-gradient-to-b from-transparent via-black/10 to-black/40">
<section className="relative z-10 flex flex-col items-center text-center w-full px-4 py-14 mt-6">
<h1 className="text-4xl md:text-5xl font-sans font-bold text-white drop-shadow-lg mb-4 tracking-tight z-10">
Welcome to Tremor Tracker
</h1>
<p className="text-lg md:text-xl font-sans text-white w-4/6 mx-auto drop-shadow-md z-10">
TremorTracker is a non-profit website and research company, that aims to provide true, reliable data. Our mission
is seismic education and preparation for all
</p>
<p className="mt-20"></p>
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">What is an earthquake?</p>
<p className="text-lg md:text-xl text-white w-4/6 mx-auto drop-shadow-md z-10">
Earthquakes are a shaking of the earth's surface caused by a sudden release of energy underground. They can range
in size, from tiny trembles to large quakes, which can cause destruction and even tsunamis. Hundreds of
earthquakes happen every daybut most are too small to feel.
</p>
<p className="mt-20"></p>
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">
How do we log earthquakes?
</p>
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">
What information are we interested in?
</p>
<p className="text-lg md:text-xl text-white w-4/6 mx-auto drop-shadow-md z-10">info</p>
<p className="mt-20"></p>
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">
What are observatories?
</p>
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">What is their role?</p>
<p className="text-lg md:text-xl text-white w-4/6 mx-auto drop-shadow-md z-10">info</p>
</section>
</div>
</div>
</section>
<p className="mt-20"></p>
<section className="relative z-10 flex flex-col items-start text-left w-5/6 mx-auto px-2 -mt-5 mb-2">
<h1 className="text-3xl md:text-3xl font-bold text-black drop-shadow-lg mb-3 tracking-tight">Recent Earthquake Events</h1>
<p className="text-lg md:text-xl text-black drop-shadow-md">
Learn about the most recent earthquake events from around the world:
</p>
</section>
<p className="mt-6"></p>
<div className="mx-auto w-5/6 px-2 border border-black divide-y bg-white bg-opacity-90 rounded-xl shadow-md">
{["Earthquake 1", "Earthquake 2", "Earthquake 3", "Earthquake 4", "Earthquake 5"].map((name) => (
<div className="px-5 py-5" key={name}>
<p className="ml-3">{name}</p>
<p></p>
</div>
))}
</div>
<p className="mt-20"></p>
<section className="relative z-10 flex flex-col items-start text-left w-5/6 mx-auto px-2 -mt-5 mb-2">
<h1 className="text-3xl md:text-3xl font-bold text-black drop-shadow-lg mb-3 tracking-tight">Contact Information</h1>
<p className="text-lg md:text-xl text-black drop-shadow-md">
Learn about Tremor Tracker's mission, our team or contact us directly:
</p>
</section>
<p className="mt-2"></p>
<div className="flex flex-col md:flex-row md:justify-evenly gap-6 mt-2">
<Link href="/contact-us" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
<Image height={100} width={100} src="/contactUs.jpg" alt="Education Icon" className="h-20 w-20 mb-4" />
<h3 className="text-xl font-bold text-black mb-4">Contact us directly</h3>
<p className="text-md text-black text-center max-w-xs opacity-90">
Visit our socials or leave us a message via phone or email.
</p>
</Link>
<Link href="/our-mission" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
<Image height={100} width={100} src="/mission.jpg" alt="Research Icon" className="h-20 w-20 mb-4" />
<h3 className="text-xl font-bold text-black mb-4">Our Mission</h3>
<p className="text-md text-black text-center max-w-xs opacity-90">
Find out more about our purpose and the features we offer.
</p>
</Link>
<Link href="/the-team" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
<Image height={100} width={100} src="/team.jpg" alt="Technology Icon" className="h-20 w-20 mb-4" />
<h3 className="text-xl font-bold text-black mb-4">Meet the Team</h3>
<p className="text-md text-black text-center max-w-xs opacity-90">
Learn about our team leads and their responsibilities.
</p>
</Link>
</div>
<p className="mt-10"></p>
<section style={{ height: 500 }} className="text-black">
<div className="w-full relative overflow-hidden z=10">
<div className="">
<Image height={1000} width={2000} alt="Background Image" src="/scientists.png"></Image>
</div>
<BottomFooter />
</div>
</section>
</main>
);
return (
<main className="min-h-screen text-black">
<div className="w-full relative">
<div>
<Image height={2000} width={2000} alt="Background Image" src="/lava_flows.jpg" />
</div>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-black/10 to-black/40"></div>
<div className="absolute inset-0 top-[30%]">
<Image className="mx-auto" height={300} width={1000} alt="Title Image" src="/tremortrackertext.png" />
</div>
</div>
<p className="mt-2"></p>
<div className="flex flex-col md:flex-row md:justify-evenly gap-6 mt-2">
<Link href="/earthquakes" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
<Image height={100} width={100} src="/earthquake.png" alt="Education Icon" className="h-40 w-40 mb-4" />
<h3 className="text-xl font-bold text-black mb-4">Earthquakes</h3>
<p className="text-md text-black text-center max-w-xs opacity-90">
Log new earthquakes with their required details or search past seismic events
</p>
</Link>
<Link
href="/observatories"
className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300"
>
<Image height={100} width={100} src="/observe.png" alt="Research Icon" className="h-40 w-40 mb-4" />
<h3 className="text-xl font-bold text-black mb-4">Observatories</h3>
<p className="text-md text-black text-center max-w-xs opacity-90">
Find recently active observatories, and newly opened/closed sites
</p>
</Link>
<Link href="/shop" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
<Image height={100} width={100} src="/artefact.png" alt="Technology Icon" className="h-40 w-40 mb-4" />
<h3 className="text-xl font-bold text-black mb-4">Artefacts</h3>
<p className="text-md text-black text-center max-w-xs opacity-90">
View or purchase recently discovered artefacts from seismic events
</p>
</Link>
</div>
<p className="mt-18"></p>
<section className="min-h-screen text-black">
<div className="w-full relative z-40">
<div>
<Image height={2500} width={2000} alt="Background Image" src="/earthquakesMap.jpg" />
</div>
<div className="border absolute top-0 inset-0 bg-gradient-to-b from-transparent via-black/10 to-black/40">
<section className="relative z-10 flex flex-col items-center text-center w-full px-4 py-14 mt-6">
<h1 className="text-4xl md:text-5xl font-sans font-bold text-white drop-shadow-lg mb-4 tracking-tight z-10">
Welcome to Tremor Tracker
</h1>
<p className="text-lg md:text-xl font-sans text-white w-4/6 mx-auto drop-shadow-md z-10">
TremorTracker is a non-profit website and research company, that aims to provide true, reliable data. Our mission
is seismic education and preparation for all
</p>
<p className="mt-20"></p>
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">What is an earthquake?</p>
<p className="text-lg md:text-xl text-white w-4/6 mx-auto drop-shadow-md z-10">
Earthquakes are a shaking of the earth's surface caused by a sudden release of energy underground. They can range
in size, from tiny trembles to large quakes, which can cause destruction and even tsunamis. Hundreds of
earthquakes happen every daybut most are too small to feel.
</p>
<p className="mt-20"></p>
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">
How do we log earthquakes?
</p>
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">
What information are we interested in?
</p>
<p className="text-lg md:text-xl text-white w-4/6 mx-auto drop-shadow-md z-10">info</p>
<p className="mt-20"></p>
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">
What are observatories?
</p>
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">What is their role?</p>
<p className="text-lg md:text-xl text-white w-4/6 mx-auto drop-shadow-md z-10">info</p>
</section>
</div>
</div>
</section>
<p className="mt-20"></p>
<section className="relative z-10 flex flex-col items-start text-left w-5/6 mx-auto px-2 -mt-5 mb-2">
<h1 className="text-3xl md:text-3xl font-bold text-black drop-shadow-lg mb-3 tracking-tight">
Recent Earthquake Events
</h1>
<p className="text-lg md:text-xl text-black drop-shadow-md">
Learn about the most recent earthquake events from around the world:
</p>
</section>
<p className="mt-6"></p>
<div className="mx-auto w-5/6 px-2">
{error && (
<div className="border rounded-xl bg-white bg-opacity-90 shadow-md p-4 mb-2">
<p>Failed to load earthquakes.</p>
</div>
)}
{isLoading && (
<div className="border rounded-xl bg-white bg-opacity-90 shadow-md p-4 mb-2">
<p>Loading...</p>
</div>
)}
{!isLoading && recents.length === 0 && (
<div className="border rounded-xl bg-white bg-opacity-90 shadow-md p-4 mb-2">
<p>No earthquakes found.</p>
</div>
)}
<div className="flex flex-col gap-4">
{recents.map((eq) => (
<div
key={eq.code}
className="flex items-center justify-between p-4 bg-white rounded-xl shadow border"
>
<div>
<div className="font-semibold">
Earthquake in {eq.location || (eq.code && eq.code.split("-")[2])}
</div>
<div className="text-sm text-gray-500">{getRelativeDate(eq.date)}</div>
</div>
<MagnitudeNumber magnitude={eq.magnitude} />
</div>
))}
</div>
</div>
<p className="mt-20"></p>
<section className="relative z-10 flex flex-col items-start text-left w-5/6 mx-auto px-2 -mt-5 mb-2">
<h1 className="text-3xl md:text-3xl font-bold text-black drop-shadow-lg mb-3 tracking-tight">
Find Out More!
</h1>
<p className="text-lg md:text-xl text-black drop-shadow-md">
Explore more of our website...
</p>
</section>
<p className="mt-2"></p>
<div className="flex flex-col md:flex-row md:justify-evenly gap-6 mt-2">
<Link href="/contact-us" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
<Image height={100} width={100} src="/contactUs.jpg" alt="Education Icon" className="h-20 w-20 mb-4" />
<h3 className="text-xl font-bold text-black mb-4">Contact us directly</h3>
<p className="text-md text-black text-center max-w-xs opacity-90">
Visit our socials or leave us a message via phone or email.
</p>
</Link>
<Link href="/our-mission" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
<Image height={100} width={100} src="/mission.jpg" alt="Research Icon" className="h-20 w-20 mb-4" />
<h3 className="text-xl font-bold text-black mb-4">Our Mission</h3>
<p className="text-md text-black text-center max-w-xs opacity-90">
Find out more about our purpose and the features we offer.
</p>
</Link>
<Link href="/the-team" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
<Image height={100} width={100} src="/team.jpg" alt="Technology Icon" className="h-20 w-20 mb-4" />
<h3 className="text-xl font-bold text-black mb-4">Meet the Team</h3>
<p className="text-md text-black text-center max-w-xs opacity-90">
Learn about our team leads and their responsibilities.
</p>
</Link>
</div>
<p className="mt-10"></p>
<section style={{ height: 500 }} className="text-black">
<div className="w-full relative overflow-hidden z=10">
<div className="flex justify-center">
<Image height={400} width={800} alt="Background Image" src="/team.PNG" />
</div>
<BottomFooter />
</div>
</section>
</main>
);
// return (
// <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
// <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
// <Image
// className="dark:invert"
// src="/next.svg"
// alt="Next.js logo"
// width={180}
// height={38}
// priority
// />
// <ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
// <li className="mb-2">
// Get started by editing{" "}
// <code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
// src/app/page.tsx
// </code>
// .
// </li>
// <li>Save and see your changes instantly.</li>
// </ol>
// <div className="flex gap-4 items-center flex-col sm:flex-row">
// <a
// className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
// href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
// target="_blank"
// rel="noopener noreferrer"
// >
// <Image
// className="dark:invert"
// src="/vercel.svg"
// alt="Vercel logomark"
// width={20}
// height={20}
// />
// Deploy now
// </a>
// <a
// className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
// href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
// target="_blank"
// rel="noopener noreferrer"
// >
// Read our docs
// </a>
// </div>
// </main>
// <footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
// <a
// className="flex items-center gap-2 hover:underline hover:underline-offset-4"
// href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
// target="_blank"
// rel="noopener noreferrer"
// >
// <Image
// aria-hidden
// src="/file.svg"
// alt="File icon"
// width={16}
// height={16}
// />
// Learn
// </a>
// <a
// className="flex items-center gap-2 hover:underline hover:underline-offset-4"
// href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
// target="_blank"
// rel="noopener noreferrer"
// >
// <Image
// aria-hidden
// src="/window.svg"
// alt="Window icon"
// width={16}
// height={16}
// />
// Examples
// </a>
// <a
// className="flex items-center gap-2 hover:underline hover:underline-offset-4"
// href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
// target="_blank"
// rel="noopener noreferrer"
// >
// <Image
// aria-hidden
// src="/globe.svg"
// alt="Globe icon"
// width={16}
// height={16}
// />
// Go to nextjs.org →
// </a>
// </footer>
// </div>
// );
}

View File

@ -1,41 +1,43 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from "react";
import { ExtendedArtefact } from "@appTypes/ApiTypes";
import { 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<ExtendedArtefact[]>([]);
const [hiddenArtefactIds, setHiddenArtefactIds] = useState<number[]>([]);
const [loading, setLoading] = useState(true);
const [cart, setCart] = useState<ExtendedArtefact[]>([]);
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,
location: a.warehouseArea, // your database
earthquakeID: a.earthquakeId?.toString() ?? "",
observatory: a.type ?? "",
observatory: a.type ?? "", // if you want to display type
dateReleased: a.createdAt ? new Date(a.createdAt).toLocaleDateString() : "",
image: "/artefactImages/" + (a.imageName || "NoImageFound.PNG"),
price: a.shopPrice ?? 100,
price: a.shopPrice ?? 100, // fallback price if not in DB
}));
setArtefacts(transformed);
} catch (e) {
// Optionally handle error
console.error("Failed to fetch artefacts", e);
} finally {
setLoading(false);
@ -45,10 +47,9 @@ export default function Shop() {
}, []);
const [currentPage, setCurrentPage] = useState(1);
const [selectedArtefact, setSelectedArtefact] = useState<ExtendedArtefact | null>(null);
const [selectedArtefact, setSelectedArtefact] = useState<Artefact | null>(null);
const [showPaymentModal, setShowPaymentModal] = useState(false);
const [artefactToBuy, setArtefactToBuy] = useState<ExtendedArtefact | null>(null);
const [cartCheckout, setCartCheckout] = useState(false); // true = checkout cart (not single artefact)
const [artefactToBuy, setArtefactToBuy] = useState<Artefact | null>(null);
const [showThankYouModal, setShowThankYouModal] = useState(false);
const [orderNumber, setOrderNumber] = useState<string | null>(null);
@ -92,14 +93,11 @@ export default function Shop() {
</div>
);
}
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 (
<div
className="fixed inset-0 bg-neutral-900 bg-opacity-50 flex justify-center items-center z-50"
@ -123,32 +121,16 @@ export default function Shop() {
<p className="text-neutral-500 mb-2">{artefact.earthquakeID}</p>
<p className="text-neutral-500 mb-2">{artefact.observatory}</p>
<p className="text-neutral-500 mb-2">{artefact.dateReleased}</p>
<div className="flex flex-col sm:flex-row justify-end gap-4 mt-4 mr-2">
<div className="flex justify-end gap-4 mt-4 mr-2">
<button
onClick={() => {
if (!inCart) setCart((cart) => [...cart, artefact]);
setArtefactToBuy(artefact); // Set artefact for payment modal
setShowPaymentModal(true); // Show payment modal
setSelectedArtefact(null); // Close this modal
}}
disabled={inCart}
className={`px-6 py-2 rounded-md font-bold border
${
inCart
? "bg-gray-300 text-gray-400 cursor-not-allowed"
: "bg-green-500 hover:bg-green-600 text-white"
}
`}
className="px-10 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
{inCart ? "In Cart" : "Add to Cart"}
</button>
<button
onClick={() => {
setArtefactToBuy(artefact);
setShowPaymentModal(true);
setCartCheckout(false);
setSelectedArtefact(null);
}}
className="px-8 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Buy Now
Buy
</button>
</div>
</div>
@ -156,99 +138,15 @@ export default function Shop() {
);
}
function CartModal() {
const total = cart.reduce((sum, art) => sum + art.price, 0);
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) setShowCartModal(false);
};
return (
<div
className="fixed inset-0 bg-neutral-900 bg-opacity-60 flex justify-center items-center z-[999]"
onClick={handleOverlayClick}
>
<div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-8">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold">Your Cart</h2>
<button onClick={() => setShowCartModal(false)} className="text-xl font-bold px-2 py-1 rounded">
</button>
</div>
{cart.length === 0 ? (
<p className="text-neutral-500">Your cart is empty.</p>
) : (
<>
<ul className="mb-4">
{cart.map((art) => (
<li key={art.id} className="flex items-center border-b py-2">
<div className="flex-shrink-0 mr-3">
<Image src={art.image} alt={art.name} width={60} height={40} className="rounded" />
</div>
<div className="flex-grow">
<p className="font-bold">{art.name}</p>
<p className="text-neutral-500 text-sm">{art.location}</p>
</div>
<p className="font-bold mr-2">
{currencyTickers[selectedCurrency]}
{convertPrice(art.price, selectedCurrency)}
</p>
<button
className="px-3 py-1 bg-red-400 hover:bg-red-500 text-white rounded"
onClick={() => setCart((c) => c.filter((a) => a.id !== art.id))}
>
Remove
</button>
</li>
))}
</ul>
<div className="flex justify-between items-center mb-2">
<span className="font-bold">Total:</span>
<span className="text-lg font-bold">
{currencyTickers[selectedCurrency]}
{convertPrice(total, selectedCurrency)}
</span>
</div>
<div className="text-right">
<button
className="px-8 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
onClick={() => {
setShowCartModal(false);
setArtefactToBuy(null);
setShowPaymentModal(true);
setCartCheckout(true);
}}
>
Checkout
</button>
</div>
</>
)}
</div>
</div>
);
}
function PaymentModal({
artefact,
onClose,
cartItems,
}: {
artefact?: ExtendedArtefact;
onClose: () => void;
cartItems?: ExtendedArtefact[];
}) {
function PaymentModal({ artefact, onClose }: { artefact: Artefact; onClose: () => void }) {
const [cardNumber, setCardNumber] = useState("");
const [expiry, setExpiry] = useState("");
const [cvc, setCvc] = useState("");
const [name, setName] = useState("");
const [email, setEmail] = useState(user?.email || "");
const [email, setEmail] = useState("");
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("@") &&
@ -264,13 +162,18 @@ export default function Shop() {
function validateExpiry(exp: string) {
return /^\d{2}\/\d{2}$/.test(exp);
}
function handlePay() {
setError("");
const paymentEmail = user?.email || email;
if (!validateEmail(paymentEmail)) {
setError("Please enter a valid email");
if (email || user?.email) {
if (!validateEmail(email)) {
setError("Please enter a valid email ending");
return;
}
} else {
return;
}
if (!validateCardNumber(cardNumber)) {
setError("Card number must be 12-19 digits.");
return;
@ -283,51 +186,46 @@ export default function Shop() {
setError("CVC must be 3 or 4 digits.");
return;
}
// remove all artefacts that were bought (works for both cart and single)
setHiddenArtefactIds((ids) => [...ids, ...artefactsToBuy.map((a) => a.id)]);
setHiddenArtefactIds((ids) => [...ids, artefact.id]);
// todo create receiving api route
// todo 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 (
<div
className="fixed inset-0 bg-neutral-900 bg-opacity-60 flex justify-center items-center z-[12000]"
className="fixed inset-0 bg-neutral-900 bg-opacity-60 flex justify-center items-center z-10"
onClick={handleOverlayClick}
>
<div className="bg-white rounded-xl shadow-2xl max-w-md w-full p-6">
<h2 className="text-2xl font-bold mb-4">
Checkout {artefact ? artefact.name : artefactsToBuy.length + " item(s)"}
{!artefact && <span className="ml-1">({artefactsToBuy.map((x) => x.name).join(", ")})</span>}
</h2>
<h2 className="text-2xl font-bold mb-4">Buy {artefact.name}</h2>
{/* ...Image... */}
<form
onSubmit={(e) => {
e.preventDefault();
handlePay();
}}
>
{/* Email autofill */}
<input
className="w-full mb-2 px-3 py-2 border rounded"
placeholder="Email Address"
value={user?.email ? user.email : email}
onChange={(e) => setEmail(e.target.value)}
type="email"
required
autoFocus
disabled={!!user?.email}
/>
{user?.email && (
<p className="text-sm text-gray-500 mb-2">
Signed in as <span className="font-bold">{user.email}</span>
</p>
)}
{!user ? (
<input
className="w-full mb-2 px-3 py-2 border rounded"
placeholder="Email Address"
value={email}
onChange={(e) => setEmail(e.target.value)}
type="email"
required
autoFocus
/>
) : null}
<input
className="w-full mb-2 px-3 py-2 border rounded"
placeholder="Cardholder Name"
@ -370,13 +268,6 @@ export default function Shop() {
Remember me
</label>
{error && <p className="text-red-600 mb-2">{error}</p>}
<div className="flex justify-between items-center mb-2">
<span className="font-bold">Total:</span>
<span className="text-lg font-bold">
{currencyTickers[selectedCurrency]}
{convertPrice(total, selectedCurrency)}
</span>
</div>
<div className="flex justify-end gap-2 mt-2">
<button type="button" onClick={onClose} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md mr-2">
Cancel
@ -421,28 +312,6 @@ export default function Shop() {
}}
>
<div className="absolute inset-0 bg-black bg-opacity-0 z-0"></div>
{/* --- Cart Button fixed at top right --- */}
<button
className="absolute top-6 right-6 z-[11000] bg-white border border-blue-500 shadow-lg rounded-full p-3 hover:bg-blue-100 flex flex-row items-center"
onClick={() => setShowCartModal(true)}
aria-label="Open your cart"
>
<span className="mr-2 font-bold">{cart.length || ""}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-7 h-7 text-blue-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13l-1.35 2.7a1 1 0 00.9 1.45h12.2M7 13l1.2-2.4M3 3l.01 0"
/>
</svg>
</button>
<div className="relative z-10 flex flex-col items-center w-full px-2 py-12">
<h1 className="text-4xl md:text-4xl font-bold text-center text-blue-300 mb-2 tracking-tight drop-shadow-lg">
Artefact Shop
@ -481,22 +350,19 @@ export default function Shop() {
</footer>
</div>
{selectedArtefact && <Modal artefact={selectedArtefact} />}
{showCartModal && <CartModal />}
{showPaymentModal && (cartCheckout || artefactToBuy) && (
{artefactToBuy && showPaymentModal && (
<PaymentModal
artefact={cartCheckout ? undefined : artefactToBuy!}
cartItems={cartCheckout ? cart : undefined}
artefact={artefactToBuy}
onClose={() => {
setShowPaymentModal(false);
setArtefactToBuy(null);
setCartCheckout(false);
}}
/>
)}
{showThankYouModal && orderNumber && (
<ThankYouModal orderNumber={orderNumber} onClose={() => setShowThankYouModal(false)} />
)}
{!selectedArtefact && !showPaymentModal && !showThankYouModal && !showCartModal && (
{!selectedArtefact && !showPaymentModal && !showThankYouModal && (
<div className="relative z-50">
<BottomFooter />
</div>

View File

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

View File

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

View File

@ -1,248 +0,0 @@
"use client";
import { useState } from "react";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
const typeOptions = [
{ value: "volcanic", label: "Volcanic" },
{ value: "tectonic", label: "Tectonic" },
{ value: "collapse", label: "Collapse" },
{ value: "explosion", label: "Explosion" }
];
export default function EarthquakeLogModal({ open, onClose, onSuccess }) {
const [date, setDate] = useState<Date | null>(new Date());
const [magnitude, setMagnitude] = useState("");
const [type, setType] = useState(typeOptions[0].value);
const [city, setCity] = useState("");
const [country, setCountry] = useState("");
const [latitude, setLatitude] = useState("");
const [longitude, setLongitude] = useState("");
const [depth, setDepth] = useState("");
const [loading, setLoading] = useState(false);
const [successCode, setSuccessCode] = useState<string | null>(null);
async function handleLatLonChange(lat: string, lon: string) {
setLatitude(lat);
setLongitude(lon);
if (/^-?\d+(\.\d+)?$/.test(lat) && /^-?\d+(\.\d+)?$/.test(lon)) {
try {
const resp = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10`
);
if (resp.ok) {
const data = await resp.json();
setCity(
data.address.city ||
data.address.town ||
data.address.village ||
data.address.hamlet ||
data.address.county ||
data.address.state ||
""
);
setCountry(data.address.country || "");
}
} catch (e) {
// ignore
}
}
}
async function handleSubmit(e) {
e.preventDefault();
setLoading(true);
if (!date || !magnitude || !type || !city || !country || !latitude || !longitude || !depth) {
alert("Please complete all fields.");
setLoading(false);
return;
}
try {
const res = await fetch("/api/earthquakes/log", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
date,
magnitude: parseFloat(magnitude),
type,
location: `${city.trim()}, ${country.trim()}`,
country: country.trim(),
latitude: parseFloat(latitude),
longitude: parseFloat(longitude),
depth
})
});
if (res.ok) {
const result = await res.json();
setSuccessCode(result.code);
setLoading(false);
if (onSuccess) onSuccess();
} else {
const err = await res.json();
alert("Failed to log earthquake! " + (err.error || ""));
setLoading(false);
}
} catch (e: any) {
alert("Failed to log. " + e.message);
setLoading(false);
}
}
if (!open) return null;
// Success popup overlay
if (successCode) {
return (
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
<div className="bg-white rounded-lg px-8 py-8 max-w-md w-full relative shadow-lg">
<button
onClick={() => {
setSuccessCode(null);
onClose();
}}
className="absolute right-4 top-4 text-xl text-gray-400 hover:text-gray-700"
aria-label="Close"
>
&times;
</button>
<div className="text-center">
<h2 className="text-xl font-semibold mb-3">
Thank you for logging an earthquake!
</h2>
<div className="mb-0">The Earthquake Identifier is</div>
<div className="font-mono text-lg mb-4 bg-slate-100 rounded px-2 py-1 inline-block text-blue-800">{successCode}</div>
</div>
</div>
</div>
);
}
return (
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
<div className="bg-white rounded-lg shadow-lg p-6 max-w-lg w-full relative">
<button
onClick={onClose}
className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg"
>
&times;
</button>
<h2 className="font-bold text-xl mb-4">Log Earthquake</h2>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="block text-sm font-medium">Date</label>
<DatePicker
selected={date}
onChange={date => setDate(date)}
className="border rounded px-3 py-2 w-full"
dateFormat="yyyy-MM-dd"
maxDate={new Date()}
showMonthDropdown
showYearDropdown
dropdownMode="select"
required
/>
</div>
<div>
<label className="block text-sm font-medium">Magnitude</label>
<input
type="number"
className="border rounded px-3 py-2 w-full"
min="0"
max="10"
step="0.1"
value={magnitude}
onChange={e => {
const val = e.target.value;
if (parseFloat(val) > 10) return;
setMagnitude(val);
}}
required
/>
</div>
<div>
<label className="block text-sm font-medium">Type</label>
<select
className="border rounded px-3 py-2 w-full"
value={type}
onChange={e => setType(e.target.value)}
required
>
{typeOptions.map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium">City/Area</label>
<span className="block text-xs text-gray-400">
(Use Lat/Lon then press Enter for reverse lookup)
</span>
<input
type="text"
className="border rounded px-3 py-2 w-full"
value={city}
onChange={e => setCity(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium">Country</label>
<input
type="text"
className="border rounded px-3 py-2 w-full"
value={country}
onChange={e => setCountry(e.target.value)}
required
/>
</div>
<div className="flex space-x-2">
<div className="flex-1">
<label className="block text-sm font-medium">Latitude</label>
<input
type="number"
className="border rounded px-3 py-2 w-full"
value={latitude}
onChange={e => handleLatLonChange(e.target.value, longitude)}
placeholder="e.g. 36.12"
step="any"
required
/>
</div>
<div className="flex-1">
<label className="block text-sm font-medium">Longitude</label>
<input
type="number"
className="border rounded px-3 py-2 w-full"
value={longitude}
onChange={e => handleLatLonChange(latitude, e.target.value)}
placeholder="e.g. -115.17"
step="any"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium">Depth</label>
<input
type="text"
className="border rounded px-3 py-2 w-full"
value={depth}
onChange={e => setDepth(e.target.value)}
placeholder="e.g. 10 km"
required
/>
</div>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 w-full"
disabled={loading}
>
{loading ? "Logging..." : "Log Earthquake"}
</button>
</form>
</div>
</div>
);
}

View File

@ -1,245 +0,0 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import axios from "axios";
export type Earthquake = {
id: string;
code: string;
magnitude: number;
location: string;
date: string;
longitude: number;
latitude: number;
};
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString();
}
const COLUMNS = [
{ label: "Code", key: "code", className: "font-mono font-bold" },
{ label: "Location", key: "location" },
{ label: "Magnitude", key: "magnitude", numeric: true },
{ label: "Date", key: "date" },
];
export default function EarthquakeSearchModal({
open,
onClose,
onSelect,
}: {
open: boolean;
onClose: () => void;
onSelect: (eq: Earthquake) => void;
}) {
const [search, setSearch] = useState<string>("");
const [results, setResults] = useState<Earthquake[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>("");
// Filters per column
const [filters, setFilters] = useState<{ [k: string]: string }>({
code: "",
location: "",
magnitude: "",
date: "",
});
// Sort state
const [sort, setSort] = useState<{ key: keyof Earthquake; dir: "asc" | "desc" } | null>(null);
useEffect(() => {
if (!open) {
setSearch("");
setResults([]);
setFilters({ code: "", location: "", magnitude: "", date: "" });
setError("");
setSort(null);
}
}, [open]);
const doSearch = async (q = search) => {
setLoading(true);
setResults([]);
setError("");
try {
const resp = await axios.post("/api/earthquakes/search", { query: q });
setResults(resp.data.earthquakes || []);
} catch (e: any) {
setError("Failed to search earthquakes.");
}
setLoading(false);
};
// Filter logic
const filteredRows = useMemo(() => {
return results.filter((row) =>
(!filters.code ||
row.code.toLowerCase().includes(filters.code.toLowerCase())) &&
(!filters.location ||
(row.location || "").toLowerCase().includes(filters.location.toLowerCase())) &&
(!filters.magnitude ||
String(row.magnitude).startsWith(filters.magnitude)) &&
(!filters.date ||
row.date.slice(0, 10) === filters.date)
);
}, [results, filters]);
// Sort logic
const sortedRows = useMemo(() => {
if (!sort) return filteredRows;
const sorted = [...filteredRows].sort((a, b) => {
let valA = a[sort.key];
let valB = b[sort.key];
if (sort.key === "magnitude") {
valA = Number(valA);
valB = Number(valB);
} else if (sort.key === "date") {
valA = a.date;
valB = b.date;
} else {
valA = String(valA || "");
valB = String(valB || "");
}
if (valA < valB) return sort.dir === "asc" ? -1 : 1;
if (valA > valB) return sort.dir === "asc" ? 1 : -1;
return 0;
});
return sorted;
}, [filteredRows, sort]);
if (!open) return null;
return (
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center animate-fadein">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-2xl relative">
<button
onClick={onClose}
className="absolute right-4 top-4 text-2xl text-gray-400 hover:text-red-500 font-bold"
>&times;</button>
<h2 className="font-bold text-xl mb-4">Search Earthquakes</h2>
<form
onSubmit={(e) => {
e.preventDefault();
doSearch();
}}
className="flex gap-2 mb-3"
>
<input
type="text"
placeholder="e.g. Mexico, EV-7.4-Mexico-00035"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-grow px-3 py-2 border rounded"
disabled={loading}
/>
<button
type="submit"
disabled={loading}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 min-w-[6.5rem] flex items-center justify-center"
>
{loading ? (
<>
<svg className="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
</svg>
Search...
</>
) : (
<>Search</>
)}
</button>
<button
type="button"
onClick={() => {
setSearch("");
setResults([]);
setFilters({ code: "", location: "", magnitude: "", date: "" });
}}
className="bg-gray-100 text-gray-600 px-3 py-2 rounded hover:bg-gray-200"
>
Clear
</button>
</form>
{error && (
<div className="text-red-600 font-medium mb-2">{error}</div>
)}
{/* Filter Row */}
<div className="mb-2">
<div className="flex gap-3">
{COLUMNS.map((col) => (
<input
key={col.key}
type={col.key === "magnitude" ? "number" : col.key === "date" ? "date" : "text"}
value={filters[col.key] || ""}
onChange={e =>
setFilters(f => ({ ...f, [col.key]: e.target.value }))
}
className="border border-neutral-200 rounded px-2 py-1 text-xs"
style={{
width:
col.key === "magnitude"
? 70
: col.key === "date"
? 130
: 120,
}}
placeholder={`Filter ${col.label}`}
aria-label={`Filter ${col.label}`}
disabled={loading || results.length === 0}
/>
))}
</div>
</div>
{/* Results Table */}
<div className="border rounded shadow-inner bg-white max-h-72 overflow-y-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-neutral-100 border-b">
{COLUMNS.map((col) => (
<th
key={col.key}
className={`font-semibold px-3 py-2 cursor-pointer select-none text-left`}
onClick={() =>
setSort(sort && sort.key === col.key
? { key: col.key as keyof Earthquake, dir: sort.dir === "asc" ? "desc" : "asc" }
: { key: col.key as keyof Earthquake, dir: "asc" })
}
>
{col.label}
{sort?.key === col.key &&
(sort.dir === "asc" ? " ↑" : " ↓")}
</th>
))}
</tr>
</thead>
<tbody>
{sortedRows.length === 0 && !loading && (
<tr>
<td colSpan={COLUMNS.length} className="p-3 text-center text-gray-400">
No results found.
</td>
</tr>
)}
{sortedRows.map(eq => (
<tr
key={eq.id}
className="hover:bg-blue-50 cursor-pointer border-b"
onClick={() => {
onSelect(eq);
onClose();
}}
tabIndex={0}
>
<td className="px-3 py-2 font-mono">{eq.code}</td>
<td className="px-3 py-2">{eq.location}</td>
<td className="px-3 py-2 font-bold">{eq.magnitude}</td>
<td className="px-3 py-2">{formatDate(eq.date)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@ -1,223 +0,0 @@
"use client";
import { useState } from "react";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
const yesNo = [
{ value: true, label: "Yes" },
{ value: false, label: "No" }
];
export default function LogObservatoryModal({ open, onClose, onSuccess }) {
const [name, setName] = useState("");
const [isOpen, setIsOpen] = useState("true");
const [dateOpened, setDateOpened] = useState<Date | null>(new Date());
const [dateClosed, setDateClosed] = useState<Date | null>(null);
const [city, setCity] = useState("");
const [country, setCountry] = useState("");
const [latitude, setLatitude] = useState("");
const [longitude, setLongitude] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<{ name: string } | null>(null);
// Reverse Geo-code
async function handleLatLonChange(lat: string, lon: string) {
setLatitude(lat);
setLongitude(lon);
if (/^-?\d+(\.\d+)?$/.test(lat) && /^-?\d+(\.\d+)?$/.test(lon)) {
try {
const resp = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10`
);
if (resp.ok) {
const data = await resp.json();
setCity(
data.address.city ||
data.address.town ||
data.address.village ||
data.address.hamlet ||
data.address.county ||
data.address.state ||
""
);
setCountry(data.address.country || "");
}
} catch {}
}
}
async function handleSubmit(e) {
e.preventDefault();
setLoading(true);
if (!name || !dateOpened || !latitude || !longitude || !city || !country) {
alert("Please complete all fields.");
setLoading(false);
return;
}
if (isOpen === "false" && !dateClosed) {
alert("Please enter the date this observatory closed.");
setLoading(false);
return;
}
try {
const res = await fetch("/api/observatories/log", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: name.trim(),
isFunctional: isOpen === "true" ? true : false,
location: `${city.trim()}, ${country.trim()}`,
latitude: parseFloat(latitude),
longitude: parseFloat(longitude),
dateEstablished: dateOpened,
dateClosed: isOpen === "false" ? dateClosed : null,
})
});
if (res.ok) {
setSuccess({ name });
setLoading(false);
if (onSuccess) onSuccess();
} else {
const err = await res.json();
alert("Failed to log observatory! " + (err.error || ""));
setLoading(false);
}
} catch (e: any) {
alert("Failed to log. " + e.message);
setLoading(false);
}
}
if (!open) return null;
if (success) {
return (
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
<div className="bg-white rounded-lg px-8 py-8 max-w-md w-full relative shadow-lg">
<button
onClick={() => { setSuccess(null); onClose(); }}
className="absolute right-4 top-4 text-xl text-gray-400 hover:text-gray-700"
aria-label="Close"
>&times;</button>
<div className="text-center">
<h2 className="text-xl font-semibold mb-3">Thank you for logging an observatory!</h2>
<div>The Observatory is now being shown as <b>{success.name}</b></div>
</div>
</div>
</div>
);
}
return (
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
<div className="bg-white rounded-lg shadow-lg p-6 max-w-lg w-full relative">
<button onClick={onClose}
className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg">&times;</button>
<h2 className="font-bold text-xl mb-4">Log Observatory</h2>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="block text-sm font-medium">Observatory Name</label>
<input
type="text" className="border rounded px-3 py-2 w-full"
value={name} onChange={e => setName(e.target.value)} required
/>
</div>
<div>
<label className="block text-sm font-medium">Is this observatory still open?</label>
<select
className="border rounded px-3 py-2 w-full"
value={isOpen}
onChange={e => setIsOpen(e.target.value)}
required
>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
<div>
<label className="block text-sm font-medium">Date Opened</label>
<DatePicker
selected={dateOpened}
onChange={date => setDateOpened(date)}
className="border rounded px-3 py-2 w-full"
dateFormat="yyyy-MM-dd"
showMonthDropdown
showYearDropdown
dropdownMode="select"
required
/>
</div>
{isOpen === "false" && (
<div>
<label className="block text-sm font-medium">Date Closed</label>
<DatePicker
selected={dateClosed}
onChange={date => setDateClosed(date)}
className="border rounded px-3 py-2 w-full"
dateFormat="yyyy-MM-dd"
showMonthDropdown
showYearDropdown
dropdownMode="select"
required
/>
</div>
)}
<div>
<label className="block text-sm font-medium">City/Area</label>
<span className="block text-xs text-gray-400">
(Use Lat/Lon then press Enter for reverse lookup)
</span>
<input
type="text"
className="border rounded px-3 py-2 w-full"
value={city}
onChange={e => setCity(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium">Country</label>
<input
type="text"
className="border rounded px-3 py-2 w-full"
value={country}
onChange={e => setCountry(e.target.value)}
required
/>
</div>
<div className="flex space-x-2">
<div className="flex-1">
<label className="block text-sm font-medium">Latitude</label>
<input type="number"
className="border rounded px-3 py-2 w-full"
value={latitude}
onChange={e => handleLatLonChange(e.target.value, longitude)}
placeholder="e.g. 36.12"
step="any"
required
/>
</div>
<div className="flex-1">
<label className="block text-sm font-medium">Longitude</label>
<input type="number"
className="border rounded px-3 py-2 w-full"
value={longitude}
onChange={e => handleLatLonChange(latitude, e.target.value)}
placeholder="e.g. -115.17"
step="any"
required
/>
</div>
</div>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 w-full"
disabled={loading}
>
{loading ? "Logging..." : "Log Observatory"}
</button>
</form>
</div>
</div>
);
}

View File

@ -111,12 +111,8 @@ function MapComponent({
}
const observatoryElement = document.createElement("div");
const root = createRoot(observatoryElement);
root.render(
<GiObservatory
className={`text-2xl drop-shadow-lg ${event.isFunctional === false ? "text-gray-400" : "text-blue-600"}`}
/>
);
const root = createRoot(observatoryElement);
root.render(<GiObservatory className="text-blue-600 text-2xl drop-shadow-lg" />);
quakeElement.appendChild(pulseElement);
quakeElement.appendChild(dotElement);

View File

@ -134,15 +134,11 @@ export default function Navbar({}: // currencySelector,
<ManagementNavbarButton name="Warehouse" href="/warehouse"></ManagementNavbarButton>
</div>
)}
{user && (
(user.role === "ADMIN" ||
(user.role === "SCIENTIST" && user.scientist?.level === "SENIOR")
) && (
<div className="flex h-full mr-5">
<ManagementNavbarButton name="Scientist Management" href="/management" />
</div>
)
)}
{user && (user.role === "SCIENTIST" || user.role === "ADMIN") && (
<div className="flex h-full mr-5">
<ManagementNavbarButton name="Scientist Management" href="/management"></ManagementNavbarButton>
</div>
)}
{user && user.role === "ADMIN" && (
<div className="flex h-full mr-5">
<ManagementNavbarButton name="Admin" href="/administrator"></ManagementNavbarButton>

View File

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

View File

@ -1,21 +0,0 @@
name,email,password,level
undefined,undefined,undefined,undefined
undefined,undefined,undefined,undefined
undefined,undefined,undefined,undefined
undefined,undefined,undefined,undefined
undefined,undefined,undefined,undefined
undefined,undefined,undefined,undefined
undefined,undefined,undefined,undefined
undefined,undefined,undefined,undefined
undefined,undefined,undefined,undefined
undefined,undefined,undefined,undefined
undefined,undefined,undefined,undefined
undefined,undefined,undefined,undefined
undefined,undefined,undefined,undefined
undefined,undefined,undefined,undefined
undefined,undefined,undefined,undefined
undefined,undefined,undefined,undefined
undefined,undefined,undefined,undefined
undefined,undefined,undefined,undefined
undefined,undefined,undefined,undefined
undefined,undefined,undefined,undefined
1 name email password level
2 undefined undefined undefined undefined
3 undefined undefined undefined undefined
4 undefined undefined undefined undefined
5 undefined undefined undefined undefined
6 undefined undefined undefined undefined
7 undefined undefined undefined undefined
8 undefined undefined undefined undefined
9 undefined undefined undefined undefined
10 undefined undefined undefined undefined
11 undefined undefined undefined undefined
12 undefined undefined undefined undefined
13 undefined undefined undefined undefined
14 undefined undefined undefined undefined
15 undefined undefined undefined undefined
16 undefined undefined undefined undefined
17 undefined undefined undefined undefined
18 undefined undefined undefined undefined
19 undefined undefined undefined undefined
20 undefined undefined undefined undefined
21 undefined undefined undefined undefined

View File

@ -1,12 +0,0 @@
// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: ["query", "error", "warn"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

View File

@ -1,60 +1,34 @@
{
"compilerOptions": {
"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"
]
"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"]
}