Lots of changes to Log earthquake/observatory

This commit is contained in:
IZZY 2025-05-31 16:43:20 +01:00
parent b73114afd1
commit 587b6b7f01
10 changed files with 909 additions and 230 deletions

95
package-lock.json generated
View File

@ -16,6 +16,7 @@
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
"csv-parse": "^5.6.0", "csv-parse": "^5.6.0",
"csv-parser": "^3.2.0", "csv-parser": "^3.2.0",
"date-fns": "^4.1.0",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"easy-peasy": "^6.1.0", "easy-peasy": "^6.1.0",
"express": "^5.1.0", "express": "^5.1.0",
@ -30,6 +31,7 @@
"path": "^0.12.7", "path": "^0.12.7",
"prisma": "^6.4.1", "prisma": "^6.4.1",
"react": "^19.1.0", "react": "^19.1.0",
"react-datepicker": "^8.4.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
@ -246,6 +248,59 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz",
"integrity": "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.0.tgz",
"integrity": "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.0",
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/react": {
"version": "0.27.9",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.9.tgz",
"integrity": "sha512-Y0aCJBNtfVF6ikI1kVzA0WzSAhVBz79vFWOhvb5MLCRNODZ1ylGSLTuncchR7JsLyn9QzV6JD44DyZhhOtvpRw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.9",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
"integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -2493,6 +2548,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color": { "node_modules/color": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@ -2783,6 +2847,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@ -6324,6 +6398,21 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-datepicker": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.4.0.tgz",
"integrity": "sha512-6nPDnj8vektWCIOy9ArS3avus9Ndsyz5XgFCJ7nBxXASSpBdSL6lG9jzNNmViPOAOPh6T5oJyGaXuMirBLECag==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.27.3",
"clsx": "^2.1.1",
"date-fns": "^4.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
@ -7344,6 +7433,12 @@
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
"license": "MIT"
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "3.4.17", "version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",

View File

@ -19,6 +19,7 @@
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
"csv-parse": "^5.6.0", "csv-parse": "^5.6.0",
"csv-parser": "^3.2.0", "csv-parser": "^3.2.0",
"date-fns": "^4.1.0",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"easy-peasy": "^6.1.0", "easy-peasy": "^6.1.0",
"express": "^5.1.0", "express": "^5.1.0",
@ -33,6 +34,7 @@
"path": "^0.12.7", "path": "^0.12.7",
"prisma": "^6.4.1", "prisma": "^6.4.1",
"react": "^19.1.0", "react": "^19.1.0",
"react-datepicker": "^8.4.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",

View File

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

View File

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

View File

@ -1,5 +1,4 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import Map from "@components/Map"; import Map from "@components/Map";
@ -9,151 +8,147 @@ import { Earthquake } from "@prismaclient";
import { getRelativeDate } from "@utils/formatters"; import { getRelativeDate } from "@utils/formatters";
import GeologicalEvent from "@appTypes/Event"; import GeologicalEvent from "@appTypes/Event";
import axios from "axios"; import axios from "axios";
import EarthquakeLogModal from "@components/EarthquakeLogModal";
// todo (optional) add in filtering of map earthquakes
// --- SEARCH MODAL COMPONENT --- // --- SEARCH MODAL COMPONENT ---
function EarthquakeSearchModal({ open, onClose, onSelect }) { function EarthquakeSearchModal({ open, onClose, onSelect }) {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [results, setResults] = useState([]); const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const handleSearch = async (e) => {
const handleSearch = async (e) => { e.preventDefault();
e.preventDefault(); setLoading(true);
setLoading(true); setResults([]);
setResults([]); try {
try { const res = await axios.post("/api/earthquakes/search", { query: search });
const res = await axios.post("/api/earthquakes/search", { query: search }); setResults(res.data.earthquakes || []);
setResults(res.data.earthquakes || []); } catch (e) {
} catch (e) { alert("Failed to search.");
alert("Failed to search."); }
} setLoading(false);
setLoading(false); };
}; if (!open) return null;
return (
if (!open) return null; <div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
return ( <div className="bg-white rounded-lg shadow-lg p-6 max-w-lg w-full relative">
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center"> <button onClick={onClose} className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg">
<div className="bg-white rounded-lg shadow-lg p-6 max-w-lg w-full relative"> &times;
<button onClick={onClose} className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg"> </button>
&times; <h2 className="font-bold text-xl mb-4">Search Earthquakes</h2>
</button> <form onSubmit={handleSearch} className="flex gap-2 mb-4">
<h2 className="font-bold text-xl mb-4">Search Earthquakes</h2> <input
<form onSubmit={handleSearch} className="flex gap-2 mb-4"> type="text"
<input placeholder="e.g. Mexico, EV-7.4-Mexico-00035"
type="text" value={search}
placeholder="e.g. Mexico, EV-7.4-Mexico-00035" onChange={(e) => setSearch(e.target.value)}
value={search} className="flex-grow px-3 py-2 border rounded"
onChange={(e) => setSearch(e.target.value)} required
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 type="submit" className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"> </button>
{loading ? "Searching..." : "Search"} </form>
</button> <div>
</form> {results.length === 0 && !loading && search !== "" && <p className="text-gray-400 text-sm">No results found.</p>}
<div> <ul>
{results.length === 0 && !loading && search !== "" && <p className="text-gray-400 text-sm">No results found.</p>} {results.map((eq) => (
<ul> <li
{results.map((eq) => ( key={eq.id}
<li className="border-b py-2 cursor-pointer hover:bg-gray-50 flex items-center justify-between"
key={eq.id} onClick={() => {
className="border-b py-2 cursor-pointer hover:bg-gray-50 flex items-center justify-between" onSelect(eq);
onClick={() => { onClose();
onSelect(eq); }}
onClose(); tabIndex={0}
}} >
tabIndex={0} <div>
> <strong>{eq.code}</strong> <span>{eq.location}</span>{" "}
<div> <span className="text-xs text-gray-500">{new Date(eq.date).toLocaleDateString()}</span>
<strong>{eq.code}</strong> <span>{eq.location}</span>{" "} </div>
<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 ${
<div eq.magnitude >= 7 ? "bg-red-500" : eq.magnitude >= 6 ? "bg-orange-400" : "bg-yellow-400"
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>
{eq.magnitude} </li>
</div> ))}
</li> </ul>
))} </div>
</ul> </div>
</div> </div>
</div> );
</div>
);
} }
// --- MAIN PAGE COMPONENT --- // --- MAIN PAGE COMPONENT ---
export default function Earthquakes() { export default function Earthquakes() {
const [selectedEventId, setSelectedEventId] = useState(""); const [selectedEventId, setSelectedEventId] = useState("");
const [hoveredEventId, setHoveredEventId] = useState(""); const [hoveredEventId, setHoveredEventId] = useState("");
const [searchModalOpen, setSearchModalOpen] = useState(false);
// Search modal state const [logModalOpen, setLogModalOpen] = useState(false); // <-- Move here!
const [searchModalOpen, setSearchModalOpen] = useState(false); // Fetch recent earthquakes as before
const { data, error, isLoading, mutate } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 10 }));
// Fetch recent earthquakes as before // Prepare events for maps/sidebar
const { data, error, isLoading } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 10 })); const earthquakeEvents = useMemo(
() =>
// Prepare events for maps/sidebar data && data.earthquakes
const earthquakeEvents = useMemo( ? data.earthquakes
() => .map(
data && data.earthquakes (x: Earthquake): GeologicalEvent => ({
? data.earthquakes id: x.code,
.map( title: `Earthquake in ${x.code.split("-")[2]}`,
(x: Earthquake): GeologicalEvent => ({ magnitude: x.magnitude,
id: x.code, longitude: x.longitude,
title: `Earthquake in ${x.code.split("-")[2]}`, latitude: x.latitude,
magnitude: x.magnitude, text1: "",
longitude: x.longitude, text2: getRelativeDate(x.date),
latitude: x.latitude, date: x.date,
text1: "", })
text2: getRelativeDate(x.date), )
date: x.date, .sort((a: GeologicalEvent, b: GeologicalEvent) => new Date(b.date).getTime() - new Date(a.date).getTime())
}) : [],
) [data]
.sort((a: GeologicalEvent, b: GeologicalEvent) => new Date(b.date).getTime() - new Date(a.date).getTime()) );
: [], return (
[data] <div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden">
); <div className="flex-grow h-full">
<Map
// Optional: show details of selected search result (not implemented here) events={earthquakeEvents}
// const [selectedSearchResult, setSelectedSearchResult] = useState(null); selectedEventId={selectedEventId}
setSelectedEventId={setSelectedEventId}
return ( hoveredEventId={hoveredEventId}
<div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden"> setHoveredEventId={setHoveredEventId}
<div className="flex-grow h-full"> mapType="Earthquakes"
<Map />
events={earthquakeEvents} </div>
selectedEventId={selectedEventId} <Sidebar
setSelectedEventId={setSelectedEventId} logTitle="Log an Earthquake"
hoveredEventId={hoveredEventId} logSubtitle="Record new earthquakes - time/date, location, magnitude, observatory and scientists"
setHoveredEventId={setHoveredEventId} recentsTitle="Recent Earthquakes"
mapType="Earthquakes" events={earthquakeEvents}
/> selectedEventId={selectedEventId}
</div> setSelectedEventId={setSelectedEventId}
<Sidebar hoveredEventId={hoveredEventId}
logTitle="Log an Earthquake" setHoveredEventId={setHoveredEventId}
logSubtitle="Record new earthquakes - time/date, location, magnitude, observatory and scientists" button1Name="Log an Earthquake"
recentsTitle="Recent Earthquakes" button2Name="Search Earthquakes"
events={earthquakeEvents} onButton1Click={() => setLogModalOpen(true)} // Correct!
selectedEventId={selectedEventId} onButton2Click={() => setSearchModalOpen(true)}
setSelectedEventId={setSelectedEventId} />
hoveredEventId={hoveredEventId} <EarthquakeSearchModal
setHoveredEventId={setHoveredEventId} open={searchModalOpen}
button1Name="Log an Earthquake" onClose={() => setSearchModalOpen(false)}
button2Name="Search Earthquakes" onSelect={(eq) => {
onButton2Click={() => setSearchModalOpen(true)} setSelectedEventId(eq.code);
/> // setSelectedSearchResult(eq); // optional
<EarthquakeSearchModal }}
open={searchModalOpen} />
onClose={() => setSearchModalOpen(false)} <EarthquakeLogModal
onSelect={(eq) => { open={logModalOpen}
setSelectedEventId(eq.code); // select on map/sidebar onClose={() => setLogModalOpen(false)}
// setSelectedSearchResult(eq); // you can use this if you want to show detail modal onSuccess={() => mutate()} // To refresh
}} />
/> </div>
</div> );
);
} }

