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,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"
>&times;</button> >
&times;
</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">
&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() { 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)}

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"> <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>

View File

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

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,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"
]
} }