diff --git a/package-lock.json b/package-lock.json index ee0daed..54bc2b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "body-parser": "^2.2.0", "csv-parse": "^5.6.0", "csv-parser": "^3.2.0", + "date-fns": "^4.1.0", "dotenv": "^16.5.0", "easy-peasy": "^6.1.0", "express": "^5.1.0", @@ -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", diff --git a/package.json b/package.json index 5c8e561..2e39b73 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "body-parser": "^2.2.0", "csv-parse": "^5.6.0", "csv-parser": "^3.2.0", + "date-fns": "^4.1.0", "dotenv": "^16.5.0", "easy-peasy": "^6.1.0", "express": "^5.1.0", @@ -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", diff --git a/src/app/api/earthquakes/log/route.ts b/src/app/api/earthquakes/log/route.ts new file mode 100644 index 0000000..f6404ca --- /dev/null +++ b/src/app/api/earthquakes/log/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@utils/prisma"; + +// Generates code using only the country, and highest id in DB for numbering +async function generateEarthquakeCode(type: string, country: string) { + const typeLetter = type.trim().charAt(0).toUpperCase(); + // Remove non-alphanumeric for the country part + const countrySlug = (country || "Unknown").replace(/[^\w]/gi, ""); + // Use highest DB id to find the latest added earthquake's code number + const last = await prisma.earthquake.findFirst({ + orderBy: { id: "desc" }, + select: { code: true } + }); + let num = 10000; + if (last?.code) { + const parts = last.code.split("-"); + const lastNum = parseInt(parts[parts.length - 1], 10); + if (!isNaN(lastNum)) num = lastNum + 1; + } + return `E${typeLetter}-${countrySlug}-${num.toString().padStart(5, "0")}`; +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { date, magnitude, type, location, latitude, longitude, depth, country } = body; + const creatorId = 1; + if (!date || !magnitude || !type || !location || !latitude || !longitude || !depth || !country) { + return NextResponse.json({ error: "Missing fields" }, { status: 400 }); + } + if (+magnitude > 10) { + return NextResponse.json({ error: "Magnitude cannot exceed 10" }, { status: 400 }); + } + const code = await generateEarthquakeCode(type, country); + const eq = await prisma.earthquake.create({ + data: { + date: new Date(date), + code, + magnitude: +magnitude, + type, + location, // "city, country" + latitude: +latitude, + longitude: +longitude, + depth, + creatorId, + } + }); + return NextResponse.json({ id: eq.id, code }, { status: 201 }); + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/observatories/log/route.ts b/src/app/api/observatories/log/route.ts new file mode 100644 index 0000000..0651cd1 --- /dev/null +++ b/src/app/api/observatories/log/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@utils/prisma"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { + name, location, latitude, longitude, dateEstablished, + dateClosed, isFunctional + } = body; + const creatorId = 1; // (Set per logged-in user if desired) + if ( + !name || !location || latitude == null || longitude == null || + !dateEstablished || isFunctional == null + ) { + return NextResponse.json({ error: "Missing fields" }, { status: 400 }); + } + const created = await prisma.observatory.create({ + data: { + name, + location, + latitude: +latitude, + longitude: +longitude, + dateEstablished: new Date(dateEstablished), + isFunctional, + seismicSensorOnline: false, // You could add this to modal if you want + creatorId + } + }); + return NextResponse.json({ id: created.id, name: created.name }, { status: 201 }); + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/earthquakes/page.tsx b/src/app/earthquakes/page.tsx index 5fbd35a..9b26801 100644 --- a/src/app/earthquakes/page.tsx +++ b/src/app/earthquakes/page.tsx @@ -1,5 +1,4 @@ "use client"; - import { useMemo, useState } from "react"; import useSWR from "swr"; import Map from "@components/Map"; @@ -9,151 +8,147 @@ 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); - setResults([]); - try { - const res = await axios.post("/api/earthquakes/search", { query: search }); - setResults(res.data.earthquakes || []); - } catch (e) { - alert("Failed to search."); - } - setLoading(false); - }; - - if (!open) return null; - return ( -
-
- -

Search Earthquakes

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

