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

View File

@ -19,6 +19,7 @@
"body-parser": "^2.2.0",
"csv-parse": "^5.6.0",
"csv-parser": "^3.2.0",
"date-fns": "^4.1.0",
"dotenv": "^16.5.0",
"easy-peasy": "^6.1.0",
"express": "^5.1.0",
@ -33,6 +34,7 @@
"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",

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";
import { useMemo, useState } from "react";
import useSWR from "swr";
import Map from "@components/Map";
@ -9,15 +8,13 @@ import { Earthquake } from "@prismaclient";
import { getRelativeDate } from "@utils/formatters";
import GeologicalEvent from "@appTypes/Event";
import axios from "axios";
// todo (optional) add in filtering of map earthquakes
import EarthquakeLogModal from "@components/EarthquakeLogModal";
// --- 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);
@ -30,7 +27,6 @@ function EarthquakeSearchModal({ open, onClose, onSelect }) {
}
setLoading(false);
};
if (!open) return null;
return (
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
@ -89,13 +85,10 @@ function EarthquakeSearchModal({ open, onClose, onSelect }) {
export default function Earthquakes() {
const [selectedEventId, setSelectedEventId] = useState("");
const [hoveredEventId, setHoveredEventId] = useState("");
// Search modal state
const [searchModalOpen, setSearchModalOpen] = useState(false);
const [logModalOpen, setLogModalOpen] = useState(false); // <-- Move here!
// Fetch recent earthquakes as before
const { data, error, isLoading } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 10 }));
const { data, error, isLoading, mutate } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 10 }));
// Prepare events for maps/sidebar
const earthquakeEvents = useMemo(
() =>
@ -117,10 +110,6 @@ export default function Earthquakes() {
: [],
[data]
);
// 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">
@ -144,16 +133,22 @@ export default function Earthquakes() {
setHoveredEventId={setHoveredEventId}
button1Name="Log an Earthquake"
button2Name="Search Earthquakes"
onButton1Click={() => setLogModalOpen(true)} // Correct!
onButton2Click={() => setSearchModalOpen(true)}
/>
<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
setSelectedEventId(eq.code);
// setSelectedSearchResult(eq); // optional
}}
/>
<EarthquakeLogModal
open={logModalOpen}
onClose={() => setLogModalOpen(false)}
onSuccess={() => mutate()} // To refresh
/>
</div>
);
}

View File

@ -1,26 +1,24 @@
"use client";
import { useMemo } from "react";
import { useState } from "react";
import { useState, useMemo } from "react";
import useSWR from "swr";
import Sidebar from "@/components/Sidebar";
import Map from "@components/Map";
import LogObservatoryModal from "@/components/LogObservatoryModal"; // Adjust if your path is different
import { fetcher } from "@utils/axiosHelpers";
import { Observatory } from "@prismaclient";
import { getRelativeDate } from "@utils/formatters";
import GeologicalEvent from "@appTypes/Event";
// 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 { data, error, isLoading } = useSWR("/api/observatories", fetcher);
const [logModalOpen, setLogModalOpen] = useState(false);
const { data, error, isLoading, mutate } = useSWR(
"/api/observatories",
fetcher
);
// todo add in earthquake events
const observatoryEvents = useMemo(
() =>
data && data.observatories
@ -36,7 +34,10 @@ export default function Observatories() {
date: x.dateEstablished,
})
)
.sort((a: GeologicalEvent, b: GeologicalEvent) => new Date(b.date).getTime() - new Date(a.date).getTime()) // Remove Date conversion
.sort(
(a: GeologicalEvent, b: GeologicalEvent) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
)
: [],
[data]
);
@ -56,7 +57,7 @@ export default function Observatories() {
<Sidebar
logTitle="Observatory Mapping"
logSubtitle="Record and search observatories - time/date set-up, location, scientists and recent earthquakes"
recentsTitle="Observatory Events"
recentsTitle="New Observatories"
events={observatoryEvents}
selectedEventId={selectedEventId}
setSelectedEventId={setSelectedEventId}
@ -64,6 +65,12 @@ export default function Observatories() {
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 BottomFooter from "@components/BottomFooter";
import { createPoster } from "@utils/axiosHelpers";
import getMagnitudeColor from "@utils/getMagnitudeColour";
import { TbHexagon } from "react-icons/tb";
// formats the date
function getRelativeDate(dateString: string): string {
@ -16,6 +18,25 @@ function getRelativeDate(dateString: string): string {
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 }));
@ -142,23 +163,10 @@ export default function Home() {
{getRelativeDate(eq.date)}
</div>
</div>
<span
className={`flex items-center justify-center font-bold text-md ml-2 rounded-full border-2 border-current
${
eq.magnitude >= 7
? "text-red-600 border-red-600"
: 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>
<MagnitudeNumber magnitude={eq.magnitude} />
</div>
))}
</div>
</div>
<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;
button2Name: string;
onButton2Click?: () => void;
onButton1Click?: () => void;
}
function MagnitudeNumber({ magnitude }: { magnitude: number }) {
@ -51,6 +52,7 @@ export default function Sidebar({
button1Name,
button2Name,
onButton2Click,
onButton1Click,
}: SidebarProps) {
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>
<p className="text-sm text-neutral-600 leading-relaxed">{logSubtitle}</p>
{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"
onClick={onButton1Click}
type="button"
>
{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">
<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>
{/* "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}