diff --git a/public/Athena.PNG b/public/Athena.PNG new file mode 100644 index 0000000..f99dba2 Binary files /dev/null and b/public/Athena.PNG differ diff --git a/public/StuartEnthusiast.PNG b/public/StuartEnthusiast.PNG new file mode 100644 index 0000000..5fdf34d Binary files /dev/null and b/public/StuartEnthusiast.PNG differ diff --git a/public/stuart.PNG b/public/stuart.PNG new file mode 100644 index 0000000..087812f Binary files /dev/null and b/public/stuart.PNG differ diff --git a/public/team.PNG b/public/team.PNG new file mode 100644 index 0000000..8e990a4 Binary files /dev/null and b/public/team.PNG differ diff --git a/src/app/api/earthquakes/search/route.ts b/src/app/api/earthquakes/search/route.ts new file mode 100644 index 0000000..70a7592 --- /dev/null +++ b/src/app/api/earthquakes/search/route.ts @@ -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 } + ); + } +} \ No newline at end of file diff --git a/src/app/earthquakes/page.tsx b/src/app/earthquakes/page.tsx index ec92374..45e3d8f 100644 --- a/src/app/earthquakes/page.tsx +++ b/src/app/earthquakes/page.tsx @@ -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 ( -
-
- -

Access Denied

-

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

- -
-
- ); + if (!open) return null; + return ( +
+
+ +

Access Denied

+

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

+ +
+
+ ); } -// --- SEARCH MODAL COMPONENT --- -function EarthquakeSearchModal({ open, onClose, onSelect }) { - const [search, setSearch] = useState(""); - const [results, setResults] = useState([]); - const [loading, setLoading] = useState(false); - const handleSearch = async (e) => { - e.preventDefault(); - setLoading(true); - setResults([]); - try { - const res = await axios.post("/api/earthquakes/search", { query: search }); - setResults(res.data.earthquakes || []); - } catch (e) { - alert("Failed to search."); - } - setLoading(false); - }; - if (!open) return null; - return ( -
-
- -

Search Earthquakes

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

No results found.

} -
    - {results.map((eq) => ( -
  • { - onSelect(eq); - onClose(); - }} - tabIndex={0} - > -
    - {eq.code} {eq.location}{" "} - {new Date(eq.date).toLocaleDateString()} -
    -
    = 7 ? "bg-red-500" : eq.magnitude >= 6 ? "bg-orange-400" : "bg-yellow-400" - }`} - > - {eq.magnitude} -
    -
  • - ))} -
-
-
-
- ); -} -// --- 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 ( -
-
- -
- setSearchModalOpen(true)} - button1Disabled={!canLogEarthquake} // <--- For style only! - /> - setSearchModalOpen(false)} - onSelect={(eq) => setSelectedEventId(eq.code)} - /> - setLogModalOpen(false)} - onSuccess={() => mutate()} // To refresh - /> - setNoAccessModalOpen(false)} - /> -
- ); + return ( +
+
+ +
+ setSearchModalOpen(true)} + button1Disabled={!canLogEarthquake} + /> + {/* ---- SEARCH MODAL ---- */} + setSearchModalOpen(false)} + onSelect={(eq) => setSelectedEventId(eq.code)} + /> + {/* ---- LOGGING MODAL ---- */} + setLogModalOpen(false)} + onSuccess={() => mutate()} + /> + {/* ---- NO ACCESS ---- */} + setNoAccessModalOpen(false)} + /> +
+ ); } \ No newline at end of file diff --git a/src/app/earthquakes/search/route.ts b/src/app/earthquakes/search/route.ts deleted file mode 100644 index 48ca6c9..0000000 --- a/src/app/earthquakes/search/route.ts +++ /dev/null @@ -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 }); - } -} \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 12abff7..0523716 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -206,7 +206,7 @@ export default function Home() {
- Background Image + Background Image
diff --git a/src/app/the-team/page.tsx b/src/app/the-team/page.tsx index f26bddd..ab43e28 100644 --- a/src/app/the-team/page.tsx +++ b/src/app/the-team/page.tsx @@ -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() { - {/* Footer goes OUTSIDE the flex/padded container, so it spans full width */} ); diff --git a/src/components/EarthquakeSearchModal.tsx b/src/components/EarthquakeSearchModal.tsx new file mode 100644 index 0000000..d433bd0 --- /dev/null +++ b/src/components/EarthquakeSearchModal.tsx @@ -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(""); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + // 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 ( +
+
+ +

Search Earthquakes

+
{ + e.preventDefault(); + doSearch(); + }} + className="flex gap-2 mb-3" + > + setSearch(e.target.value)} + className="flex-grow px-3 py-2 border rounded" + disabled={loading} + /> + + +
+ {error && ( +
{error}
+ )} + {/* Filter Row */} +
+
+ {COLUMNS.map((col) => ( + + 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} + /> + ))} +
+
+ {/* Results Table */} +
+ + + + {COLUMNS.map((col) => ( + + ))} + + + + {sortedRows.length === 0 && !loading && ( + + + + )} + {sortedRows.map(eq => ( + { + onSelect(eq); + onClose(); + }} + tabIndex={0} + > + + + + + + ))} + +
+ 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" ? " ↑" : " ↓")} +
+ No results found. +
{eq.code}{eq.location}{eq.magnitude}{formatDate(eq.date)}
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..a6efed3 --- /dev/null +++ b/src/lib/prisma.ts @@ -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; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 36eff5f..debe833 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" + ] +} \ No newline at end of file