No results found.

} -
    - {results.map((eq) => ( -
  • { - onSelect(eq); - onClose(); - }} - tabIndex={0} - > -
    - {eq.code} {eq.location}{" "} - {new Date(eq.date).toLocaleDateString()} -
    -
    = 7 ? "bg-red-500" : eq.magnitude >= 6 ? "bg-orange-400" : "bg-yellow-400" - }`} - > - {eq.magnitude} -
    -
  • - ))} -
-
-
-
- ); + const [search, setSearch] = useState(""); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const handleSearch = async (e) => { + e.preventDefault(); + setLoading(true); + setResults([]); + try { + const res = await axios.post("/api/earthquakes/search", { query: search }); + setResults(res.data.earthquakes || []); + } catch (e) { + alert("Failed to search."); + } + setLoading(false); + }; + if (!open) return null; + return ( +
+
+ +

Search Earthquakes

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

No results found.

} +
    + {results.map((eq) => ( +
  • { + onSelect(eq); + onClose(); + }} + tabIndex={0} + > +
    + {eq.code} {eq.location}{" "} + {new Date(eq.date).toLocaleDateString()} +
    +
    = 7 ? "bg-red-500" : eq.magnitude >= 6 ? "bg-orange-400" : "bg-yellow-400" + }`} + > + {eq.magnitude} +
    +
  • + ))} +
+
+
+
+ ); } // --- MAIN PAGE COMPONENT --- export default function Earthquakes() { - const [selectedEventId, setSelectedEventId] = useState(""); - const [hoveredEventId, setHoveredEventId] = useState(""); - - // Search modal state - const [searchModalOpen, setSearchModalOpen] = useState(false); - - // Fetch recent earthquakes as before - const { data, error, isLoading } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 10 })); - - // Prepare events for maps/sidebar - const earthquakeEvents = useMemo( - () => - data && data.earthquakes - ? data.earthquakes - .map( - (x: Earthquake): GeologicalEvent => ({ - id: x.code, - title: `Earthquake in ${x.code.split("-")[2]}`, - magnitude: x.magnitude, - longitude: x.longitude, - latitude: x.latitude, - text1: "", - text2: getRelativeDate(x.date), - date: x.date, - }) - ) - .sort((a: GeologicalEvent, b: GeologicalEvent) => new Date(b.date).getTime() - new Date(a.date).getTime()) - : [], - [data] - ); - - // Optional: show details of selected search result (not implemented here) - // const [selectedSearchResult, setSelectedSearchResult] = useState(null); - - return ( -
-
- -
- setSearchModalOpen(true)} - /> - setSearchModalOpen(false)} - onSelect={(eq) => { - setSelectedEventId(eq.code); // select on map/sidebar - // setSelectedSearchResult(eq); // you can use this if you want to show detail modal - }} - /> -
- ); -} + const [selectedEventId, setSelectedEventId] = useState(""); + const [hoveredEventId, setHoveredEventId] = useState(""); + const [searchModalOpen, setSearchModalOpen] = useState(false); + const [logModalOpen, setLogModalOpen] = useState(false); // <-- Move here! + // Fetch recent earthquakes as before + const { data, error, isLoading, mutate } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 10 })); + // Prepare events for maps/sidebar + const earthquakeEvents = useMemo( + () => + data && data.earthquakes + ? data.earthquakes + .map( + (x: Earthquake): GeologicalEvent => ({ + id: x.code, + title: `Earthquake in ${x.code.split("-")[2]}`, + magnitude: x.magnitude, + longitude: x.longitude, + latitude: x.latitude, + text1: "", + text2: getRelativeDate(x.date), + date: x.date, + }) + ) + .sort((a: GeologicalEvent, b: GeologicalEvent) => new Date(b.date).getTime() - new Date(a.date).getTime()) + : [], + [data] + ); + return ( +
+
+ +
+ setLogModalOpen(true)} // Correct! + onButton2Click={() => setSearchModalOpen(true)} + /> + setSearchModalOpen(false)} + onSelect={(eq) => { + setSelectedEventId(eq.code); + // setSelectedSearchResult(eq); // optional + }} + /> + setLogModalOpen(false)} + onSuccess={() => mutate()} // To refresh + /> +
+ ); +} \ No newline at end of file diff --git a/src/app/observatories/page.tsx b/src/app/observatories/page.tsx index b496a09..6409fac 100644 --- a/src/app/observatories/page.tsx +++ b/src/app/observatories/page.tsx @@ -1,70 +1,77 @@ "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 [selectedEventId, setSelectedEventId] = useState(""); + const [hoveredEventId, setHoveredEventId] = useState(""); + const [logModalOpen, setLogModalOpen] = useState(false); - // todo add in earthquake events - const observatoryEvents = useMemo( - () => - data && data.observatories - ? data.observatories - .map( - (x: Observatory): GeologicalEvent => ({ - id: x.id.toString(), - title: `New Observatory - ${x.name}`, - longitude: x.longitude, - latitude: x.latitude, - text1: "", - text2: getRelativeDate(x.dateEstablished), - date: x.dateEstablished, - }) - ) - .sort((a: GeologicalEvent, b: GeologicalEvent) => new Date(b.date).getTime() - new Date(a.date).getTime()) // Remove Date conversion - : [], - [data] - ); + const { data, error, isLoading, mutate } = useSWR( + "/api/observatories", + fetcher + ); - return ( -
-
- -
- -
- ); + const observatoryEvents = useMemo( + () => + data && data.observatories + ? data.observatories + .map( + (x: Observatory): GeologicalEvent => ({ + 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() + ) + : [], + [data] + ); + + return ( +
+
+ +
+ setLogModalOpen(true)} + /> + setLogModalOpen(false)} + onSuccess={() => mutate()} + /> +
+ ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index d1fad16..a114388 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( +
+ +
+
+ {whole} + . + {decimal} +
+
+
+ ); +} + export default function Home() { const { data, error, isLoading } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 6 })); @@ -131,34 +152,21 @@ export default function Home() {
{recents.map((eq) => (
-
-
- Earthquake in {eq.location || (eq.code && eq.code.split("-")[2])} -
-
- {getRelativeDate(eq.date)} -
+ key={eq.code} + className="flex items-center justify-between p-4 bg-white rounded-xl shadow border" + > +
+
+ Earthquake in {eq.location || (eq.code && eq.code.split("-")[2])}
- = 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} - +
+ {getRelativeDate(eq.date)} +
+
+
))} +

