Search function

This commit is contained in:
IZZY 2025-05-31 23:25:51 +01:00
commit 6cfef6fe6a
12 changed files with 495 additions and 234 deletions

BIN
public/Athena.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

BIN
public/StuartEnthusiast.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

BIN
public/stuart.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

BIN
public/team.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View 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 }
);
}
}

View File

@ -7,190 +7,133 @@ import { createPoster } from "@utils/axiosHelpers";
import { Earthquake } from "@prismaclient";
import { getRelativeDate } from "@utils/formatters";
import GeologicalEvent from "@appTypes/Event";
import axios from "axios";
import EarthquakeLogModal from "@components/EarthquakeLogModal";
import EarthquakeSearchModal from "@components/EarthquakeSearchModal";
import EarthquakeLogModal from "@components/EarthquakeLogModal"; // If you use a separate log modal
import { useStoreState } from "@hooks/store";
// --- NO ACCESS MODAL ---
// Optional: "No Access Modal" - as in your original
function NoAccessModal({ open, onClose }) {
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-8 max-w-xs w-full text-center relative">
<button
onClick={onClose}
className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg"
aria-label="Close"
>&times;</button>
<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>
<button
onClick={onClose}
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 mt-2"
>OK</button>
</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-8 max-w-xs w-full text-center relative">
<button
onClick={onClose}
className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg"
aria-label="Close"
>
&times;
</button>
<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>
<button
onClick={onClose}
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 mt-2"
>OK</button>
</div>
</div>
);
}
// --- 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">
&times;
</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() {
const [selectedEventId, setSelectedEventId] = useState("");
const [hoveredEventId, setHoveredEventId] = useState("");
const [searchModalOpen, setSearchModalOpen] = useState(false);
const [logModalOpen, setLogModalOpen] = useState(false);
const [noAccessModalOpen, setNoAccessModalOpen] = useState(false);
const [selectedEventId, setSelectedEventId] = useState("");
const [hoveredEventId, setHoveredEventId] = useState("");
const [searchModalOpen, setSearchModalOpen] = useState(false);
const [logModalOpen, setLogModalOpen] = useState(false);
const [noAccessModalOpen, setNoAccessModalOpen] = useState(false);
const user = useStoreState((state) => state.user);
const role: "GUEST" | "SCIENTIST" | "ADMIN" = user?.role ?? "GUEST";
const canLogEarthquake = role === "SCIENTIST" || role === "ADMIN";
// Your user/role logic
const user = useStoreState((state) => state.user);
const role: "GUEST" | "SCIENTIST" | "ADMIN" = user?.role ?? "GUEST";
const canLogEarthquake = role === "SCIENTIST" || role === "ADMIN";
// Fetch recent earthquakes
const { data, error, isLoading, mutate } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 10 }));
// Fetch earthquakes (10 days recent)
const { data, error, isLoading, mutate } = useSWR(
"/api/earthquakes",
createPoster({ rangeDaysPrev: 10 })
);
// Prepare events
const earthquakeEvents = useMemo(
() =>
data && data.earthquakes
? data.earthquakes
.map(
(x: Earthquake): GeologicalEvent => ({
id: x.code,
title: `Earthquake in ${x.location || (x.code && 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]
);
// Shape for Map/Sidebar
const earthquakeEvents = useMemo(
() =>
data && data.earthquakes
? data.earthquakes
.map(
(x: Earthquake): GeologicalEvent => ({
id: x.code,
title: `Earthquake in ${x.location || (x.code && 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]
);
// This handler is always called, regardless of button state!
const handleLogClick = () => {
if (canLogEarthquake) {
setLogModalOpen(true);
} else {
setNoAccessModalOpen(true);
}
};
// Handler for log
const handleLogClick = () => {
if (canLogEarthquake) {
setLogModalOpen(true);
} else {
setNoAccessModalOpen(true);
}
};
return (
<div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden">
<div className="flex-grow h-full">
<Map
events={earthquakeEvents}
selectedEventId={selectedEventId}
setSelectedEventId={setSelectedEventId}
hoveredEventId={hoveredEventId}
setHoveredEventId={setHoveredEventId}
mapType="Earthquakes"
/>
</div>
<Sidebar
logTitle="Log an Earthquake"
logSubtitle="Record new earthquakes - time/date, location, magnitude, observatory and scientists"
recentsTitle="Recent Earthquakes"
events={earthquakeEvents}
selectedEventId={selectedEventId}
setSelectedEventId={setSelectedEventId}
hoveredEventId={hoveredEventId}
setHoveredEventId={setHoveredEventId}
button1Name="Log an Earthquake"
button2Name="Search Earthquakes"
onButton1Click={handleLogClick} // <--- Important!
onButton2Click={() => setSearchModalOpen(true)}
button1Disabled={!canLogEarthquake} // <--- For style only!
/>
<EarthquakeSearchModal
open={searchModalOpen}
onClose={() => setSearchModalOpen(false)}
onSelect={(eq) => setSelectedEventId(eq.code)}
/>
<EarthquakeLogModal
open={logModalOpen}
onClose={() => setLogModalOpen(false)}
onSuccess={() => mutate()} // To refresh
/>
<NoAccessModal
open={noAccessModalOpen}
onClose={() => setNoAccessModalOpen(false)}
/>
</div>
);
return (
<div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden">
<div className="flex-grow h-full">
<Map
events={earthquakeEvents}
selectedEventId={selectedEventId}
setSelectedEventId={setSelectedEventId}
hoveredEventId={hoveredEventId}
setHoveredEventId={setHoveredEventId}
mapType="Earthquakes"
/>
</div>
<Sidebar
logTitle="Log an Earthquake"
logSubtitle="Record new earthquakes - time/date, location, magnitude, observatory and scientists"
recentsTitle="Recent Earthquakes"
events={earthquakeEvents}
selectedEventId={selectedEventId}
setSelectedEventId={setSelectedEventId}
hoveredEventId={hoveredEventId}
setHoveredEventId={setHoveredEventId}
button1Name="Log an Earthquake"
button2Name="Search Earthquakes"
onButton1Click={handleLogClick}
onButton2Click={() => setSearchModalOpen(true)}
button1Disabled={!canLogEarthquake}
/>
{/* ---- SEARCH MODAL ---- */}
<EarthquakeSearchModal
open={searchModalOpen}
onClose={() => setSearchModalOpen(false)}
onSelect={(eq) => setSelectedEventId(eq.code)}
/>
{/* ---- LOGGING MODAL ---- */}
<EarthquakeLogModal
open={logModalOpen}
onClose={() => setLogModalOpen(false)}
onSuccess={() => mutate()}
/>
{/* ---- NO ACCESS ---- */}
<NoAccessModal
open={noAccessModalOpen}
onClose={() => setNoAccessModalOpen(false)}
/>
</div>
);
}

View File

@ -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 });
}
}

