Added close earthquake log and search modals on click outside
This commit is contained in:
parent
128517b388
commit
dd650c6ba6
@ -229,6 +229,7 @@ export default function Profile() {
|
||||
}
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
// todo add delete user route
|
||||
const res = await axios.post(
|
||||
"/api/delete-user",
|
||||
{ userId: user!.id },
|
||||
|
||||
@ -95,6 +95,7 @@ function LogModal({ onClose }: { onClose: () => void }) {
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// todo!! add log api route
|
||||
await new Promise((resolve) => setTimeout(resolve, 500)); // Simulated API call
|
||||
alert(`Logged ${name} to storage: ${storageLocation}`);
|
||||
onClose();
|
||||
|
||||
@ -1,248 +1,245 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { FormEvent, useState, useRef } 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" }
|
||||
{ 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);
|
||||
export default function EarthquakeLogModal({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
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);
|
||||
const modalRef = useRef<HTMLDivElement>(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 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);
|
||||
}
|
||||
}
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
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;
|
||||
function handleOutsideClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 max-w-lg w-full relative">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
// Success popup overlay
|
||||
if (successCode) {
|
||||
return (
|
||||
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center" onClick={handleOutsideClick}>
|
||||
<div className="bg-white rounded-lg px-8 py-8 max-w-md w-full relative shadow-lg" ref={modalRef}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSuccessCode(null);
|
||||
onClose();
|
||||
}}
|
||||
className="absolute right-4 top-4 text-xl text-gray-400 hover:text-gray-700"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</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" onClick={handleOutsideClick}>
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 max-w-lg w-full relative" ref={modalRef}>
|
||||
<button onClick={onClose} className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg">
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,243 +3,233 @@ import { useState, useEffect, useMemo } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
export type Earthquake = {
|
||||
id: string;
|
||||
code: string;
|
||||
magnitude: number;
|
||||
location: string;
|
||||
date: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
id: string;
|
||||
code: string;
|
||||
magnitude: number;
|
||||
location: string;
|
||||
date: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
};
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
const COLUMNS = [
|
||||
{ label: "Code", key: "code", className: "font-mono font-bold" },
|
||||
{ label: "Location", key: "location" },
|
||||
{ label: "Magnitude", key: "magnitude", numeric: true },
|
||||
{ label: "Date", key: "date" },
|
||||
{ label: "Code", key: "code", className: "font-mono font-bold" },
|
||||
{ label: "Location", key: "location" },
|
||||
{ label: "Magnitude", key: "magnitude", numeric: true },
|
||||
{ label: "Date", key: "date" },
|
||||
];
|
||||
|
||||
// todo modify slightly
|
||||
|
||||
export default function EarthquakeSearchModal({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (eq: Earthquake) => void;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (eq: Earthquake) => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState<string>("");
|
||||
const [results, setResults] = useState<Earthquake[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [search, setSearch] = useState<string>("");
|
||||
const [results, setResults] = useState<Earthquake[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
// Filters per column
|
||||
const [filters, setFilters] = useState<{ [k: string]: string }>({
|
||||
code: "",
|
||||
location: "",
|
||||
magnitude: "",
|
||||
date: "",
|
||||
});
|
||||
// Sort state
|
||||
const [sort, setSort] = useState<{ key: keyof Earthquake; dir: "asc" | "desc" } | null>(null);
|
||||
// Filters per column
|
||||
const [filters, setFilters] = useState<{ [k: string]: string }>({
|
||||
code: "",
|
||||
location: "",
|
||||
magnitude: "",
|
||||
date: "",
|
||||
});
|
||||
// Sort state
|
||||
const [sort, setSort] = useState<{ key: keyof Earthquake; dir: "asc" | "desc" } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearch("");
|
||||
setResults([]);
|
||||
setFilters({ code: "", location: "", magnitude: "", date: "" });
|
||||
setError("");
|
||||
setSort(null);
|
||||
}
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearch("");
|
||||
setResults([]);
|
||||
setFilters({ code: "", location: "", magnitude: "", date: "" });
|
||||
setError("");
|
||||
setSort(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const doSearch = async (q = search) => {
|
||||
setLoading(true);
|
||||
setResults([]);
|
||||
setError("");
|
||||
try {
|
||||
const resp = await axios.post("/api/earthquakes/search", { query: q });
|
||||
setResults(resp.data.earthquakes || []);
|
||||
} catch (e: any) {
|
||||
setError("Failed to search earthquakes.");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
const doSearch = async (q = search) => {
|
||||
setLoading(true);
|
||||
setResults([]);
|
||||
setError("");
|
||||
try {
|
||||
const resp = await axios.post("/api/earthquakes/search", { query: q });
|
||||
setResults(resp.data.earthquakes || []);
|
||||
} catch (e: any) {
|
||||
setError("Failed to search earthquakes.");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Filter logic
|
||||
const filteredRows = useMemo(() => {
|
||||
return results.filter((row) =>
|
||||
(!filters.code ||
|
||||
row.code.toLowerCase().includes(filters.code.toLowerCase())) &&
|
||||
(!filters.location ||
|
||||
(row.location || "").toLowerCase().includes(filters.location.toLowerCase())) &&
|
||||
(!filters.magnitude ||
|
||||
String(row.magnitude).startsWith(filters.magnitude)) &&
|
||||
(!filters.date ||
|
||||
row.date.slice(0, 10) === filters.date)
|
||||
);
|
||||
}, [results, filters]);
|
||||
// Filter logic
|
||||
const filteredRows = useMemo(() => {
|
||||
return results.filter(
|
||||
(row) =>
|
||||
(!filters.code || row.code.toLowerCase().includes(filters.code.toLowerCase())) &&
|
||||
(!filters.location || (row.location || "").toLowerCase().includes(filters.location.toLowerCase())) &&
|
||||
(!filters.magnitude || String(row.magnitude).startsWith(filters.magnitude)) &&
|
||||
(!filters.date || row.date.slice(0, 10) === filters.date)
|
||||
);
|
||||
}, [results, filters]);
|
||||
|
||||
// Sort logic
|
||||
const sortedRows = useMemo(() => {
|
||||
if (!sort) return filteredRows;
|
||||
const sorted = [...filteredRows].sort((a, b) => {
|
||||
let valA = a[sort.key];
|
||||
let valB = b[sort.key];
|
||||
if (sort.key === "magnitude") {
|
||||
valA = Number(valA);
|
||||
valB = Number(valB);
|
||||
} else if (sort.key === "date") {
|
||||
valA = a.date;
|
||||
valB = b.date;
|
||||
} else {
|
||||
valA = String(valA || "");
|
||||
valB = String(valB || "");
|
||||
}
|
||||
if (valA < valB) return sort.dir === "asc" ? -1 : 1;
|
||||
if (valA > valB) return sort.dir === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
return sorted;
|
||||
}, [filteredRows, sort]);
|
||||
// Sort logic
|
||||
const sortedRows = useMemo(() => {
|
||||
if (!sort) return filteredRows;
|
||||
const sorted = [...filteredRows].sort((a, b) => {
|
||||
let valA = a[sort.key];
|
||||
let valB = b[sort.key];
|
||||
if (sort.key === "magnitude") {
|
||||
valA = Number(valA);
|
||||
valB = Number(valB);
|
||||
} else if (sort.key === "date") {
|
||||
valA = a.date;
|
||||
valB = b.date;
|
||||
} else {
|
||||
valA = String(valA || "");
|
||||
valB = String(valB || "");
|
||||
}
|
||||
if (valA < valB) return sort.dir === "asc" ? -1 : 1;
|
||||
if (valA > valB) return sort.dir === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
return sorted;
|
||||
}, [filteredRows, sort]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center animate-fadein">
|
||||
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-2xl relative">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 text-2xl text-gray-400 hover:text-red-500 font-bold"
|
||||
>×</button>
|
||||
<h2 className="font-bold text-xl mb-4">Search Earthquakes</h2>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
doSearch();
|
||||
}}
|
||||
className="flex gap-2 mb-3"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Mexico, EV-7.4-Mexico-00035"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-grow px-3 py-2 border rounded"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 min-w-[6.5rem] flex items-center justify-center"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
|
||||
</svg>
|
||||
Search...
|
||||
</>
|
||||
) : (
|
||||
<>Search</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setResults([]);
|
||||
setFilters({ code: "", location: "", magnitude: "", date: "" });
|
||||
}}
|
||||
className="bg-gray-100 text-gray-600 px-3 py-2 rounded hover:bg-gray-200"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</form>
|
||||
{error && (
|
||||
<div className="text-red-600 font-medium mb-2">{error}</div>
|
||||
)}
|
||||
{/* Filter Row */}
|
||||
<div className="mb-2">
|
||||
<div className="flex gap-3">
|
||||
{COLUMNS.map((col) => (
|
||||
<input
|
||||
key={col.key}
|
||||
type={col.key === "magnitude" ? "number" : col.key === "date" ? "date" : "text"}
|
||||
value={filters[col.key] || ""}
|
||||
onChange={e =>
|
||||
setFilters(f => ({ ...f, [col.key]: e.target.value }))
|
||||
}
|
||||
className="border border-neutral-200 rounded px-2 py-1 text-xs"
|
||||
style={{
|
||||
width:
|
||||
col.key === "magnitude"
|
||||
? 70
|
||||
: col.key === "date"
|
||||
? 130
|
||||
: 120,
|
||||
}}
|
||||
placeholder={`Filter ${col.label}`}
|
||||
aria-label={`Filter ${col.label}`}
|
||||
disabled={loading || results.length === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Results Table */}
|
||||
<div className="border rounded shadow-inner bg-white max-h-72 overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-neutral-100 border-b">
|
||||
{COLUMNS.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`font-semibold px-3 py-2 cursor-pointer select-none text-left`}
|
||||
onClick={() =>
|
||||
setSort(sort && sort.key === col.key
|
||||
? { key: col.key as keyof Earthquake, dir: sort.dir === "asc" ? "desc" : "asc" }
|
||||
: { key: col.key as keyof Earthquake, dir: "asc" })
|
||||
}
|
||||
>
|
||||
{col.label}
|
||||
{sort?.key === col.key &&
|
||||
(sort.dir === "asc" ? " ↑" : " ↓")}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td colSpan={COLUMNS.length} className="p-3 text-center text-gray-400">
|
||||
No results found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{sortedRows.map(eq => (
|
||||
<tr
|
||||
key={eq.id}
|
||||
className="hover:bg-blue-50 cursor-pointer border-b"
|
||||
onClick={() => {
|
||||
onSelect(eq);
|
||||
onClose();
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
<td className="px-3 py-2 font-mono">{eq.code}</td>
|
||||
<td className="px-3 py-2">{eq.location}</td>
|
||||
<td className="px-3 py-2 font-bold">{eq.magnitude}</td>
|
||||
<td className="px-3 py-2">{formatDate(eq.date)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center animate-fadein">
|
||||
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-2xl relative">
|
||||
<button onClick={onClose} className="absolute right-4 top-4 text-2xl text-gray-400 hover:text-red-500 font-bold">
|
||||
×
|
||||
</button>
|
||||
<h2 className="font-bold text-xl mb-4">Search Earthquakes</h2>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
doSearch();
|
||||
}}
|
||||
className="flex gap-2 mb-3"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Mexico, EV-7.4-Mexico-00035"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-grow px-3 py-2 border rounded"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 min-w-[6.5rem] flex items-center justify-center"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
|
||||
</svg>
|
||||
Search...
|
||||
</>
|
||||
) : (
|
||||
<>Search</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setResults([]);
|
||||
setFilters({ code: "", location: "", magnitude: "", date: "" });
|
||||
}}
|
||||
className="bg-gray-100 text-gray-600 px-3 py-2 rounded hover:bg-gray-200"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</form>
|
||||
{error && <div className="text-red-600 font-medium mb-2">{error}</div>}
|
||||
{/* Filter Row */}
|
||||
<div className="mb-2">
|
||||
<div className="flex gap-3">
|
||||
{COLUMNS.map((col) => (
|
||||
<input
|
||||
key={col.key}
|
||||
type={col.key === "magnitude" ? "number" : col.key === "date" ? "date" : "text"}
|
||||
value={filters[col.key] || ""}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, [col.key]: e.target.value }))}
|
||||
className="border border-neutral-200 rounded px-2 py-1 text-xs"
|
||||
style={{
|
||||
width: col.key === "magnitude" ? 70 : col.key === "date" ? 130 : 120,
|
||||
}}
|
||||
placeholder={`Filter ${col.label}`}
|
||||
aria-label={`Filter ${col.label}`}
|
||||
disabled={loading || results.length === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Results Table */}
|
||||
<div className="border rounded shadow-inner bg-white max-h-72 overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-neutral-100 border-b">
|
||||
{COLUMNS.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`font-semibold px-3 py-2 cursor-pointer select-none text-left`}
|
||||
onClick={() =>
|
||||
setSort(
|
||||
sort && sort.key === col.key
|
||||
? { key: col.key as keyof Earthquake, dir: sort.dir === "asc" ? "desc" : "asc" }
|
||||
: { key: col.key as keyof Earthquake, dir: "asc" }
|
||||
)
|
||||
}
|
||||
>
|
||||
{col.label}
|
||||
{sort?.key === col.key && (sort.dir === "asc" ? " ↑" : " ↓")}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td colSpan={COLUMNS.length} className="p-3 text-center text-gray-400">
|
||||
No results found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{sortedRows.map((eq) => (
|
||||
<tr
|
||||
key={eq.id}
|
||||
className="hover:bg-blue-50 cursor-pointer border-b"
|
||||
onClick={() => {
|
||||
onSelect(eq);
|
||||
onClose();
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
<td className="px-3 py-2 font-mono">{eq.code}</td>
|
||||
<td className="px-3 py-2">{eq.location}</td>
|
||||
<td className="px-3 py-2 font-bold">{eq.magnitude}</td>
|
||||
<td className="px-3 py-2">{formatDate(eq.date)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user