View File

@ -1,70 +1,77 @@
"use client"; "use client";
import { useMemo } from "react"; import { useState, useMemo } from "react";
import { useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import Map from "@components/Map"; import Map from "@components/Map";
import LogObservatoryModal from "@/components/LogObservatoryModal"; // Adjust if your path is different
import { fetcher } from "@utils/axiosHelpers"; import { fetcher } from "@utils/axiosHelpers";
import { Observatory } from "@prismaclient"; import { Observatory } from "@prismaclient";
import { getRelativeDate } from "@utils/formatters"; import { getRelativeDate } from "@utils/formatters";
import GeologicalEvent from "@appTypes/Event"; import GeologicalEvent from "@appTypes/Event";
// 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() { export default function Observatories() {
const [selectedEventId, setSelectedEventId] = useState(""); const [selectedEventId, setSelectedEventId] = useState("");
const [hoveredEventId, setHoveredEventId] = useState(""); const [hoveredEventId, setHoveredEventId] = useState("");
const { data, error, isLoading } = useSWR("/api/observatories", fetcher); const [logModalOpen, setLogModalOpen] = useState(false);
// todo add in earthquake events const { data, error, isLoading, mutate } = useSWR(
const observatoryEvents = useMemo( "/api/observatories",
() => fetcher
data && data.observatories );
? data.observatories
.map(
(x: Observatory): GeologicalEvent => ({
id: x.id.toString(),
title: `New Observatory - ${x.name}`,
longitude: x.longitude,
latitude: x.latitude,
text1: "",
text2: getRelativeDate(x.dateEstablished),
date: x.dateEstablished,
})
)
.sort((a: GeologicalEvent, b: GeologicalEvent) => new Date(b.date).getTime() - new Date(a.date).getTime()) // Remove Date conversion
: [],
[data]
);
return ( const observatoryEvents = useMemo(
<div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden"> () =>
<div className="flex-grow h-full"> data && data.observatories
<Map ? data.observatories
events={observatoryEvents} .map(
selectedEventId={selectedEventId} (x: Observatory): GeologicalEvent => ({
setSelectedEventId={setSelectedEventId} id: x.id.toString(),
hoveredEventId={hoveredEventId} title: `New Observatory - ${x.name}`,
setHoveredEventId={setHoveredEventId} longitude: x.longitude,
mapType="observatories" latitude: x.latitude,
/> text1: "",
</div> text2: getRelativeDate(x.dateEstablished),
<Sidebar date: x.dateEstablished,
logTitle="Observatory Mapping" })
logSubtitle="Record and search observatories - time/date set-up, location, scientists and recent earthquakes" )
recentsTitle="Observatory Events" .sort(
events={observatoryEvents} (a: GeologicalEvent, b: GeologicalEvent) =>
selectedEventId={selectedEventId} new Date(b.date).getTime() - new Date(a.date).getTime()
setSelectedEventId={setSelectedEventId} )
hoveredEventId={hoveredEventId} : [],
setHoveredEventId={setHoveredEventId} [data]
button1Name="Log a New Observatory" );
button2Name="Search Observatories"
/> return (
</div> <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={() => setLogModalOpen(true)}
/>
<LogObservatoryModal
open={logModalOpen}
onClose={() => setLogModalOpen(false)}
onSuccess={() => mutate()}
/>
</div>
);
} }