View File

@ -206,7 +206,7 @@ export default function Home() {
<section style={{ height: 500 }} className="text-black">
<div className="w-full relative overflow-hidden z=10">
<div>
<Image height={1000} width={2000} alt="Background Image" src="/scientists.png" />
<Image height={800} width={1500} alt="Background Image" src="/team.PNG" />
</div>
<BottomFooter />
</div>

View File

@ -1,6 +1,7 @@
"use client";
import BottomFooter from "@components/BottomFooter";
const teamMembers = [
{
name: "Tim Howitz",
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!",
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() {
@ -77,7 +92,6 @@ export default function Page() {
</div>
</div>
{/* Footer goes OUTSIDE the flex/padded container, so it spans full width */}
<BottomFooter />
</>
);

View 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"
>&times;</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
View 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;

View File

@ -1,34 +1,60 @@
{
"compilerOptions": {
"moduleResolution": "node", // Use "node" module resolution strategy
"forceConsistentCasingInFileNames": true,
"target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "ESNext",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@components/*": ["./src/components/*"],
"@hooks/*": ["./src/hooks/*"],
"@utils/*": ["./src/utils/*"],
"@appTypes/*": ["./src/types/*"],
"@zod/*": ["./src/zod/*"],
"@prismaclient": ["./src/generated/prisma/client"],
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
"compilerOptions": {
"moduleResolution": "node",
"forceConsistentCasingInFileNames": true,
"target": "ESNext",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "ESNext",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": "src",
"plugins": [
{
"name": "next"
}
],
"paths": {
"@components/*": [
"./components/*"
],
"@hooks/*": [
"./hooks/*"
],
"@utils/*": [
"./utils/*"
],
"@appTypes/*": [
"./types/*"
],
"@zod/*": [
"./zod/*"
],
"@prismaclient": [
"./generated/prisma/client"
],
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}