Search function
This commit is contained in:
commit
6cfef6fe6a
BIN
public/Athena.PNG
Normal file
BIN
public/Athena.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 589 KiB |
BIN
public/StuartEnthusiast.PNG
Normal file
BIN
public/StuartEnthusiast.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
BIN
public/stuart.PNG
Normal file
BIN
public/stuart.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 673 KiB |
BIN
public/team.PNG
Normal file
BIN
public/team.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
46
src/app/api/earthquakes/search/route.ts
Normal file
46
src/app/api/earthquakes/search/route.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { query } = await req.json();
|
||||||
|
|
||||||
|
// Nothing to search
|
||||||
|
if (!query || typeof query !== "string" || query.trim().length === 0) {
|
||||||
|
// Return recent earthquakes if no search string
|
||||||
|
const earthquakes = await prisma.earthquake.findMany({
|
||||||
|
orderBy: { date: "desc" },
|
||||||
|
take: 30,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ earthquakes });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple search: code, location, magnitude (add more fields as desired)
|
||||||
|
const q = query.trim();
|
||||||
|
|
||||||
|
const earthquakes = await prisma.earthquake.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ code: { contains: q, } },
|
||||||
|
{ location: { contains: q, } },
|
||||||
|
{
|
||||||
|
magnitude: Number.isNaN(Number(q))
|
||||||
|
? undefined
|
||||||
|
: Number(q),
|
||||||
|
},
|
||||||
|
// optionally add more fields
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { date: "desc" },
|
||||||
|
take: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ earthquakes });
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Earthquake search error:", e);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to search earthquakes." },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,11 +7,11 @@ import { createPoster } from "@utils/axiosHelpers";
|
|||||||
import { Earthquake } from "@prismaclient";
|
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 EarthquakeSearchModal from "@components/EarthquakeSearchModal";
|
||||||
import EarthquakeLogModal from "@components/EarthquakeLogModal";
|
import EarthquakeLogModal from "@components/EarthquakeLogModal"; // If you use a separate log modal
|
||||||
import { useStoreState } from "@hooks/store";
|
import { useStoreState } from "@hooks/store";
|
||||||
|
|
||||||
// --- NO ACCESS MODAL ---
|
// Optional: "No Access Modal" - as in your original
|
||||||
function NoAccessModal({ open, onClose }) {
|
function NoAccessModal({ open, onClose }) {
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
return (
|
return (
|
||||||
@ -21,9 +21,13 @@ function NoAccessModal({ open, onClose }) {
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg"
|
className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>×</button>
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
<h2 className="font-bold text-xl mb-4">Access Denied</h2>
|
<h2 className="font-bold text-xl mb-4">Access Denied</h2>
|
||||||
<p className="text-gray-600 mb-3">Sorry, you do not have access rights to Log an Earthquake. Please Log in here, or contact an Admin if you believe this is a mistake</p>
|
<p className="text-gray-600 mb-3">
|
||||||
|
Sorry, you do not have access rights to Log an Earthquake. Please Log in here, or contact an Admin if you believe this is a mistake
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 mt-2"
|
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 mt-2"
|
||||||
@ -33,78 +37,7 @@ function NoAccessModal({ open, onClose }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SEARCH MODAL COMPONENT ---
|
|
||||||
function EarthquakeSearchModal({ open, onClose, onSelect }) {
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [results, setResults] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const handleSearch = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setLoading(true);
|
|
||||||
setResults([]);
|
|
||||||
try {
|
|
||||||
const res = await axios.post("/api/earthquakes/search", { query: search });
|
|
||||||
setResults(res.data.earthquakes || []);
|
|
||||||
} catch (e) {
|
|
||||||
alert("Failed to search.");
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
if (!open) return null;
|
|
||||||
return (
|
|
||||||
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
|
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6 max-w-lg w-full relative">
|
|
||||||
<button onClick={onClose} className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg">
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
<h2 className="font-bold text-xl mb-4">Search Earthquakes</h2>
|
|
||||||
<form onSubmit={handleSearch} className="flex gap-2 mb-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g. Mexico, EV-7.4-Mexico-00035"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
className="flex-grow px-3 py-2 border rounded"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
|
|
||||||
{loading ? "Searching..." : "Search"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<div>
|
|
||||||
{results.length === 0 && !loading && search !== "" && <p className="text-gray-400 text-sm">No results found.</p>}
|
|
||||||
<ul>
|
|
||||||
{results.map((eq) => (
|
|
||||||
<li
|
|
||||||
key={eq.id}
|
|
||||||
className="border-b py-2 cursor-pointer hover:bg-gray-50 flex items-center justify-between"
|
|
||||||
onClick={() => {
|
|
||||||
onSelect(eq);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<strong>{eq.code}</strong> <span>{eq.location}</span>{" "}
|
|
||||||
<span className="text-xs text-gray-500">{new Date(eq.date).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`rounded-full px-2 py-1 ml-2 text-white font-semibold ${
|
|
||||||
eq.magnitude >= 7 ? "bg-red-500" : eq.magnitude >= 6 ? "bg-orange-400" : "bg-yellow-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{eq.magnitude}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- MAIN PAGE COMPONENT ---
|
|
||||||
export default function Earthquakes() {
|
export default function Earthquakes() {
|
||||||
const [selectedEventId, setSelectedEventId] = useState("");
|
const [selectedEventId, setSelectedEventId] = useState("");
|
||||||
const [hoveredEventId, setHoveredEventId] = useState("");
|
const [hoveredEventId, setHoveredEventId] = useState("");
|
||||||
@ -112,14 +45,18 @@ export default function Earthquakes() {
|
|||||||
const [logModalOpen, setLogModalOpen] = useState(false);
|
const [logModalOpen, setLogModalOpen] = useState(false);
|
||||||
const [noAccessModalOpen, setNoAccessModalOpen] = useState(false);
|
const [noAccessModalOpen, setNoAccessModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Your user/role logic
|
||||||
const user = useStoreState((state) => state.user);
|
const user = useStoreState((state) => state.user);
|
||||||
const role: "GUEST" | "SCIENTIST" | "ADMIN" = user?.role ?? "GUEST";
|
const role: "GUEST" | "SCIENTIST" | "ADMIN" = user?.role ?? "GUEST";
|
||||||
const canLogEarthquake = role === "SCIENTIST" || role === "ADMIN";
|
const canLogEarthquake = role === "SCIENTIST" || role === "ADMIN";
|
||||||
|
|
||||||
// Fetch recent earthquakes
|
// Fetch earthquakes (10 days recent)
|
||||||
const { data, error, isLoading, mutate } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 10 }));
|
const { data, error, isLoading, mutate } = useSWR(
|
||||||
|
"/api/earthquakes",
|
||||||
|
createPoster({ rangeDaysPrev: 10 })
|
||||||
|
);
|
||||||
|
|
||||||
// Prepare events
|
// Shape for Map/Sidebar
|
||||||
const earthquakeEvents = useMemo(
|
const earthquakeEvents = useMemo(
|
||||||
() =>
|
() =>
|
||||||
data && data.earthquakes
|
data && data.earthquakes
|
||||||
@ -136,12 +73,15 @@ export default function Earthquakes() {
|
|||||||
date: x.date,
|
date: x.date,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.sort((a: GeologicalEvent, b: GeologicalEvent) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
.sort(
|
||||||
|
(a: GeologicalEvent, b: GeologicalEvent) =>
|
||||||
|
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
|
)
|
||||||
: [],
|
: [],
|
||||||
[data]
|
[data]
|
||||||
);
|
);
|
||||||
|
|
||||||
// This handler is always called, regardless of button state!
|
// Handler for log
|
||||||
const handleLogClick = () => {
|
const handleLogClick = () => {
|
||||||
if (canLogEarthquake) {
|
if (canLogEarthquake) {
|
||||||
setLogModalOpen(true);
|
setLogModalOpen(true);
|
||||||
@ -173,20 +113,23 @@ export default function Earthquakes() {
|
|||||||
setHoveredEventId={setHoveredEventId}
|
setHoveredEventId={setHoveredEventId}
|
||||||
button1Name="Log an Earthquake"
|
button1Name="Log an Earthquake"
|
||||||
button2Name="Search Earthquakes"
|
button2Name="Search Earthquakes"
|
||||||
onButton1Click={handleLogClick} // <--- Important!
|
onButton1Click={handleLogClick}
|
||||||
onButton2Click={() => setSearchModalOpen(true)}
|
onButton2Click={() => setSearchModalOpen(true)}
|
||||||
button1Disabled={!canLogEarthquake} // <--- For style only!
|
button1Disabled={!canLogEarthquake}
|
||||||
/>
|
/>
|
||||||
|
{/* ---- SEARCH MODAL ---- */}
|
||||||
<EarthquakeSearchModal
|
<EarthquakeSearchModal
|
||||||
open={searchModalOpen}
|
open={searchModalOpen}
|
||||||
onClose={() => setSearchModalOpen(false)}
|
onClose={() => setSearchModalOpen(false)}
|
||||||
onSelect={(eq) => setSelectedEventId(eq.code)}
|
onSelect={(eq) => setSelectedEventId(eq.code)}
|
||||||
/>
|
/>
|
||||||
|
{/* ---- LOGGING MODAL ---- */}
|
||||||
<EarthquakeLogModal
|
<EarthquakeLogModal
|
||||||
open={logModalOpen}
|
open={logModalOpen}
|
||||||
onClose={() => setLogModalOpen(false)}
|
onClose={() => setLogModalOpen(false)}
|
||||||
onSuccess={() => mutate()} // To refresh
|
onSuccess={() => mutate()}
|
||||||
/>
|
/>
|
||||||
|
{/* ---- NO ACCESS ---- */}
|
||||||
<NoAccessModal
|
<NoAccessModal
|
||||||
open={noAccessModalOpen}
|
open={noAccessModalOpen}
|
||||||
onClose={() => setNoAccessModalOpen(false)}
|
onClose={() => setNoAccessModalOpen(false)}
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { prisma } from "@utils/prisma";
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
try {
|
|
||||||
const { query } = await req.json();
|
|
||||||
|
|
||||||
// Find earthquakes where either code or location matches (case-insensitive)
|
|
||||||
const earthquakes = await prisma.earthquake.findMany({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ code: { contains: query, mode: "insensitive" } },
|
|
||||||
{ location: { contains: query, mode: "insensitive" } }
|
|
||||||
],
|
|
||||||
},
|
|
||||||
orderBy: { date: "desc" },
|
|
||||||
take: 20, // limit results
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ earthquakes, message: "Success" });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error in earthquake search", error);
|
|
||||||
return NextResponse.json({ message: "Internal Server Error" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -206,7 +206,7 @@ export default function Home() {
|
|||||||
<section style={{ height: 500 }} className="text-black">
|
<section style={{ height: 500 }} className="text-black">
|
||||||
<div className="w-full relative overflow-hidden z=10">
|
<div className="w-full relative overflow-hidden z=10">
|
||||||
<div>
|
<div>
|
||||||
<Image height={1000} width={2000} alt="Background Image" src="/scientists.png" />
|
<Image height={800} width={1500} alt="Background Image" src="/team.PNG" />
|
||||||
</div>
|
</div>
|
||||||
<BottomFooter />
|
<BottomFooter />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import BottomFooter from "@components/BottomFooter";
|
import BottomFooter from "@components/BottomFooter";
|
||||||
const teamMembers = [
|
const teamMembers = [
|
||||||
|
|
||||||
{
|
{
|
||||||
name: "Tim Howitz",
|
name: "Tim Howitz",
|
||||||
title: "Chief Crack Inspector",
|
title: "Chief Crack Inspector",
|
||||||
@ -29,6 +30,20 @@ const teamMembers = [
|
|||||||
"Lukeshan and his team look at the dust particles created by an earthquake and how to minimise their damage. For pleasure, he likes to play Monopoly on repeat. Maybe one day he'll get a hotel!",
|
"Lukeshan and his team look at the dust particles created by an earthquake and how to minimise their damage. For pleasure, he likes to play Monopoly on repeat. Maybe one day he'll get a hotel!",
|
||||||
image: "/Lukeshanthescientist.PNG",
|
image: "/Lukeshanthescientist.PNG",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "Stuart Nicholson",
|
||||||
|
title: "Chief Earthquake Enthusiast",
|
||||||
|
description:
|
||||||
|
"Stuart is an avid earthquake enthusiast interested in their origins and humanitarian efforts. In his home life likes to sing karaoke to shake it off.",
|
||||||
|
image: "/stuart.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Athena",
|
||||||
|
title: "Chief Software Engineer",
|
||||||
|
description: "Athena is responsible for making all software dreams come true. <3",
|
||||||
|
image: "/athena.PNG",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
@ -77,7 +92,6 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer goes OUTSIDE the flex/padded container, so it spans full width */}
|
|
||||||
<BottomFooter />
|
<BottomFooter />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
245
src/components/EarthquakeSearchModal.tsx
Normal file
245
src/components/EarthquakeSearchModal.tsx
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export type Earthquake = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
magnitude: number;
|
||||||
|
location: string;
|
||||||
|
date: string;
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS = [
|
||||||
|
{ label: "Code", key: "code", className: "font-mono font-bold" },
|
||||||
|
{ label: "Location", key: "location" },
|
||||||
|
{ label: "Magnitude", key: "magnitude", numeric: true },
|
||||||
|
{ label: "Date", key: "date" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function EarthquakeSearchModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (eq: Earthquake) => void;
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = useState<string>("");
|
||||||
|
const [results, setResults] = useState<Earthquake[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>("");
|
||||||
|
|
||||||
|
// Filters per column
|
||||||
|
const [filters, setFilters] = useState<{ [k: string]: string }>({
|
||||||
|
code: "",
|
||||||
|
location: "",
|
||||||
|
magnitude: "",
|
||||||
|
date: "",
|
||||||
|
});
|
||||||
|
// Sort state
|
||||||
|
const [sort, setSort] = useState<{ key: keyof Earthquake; dir: "asc" | "desc" } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSearch("");
|
||||||
|
setResults([]);
|
||||||
|
setFilters({ code: "", location: "", magnitude: "", date: "" });
|
||||||
|
setError("");
|
||||||
|
setSort(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const doSearch = async (q = search) => {
|
||||||
|
setLoading(true);
|
||||||
|
setResults([]);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const resp = await axios.post("/api/earthquakes/search", { query: q });
|
||||||
|
setResults(resp.data.earthquakes || []);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError("Failed to search earthquakes.");
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter logic
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
return results.filter((row) =>
|
||||||
|
(!filters.code ||
|
||||||
|
row.code.toLowerCase().includes(filters.code.toLowerCase())) &&
|
||||||
|
(!filters.location ||
|
||||||
|
(row.location || "").toLowerCase().includes(filters.location.toLowerCase())) &&
|
||||||
|
(!filters.magnitude ||
|
||||||
|
String(row.magnitude).startsWith(filters.magnitude)) &&
|
||||||
|
(!filters.date ||
|
||||||
|
row.date.slice(0, 10) === filters.date)
|
||||||
|
);
|
||||||
|
}, [results, filters]);
|
||||||
|
|
||||||
|
// Sort logic
|
||||||
|
const sortedRows = useMemo(() => {
|
||||||
|
if (!sort) return filteredRows;
|
||||||
|
const sorted = [...filteredRows].sort((a, b) => {
|
||||||
|
let valA = a[sort.key];
|
||||||
|
let valB = b[sort.key];
|
||||||
|
if (sort.key === "magnitude") {
|
||||||
|
valA = Number(valA);
|
||||||
|
valB = Number(valB);
|
||||||
|
} else if (sort.key === "date") {
|
||||||
|
valA = a.date;
|
||||||
|
valB = b.date;
|
||||||
|
} else {
|
||||||
|
valA = String(valA || "");
|
||||||
|
valB = String(valB || "");
|
||||||
|
}
|
||||||
|
if (valA < valB) return sort.dir === "asc" ? -1 : 1;
|
||||||
|
if (valA > valB) return sort.dir === "asc" ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}, [filteredRows, sort]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center animate-fadein">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-2xl relative">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-4 top-4 text-2xl text-gray-400 hover:text-red-500 font-bold"
|
||||||
|
>×</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/lib/prisma.ts
Normal file
12
src/lib/prisma.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// src/lib/prisma.ts
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const globalForPrisma = global as unknown as { prisma: PrismaClient };
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ||
|
||||||
|
new PrismaClient({
|
||||||
|
log: ["query", "error", "warn"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||||
@ -1,9 +1,13 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"moduleResolution": "node", // Use "node" module resolution strategy
|
"moduleResolution": "node",
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@ -14,21 +18,43 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
|
"baseUrl": "src",
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@components/*": ["./src/components/*"],
|
"@components/*": [
|
||||||
"@hooks/*": ["./src/hooks/*"],
|
"./components/*"
|
||||||
"@utils/*": ["./src/utils/*"],
|
],
|
||||||
"@appTypes/*": ["./src/types/*"],
|
"@hooks/*": [
|
||||||
"@zod/*": ["./src/zod/*"],
|
"./hooks/*"
|
||||||
"@prismaclient": ["./src/generated/prisma/client"],
|
],
|
||||||
"@/*": ["./src/*"]
|
"@utils/*": [
|
||||||
|
"./utils/*"
|
||||||
|
],
|
||||||
|
"@appTypes/*": [
|
||||||
|
"./types/*"
|
||||||
|
],
|
||||||
|
"@zod/*": [
|
||||||
|
"./zod/*"
|
||||||
|
],
|
||||||
|
"@prismaclient": [
|
||||||
|
"./generated/prisma/client"
|
||||||
|
],
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user