View File

@ -4,6 +4,8 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import BottomFooter from "@components/BottomFooter"; import BottomFooter from "@components/BottomFooter";
import { createPoster } from "@utils/axiosHelpers"; import { createPoster } from "@utils/axiosHelpers";
import getMagnitudeColor from "@utils/getMagnitudeColour";
import { TbHexagon } from "react-icons/tb";
// formats the date // formats the date
function getRelativeDate(dateString: string): string { function getRelativeDate(dateString: string): string {
@ -16,6 +18,25 @@ function getRelativeDate(dateString: string): string {
return date.toLocaleDateString(); return date.toLocaleDateString();
} }
// copied from sidebar
function MagnitudeNumber({ magnitude }: { magnitude: number }) {
const magnitudeStr = magnitude.toFixed(1);
const [whole, decimal] = magnitudeStr.split(".");
return (
<div className="relative" style={{ color: getMagnitudeColor(magnitude) }}>
<TbHexagon size={40} className="drop-shadow-sm" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex items-baseline font-mono font-bold tracking-tight">
<span className="text-xl -mr-1">{whole}</span>
<span className="text-xs ml-[1.5px] -mr-[2.5px]">.</span>
<span className="text-xs -mr-[1px]">{decimal}</span>
</div>
</div>
</div>
);
}
export default function Home() { export default function Home() {
const { data, error, isLoading } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 6 })); const { data, error, isLoading } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 6 }));
@ -131,34 +152,21 @@ export default function Home() {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{recents.map((eq) => ( {recents.map((eq) => (
<div <div
key={eq.code} key={eq.code}
className="flex items-center justify-between p-4 bg-white rounded-xl shadow border" className="flex items-center justify-between p-4 bg-white rounded-xl shadow border"
> >
<div> <div>
<div className="font-semibold"> <div className="font-semibold">
Earthquake in {eq.location || (eq.code && eq.code.split("-")[2])} Earthquake in {eq.location || (eq.code && eq.code.split("-")[2])}
</div>
<div className="text-sm text-gray-500">
{getRelativeDate(eq.date)}
</div>
</div> </div>
<span <div className="text-sm text-gray-500">
className={`flex items-center justify-center font-bold text-md ml-2 rounded-full border-2 border-current {getRelativeDate(eq.date)}
${ </div>
eq.magnitude >= 7 </div>
? "text-red-600 border-red-600" <MagnitudeNumber magnitude={eq.magnitude} />
: eq.magnitude >= 6
? "text-orange-500 border-orange-500"
: "text-yellow-500 border-yellow-500"
}
min-w-[2.8rem] min-h-[2.8rem] max-h-12 max-w-12`}
style={{ aspectRatio: "1 / 1" }}
title={`Magnitude ${eq.magnitude}`}
>
{eq.magnitude}
</span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
<p className="mt-20"></p> <p className="mt-20"></p>

View File

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

View File

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

View File

@ -17,6 +17,7 @@ interface SidebarProps {
button1Name: string; button1Name: string;
button2Name: string; button2Name: string;
onButton2Click?: () => void; onButton2Click?: () => void;
onButton1Click?: () => void;
} }
function MagnitudeNumber({ magnitude }: { magnitude: number }) { function MagnitudeNumber({ magnitude }: { magnitude: number }) {
@ -51,6 +52,7 @@ export default function Sidebar({
button1Name, button1Name,
button2Name, button2Name,
onButton2Click, onButton2Click,
onButton1Click,
}: SidebarProps) { }: SidebarProps) {
const eventsContainerRef = useRef<HTMLDivElement>(null); const eventsContainerRef = useRef<HTMLDivElement>(null);
@ -73,12 +75,25 @@ export default function Sidebar({
<h2 className="text-2xl font-bold text-neutral-800 mb-2">{logTitle}</h2> <h2 className="text-2xl font-bold text-neutral-800 mb-2">{logTitle}</h2>
<p className="text-sm text-neutral-600 leading-relaxed">{logSubtitle}</p> <p className="text-sm text-neutral-600 leading-relaxed">{logSubtitle}</p>
<Link href="/"> {onButton1Click ? (
<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"> <button
{button1Name} 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"
</button> onClick={onButton1Click}
</Link> type="button"
{/* "Search Earthquakes" should NOT be wrapped in a Link! */} >
{button1Name}
</button>
) : (
<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"
type="button"
>
{button1Name}
</button>
</Link>
)}
<button <button
className="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition-colors duration-200 font-medium" className="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition-colors duration-200 font-medium"
onClick={onButton2Click} onClick={onButton2Click}