diff --git a/src/components/EarthquakeLogModal.tsx b/src/components/EarthquakeLogModal.tsx new file mode 100644 index 0000000..ce9d30f --- /dev/null +++ b/src/components/EarthquakeLogModal.tsx @@ -0,0 +1,248 @@ +"use client"; +import { useState } from "react"; +import DatePicker from "react-datepicker"; +import "react-datepicker/dist/react-datepicker.css"; + +const typeOptions = [ + { value: "volcanic", label: "Volcanic" }, + { value: "tectonic", label: "Tectonic" }, + { value: "collapse", label: "Collapse" }, + { value: "explosion", label: "Explosion" } +]; + +export default function EarthquakeLogModal({ open, onClose, onSuccess }) { + const [date, setDate] = useState(new Date()); + const [magnitude, setMagnitude] = useState(""); + const [type, setType] = useState(typeOptions[0].value); + const [city, setCity] = useState(""); + const [country, setCountry] = useState(""); + const [latitude, setLatitude] = useState(""); + const [longitude, setLongitude] = useState(""); + const [depth, setDepth] = useState(""); + const [loading, setLoading] = useState(false); + const [successCode, setSuccessCode] = useState(null); + + async function handleLatLonChange(lat: string, lon: string) { + setLatitude(lat); + setLongitude(lon); + if (/^-?\d+(\.\d+)?$/.test(lat) && /^-?\d+(\.\d+)?$/.test(lon)) { + try { + const resp = await fetch( + `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10` + ); + if (resp.ok) { + const data = await resp.json(); + setCity( + data.address.city || + data.address.town || + data.address.village || + data.address.hamlet || + data.address.county || + data.address.state || + "" + ); + setCountry(data.address.country || ""); + } + } catch (e) { + // ignore + } + } + } + + async function handleSubmit(e) { + e.preventDefault(); + setLoading(true); + if (!date || !magnitude || !type || !city || !country || !latitude || !longitude || !depth) { + alert("Please complete all fields."); + setLoading(false); + return; + } + try { + const res = await fetch("/api/earthquakes/log", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + date, + magnitude: parseFloat(magnitude), + type, + location: `${city.trim()}, ${country.trim()}`, + country: country.trim(), + latitude: parseFloat(latitude), + longitude: parseFloat(longitude), + depth + }) + }); + if (res.ok) { + const result = await res.json(); + setSuccessCode(result.code); + setLoading(false); + if (onSuccess) onSuccess(); + } else { + const err = await res.json(); + alert("Failed to log earthquake! " + (err.error || "")); + setLoading(false); + } + } catch (e: any) { + alert("Failed to log. " + e.message); + setLoading(false); + } + } + + if (!open) return null; + + // Success popup overlay + if (successCode) { + return ( +
+
+ +
+

+ Thank you for logging an earthquake! +

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

Log Earthquake

+
+
+ + setDate(date)} + className="border rounded px-3 py-2 w-full" + dateFormat="yyyy-MM-dd" + maxDate={new Date()} + showMonthDropdown + showYearDropdown + dropdownMode="select" + required + /> +
+
+ + { + const val = e.target.value; + if (parseFloat(val) > 10) return; + setMagnitude(val); + }} + required + /> +
+
+ + +
+
+ + + (Use Lat/Lon then press Enter for reverse lookup) + + setCity(e.target.value)} + required + /> +
+
+ + setCountry(e.target.value)} + required + /> +
+
+
+ + handleLatLonChange(e.target.value, longitude)} + placeholder="e.g. 36.12" + step="any" + required + /> +
+
+ + handleLatLonChange(latitude, e.target.value)} + placeholder="e.g. -115.17" + step="any" + required + /> +
+
+
+ + setDepth(e.target.value)} + placeholder="e.g. 10 km" + required + /> +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/LogObservatoryModal.tsx b/src/components/LogObservatoryModal.tsx new file mode 100644 index 0000000..fdb8d44 --- /dev/null +++ b/src/components/LogObservatoryModal.tsx @@ -0,0 +1,223 @@ +"use client"; +import { useState } from "react"; +import DatePicker from "react-datepicker"; +import "react-datepicker/dist/react-datepicker.css"; + +const yesNo = [ + { value: true, label: "Yes" }, + { value: false, label: "No" } +]; + +export default function LogObservatoryModal({ open, onClose, onSuccess }) { + const [name, setName] = useState(""); + const [isOpen, setIsOpen] = useState("true"); + const [dateOpened, setDateOpened] = useState(new Date()); + const [dateClosed, setDateClosed] = useState(null); + const [city, setCity] = useState(""); + const [country, setCountry] = useState(""); + const [latitude, setLatitude] = useState(""); + const [longitude, setLongitude] = useState(""); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState<{ name: string } | null>(null); + + // Reverse Geo-code + async function handleLatLonChange(lat: string, lon: string) { + setLatitude(lat); + setLongitude(lon); + if (/^-?\d+(\.\d+)?$/.test(lat) && /^-?\d+(\.\d+)?$/.test(lon)) { + try { + const resp = await fetch( + `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10` + ); + if (resp.ok) { + const data = await resp.json(); + setCity( + data.address.city || + data.address.town || + data.address.village || + data.address.hamlet || + data.address.county || + data.address.state || + "" + ); + setCountry(data.address.country || ""); + } + } catch {} + } + } + + async function handleSubmit(e) { + e.preventDefault(); + setLoading(true); + if (!name || !dateOpened || !latitude || !longitude || !city || !country) { + alert("Please complete all fields."); + setLoading(false); + return; + } + if (isOpen === "false" && !dateClosed) { + alert("Please enter the date this observatory closed."); + setLoading(false); + return; + } + try { + const res = await fetch("/api/observatories/log", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: name.trim(), + isFunctional: isOpen === "true" ? true : false, + location: `${city.trim()}, ${country.trim()}`, + latitude: parseFloat(latitude), + longitude: parseFloat(longitude), + dateEstablished: dateOpened, + dateClosed: isOpen === "false" ? dateClosed : null, + }) + }); + if (res.ok) { + setSuccess({ name }); + setLoading(false); + if (onSuccess) onSuccess(); + } else { + const err = await res.json(); + alert("Failed to log observatory! " + (err.error || "")); + setLoading(false); + } + } catch (e: any) { + alert("Failed to log. " + e.message); + setLoading(false); + } + } + + if (!open) return null; + + if (success) { + return ( +
+
+ +
+

Thank you for logging an observatory!

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

Log Observatory

+
+
+ + setName(e.target.value)} required + /> +
+
+ + +
+
+ + setDateOpened(date)} + className="border rounded px-3 py-2 w-full" + dateFormat="yyyy-MM-dd" + showMonthDropdown + showYearDropdown + dropdownMode="select" + required + /> +
+ {isOpen === "false" && ( +
+ + setDateClosed(date)} + className="border rounded px-3 py-2 w-full" + dateFormat="yyyy-MM-dd" + showMonthDropdown + showYearDropdown + dropdownMode="select" + required + /> +
+ )} +
+ + + (Use Lat/Lon then press Enter for reverse lookup) + + setCity(e.target.value)} + required + /> +
+
+ + setCountry(e.target.value)} + required + /> +
+
+
+ + handleLatLonChange(e.target.value, longitude)} + placeholder="e.g. 36.12" + step="any" + required + /> +
+
+ + handleLatLonChange(latitude, e.target.value)} + placeholder="e.g. -115.17" + step="any" + required + /> +
+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 98bfa62..b88ec84 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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(null); @@ -73,12 +75,25 @@ export default function Sidebar({

{logTitle}

{logSubtitle}

- - - - {/* "Search Earthquakes" should NOT be wrapped in a Link! */} + {onButton1Click ? ( + + ) : ( + + + + )} +