Observatory search page!
This commit is contained in:
parent
19253672dc
commit
fd77ceae88
@ -3,7 +3,8 @@ import { useState, useMemo } from "react";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import Sidebar from "@/components/Sidebar";
|
import Sidebar from "@/components/Sidebar";
|
||||||
import Map from "@components/Map";
|
import Map from "@components/Map";
|
||||||
import LogObservatoryModal from "@/components/LogObservatoryModal"; // Adjust if your path is different
|
import LogObservatoryModal from "@/components/LogObservatoryModal";
|
||||||
|
import SearchObservatoriesModal from "@/components/SearchObservatoriesModal"; // <-- add this import
|
||||||
import { fetcher } from "@utils/axiosHelpers";
|
import { fetcher } from "@utils/axiosHelpers";
|
||||||
import { Observatory } from "@prismaclient";
|
import { Observatory } from "@prismaclient";
|
||||||
import { getRelativeDate } from "@utils/formatters";
|
import { getRelativeDate } from "@utils/formatters";
|
||||||
@ -11,90 +12,96 @@ import GeologicalEvent from "@appTypes/GeologicalEvent";
|
|||||||
import { useStoreState } from "@hooks/store";
|
import { useStoreState } from "@hooks/store";
|
||||||
|
|
||||||
function NoAccessModal({ open, onClose }) {
|
function NoAccessModal({ open, onClose }) {
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
return (
|
return (
|
||||||
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
|
<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">
|
<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">
|
<button onClick={onClose} className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg" aria-label="Close">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
<h2 className="font-bold text-xl mb-4">No Access</h2>
|
<h2 className="font-bold text-xl mb-4">No Access</h2>
|
||||||
<p className="text-gray-600 mb-3">Sorry, You do not have access rights, please log in or contact an Admin.</p>
|
<p className="text-gray-600 mb-3">Sorry, You do not have access rights, please log in or contact an Admin.</p>
|
||||||
<button onClick={onClose} className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 mt-2">
|
<button onClick={onClose} className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 mt-2">
|
||||||
OK
|
OK
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Observatories() {
|
export default function Observatories() {
|
||||||
const [selectedEventId, setSelectedEventId] = useState("");
|
const [selectedEventId, setSelectedEventId] = useState("");
|
||||||
const [hoveredEventId, setHoveredEventId] = useState("");
|
const [hoveredEventId, setHoveredEventId] = useState("");
|
||||||
const [logModalOpen, setLogModalOpen] = useState(false);
|
const [logModalOpen, setLogModalOpen] = useState(false);
|
||||||
const [noAccessModalOpen, setNoAccessModalOpen] = useState(false);
|
const [noAccessModalOpen, setNoAccessModalOpen] = useState(false);
|
||||||
|
const [searchModalOpen, setSearchModalOpen] = useState(false); // <-- NEW STATE
|
||||||
|
|
||||||
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 canLogObservatory = role === "SCIENTIST" || role === "ADMIN";
|
const canLogObservatory = role === "SCIENTIST" || role === "ADMIN";
|
||||||
|
const { data, error, isLoading, mutate } = useSWR("/api/observatories", fetcher);
|
||||||
|
|
||||||
const { data, error, isLoading, mutate } = useSWR("/api/observatories", fetcher);
|
const observatoryEvents = useMemo(
|
||||||
|
() =>
|
||||||
|
data && data.observatories
|
||||||
|
? data.observatories
|
||||||
|
.map((x: Observatory): GeologicalEvent & { isFunctional: boolean } => ({
|
||||||
|
id: x.id.toString(),
|
||||||
|
title: ` ${x.name}`,
|
||||||
|
longitude: x.longitude,
|
||||||
|
latitude: x.latitude,
|
||||||
|
isFunctional: !!x.isFunctional, // if isFunctional = 1/0, coerce to boolean
|
||||||
|
text1: "",
|
||||||
|
text2: getRelativeDate(x.dateEstablished),
|
||||||
|
date: x.dateEstablished,
|
||||||
|
}))
|
||||||
|
.sort((a: GeologicalEvent, b: GeologicalEvent) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||||
|
: [],
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
|
||||||
const observatoryEvents = useMemo(
|
const handleLogClick = () => {
|
||||||
() =>
|
if (canLogObservatory) {
|
||||||
data && data.observatories
|
setLogModalOpen(true);
|
||||||
? data.observatories
|
} else {
|
||||||
.map((x: Observatory): GeologicalEvent & { isFunctional: boolean } => ({
|
setNoAccessModalOpen(true);
|
||||||
id: x.id.toString(),
|
}
|
||||||
title: ` ${x.name}`,
|
};
|
||||||
longitude: x.longitude,
|
|
||||||
latitude: x.latitude,
|
|
||||||
isFunctional: x.isFunctional, // <-- include this!
|
|
||||||
text1: "",
|
|
||||||
text2: getRelativeDate(x.dateEstablished),
|
|
||||||
date: x.dateEstablished,
|
|
||||||
}))
|
|
||||||
.sort((a: GeologicalEvent, b: GeologicalEvent) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
|
||||||
: [],
|
|
||||||
[data]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleLogClick = () => {
|
return (
|
||||||
if (canLogObservatory) {
|
<div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden">
|
||||||
setLogModalOpen(true);
|
<div className="flex-grow h-full">
|
||||||
} else {
|
<Map
|
||||||
setNoAccessModalOpen(true);
|
events={observatoryEvents}
|
||||||
}
|
selectedEventId={selectedEventId}
|
||||||
};
|
setSelectedEventId={setSelectedEventId}
|
||||||
|
hoveredEventId={hoveredEventId}
|
||||||
return (
|
setHoveredEventId={setHoveredEventId}
|
||||||
<div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden">
|
mapType="observatories"
|
||||||
<div className="flex-grow h-full">
|
/>
|
||||||
<Map
|
</div>
|
||||||
events={observatoryEvents}
|
<Sidebar
|
||||||
selectedEventId={selectedEventId}
|
logTitle="Observatory Mapping"
|
||||||
setSelectedEventId={setSelectedEventId}
|
logSubtitle="Record and search observatories - time/date set-up, location, scientists and recent earthquakes"
|
||||||
hoveredEventId={hoveredEventId}
|
recentsTitle="New Observatories"
|
||||||
setHoveredEventId={setHoveredEventId}
|
events={observatoryEvents}
|
||||||
mapType="observatories"
|
selectedEventId={selectedEventId}
|
||||||
/>
|
setSelectedEventId={setSelectedEventId}
|
||||||
</div>
|
hoveredEventId={hoveredEventId}
|
||||||
<Sidebar
|
setHoveredEventId={setHoveredEventId}
|
||||||
logTitle="Observatory Mapping"
|
button1Name="Log a New Observatory"
|
||||||
logSubtitle="Record and search observatories - time/date set-up, location, scientists and recent earthquakes"
|
button2Name="Search Observatories"
|
||||||
recentsTitle="New Observatories"
|
onButton1Click={handleLogClick}
|
||||||
events={observatoryEvents}
|
button1Disabled={!canLogObservatory}
|
||||||
selectedEventId={selectedEventId}
|
onButton2Click={() => setSearchModalOpen(true)} // <-- This line enables the search modal
|
||||||
setSelectedEventId={setSelectedEventId}
|
/>
|
||||||
hoveredEventId={hoveredEventId}
|
<LogObservatoryModal open={logModalOpen} onClose={() => setLogModalOpen(false)} onSuccess={() => mutate()} />
|
||||||
setHoveredEventId={setHoveredEventId}
|
<NoAccessModal open={noAccessModalOpen} onClose={() => setNoAccessModalOpen(false)} />
|
||||||
button1Name="Log a New Observatory"
|
<SearchObservatoriesModal
|
||||||
button2Name="Search Observatories"
|
open={searchModalOpen}
|
||||||
onButton1Click={handleLogClick}
|
onClose={() => setSearchModalOpen(false)}
|
||||||
button1Disabled={!canLogObservatory}
|
observatories={data?.observatories ?? []}
|
||||||
/>
|
/>
|
||||||
<LogObservatoryModal open={logModalOpen} onClose={() => setLogModalOpen(false)} onSuccess={() => mutate()} />
|
</div>
|
||||||
<NoAccessModal open={noAccessModalOpen} onClose={() => setNoAccessModalOpen(false)} />
|
);
|
||||||
</div>
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
170
src/components/SearchObservatoriesModal.tsx
Normal file
170
src/components/SearchObservatoriesModal.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
|
||||||
|
interface Observatory {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
location: string;
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
dateEstablished: string;
|
||||||
|
dateClosed?: string | null;
|
||||||
|
isFunctional: number; // 0 or 1!
|
||||||
|
// ...other fields can be ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchObservatoriesModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
observatories: Observatory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(isFunctional: number) {
|
||||||
|
return isFunctional === 1 ? "bg-green-500" : "bg-red-500";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: string | null | undefined) {
|
||||||
|
if (!date) return "-";
|
||||||
|
// Handles both ISO and possibly SQL datetime strings.
|
||||||
|
const parsed = new Date(date);
|
||||||
|
if (parsed.getFullYear() < 1900) return "-";
|
||||||
|
return parsed.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchObservatoriesModal: React.FC<SearchObservatoriesModalProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
observatories,
|
||||||
|
}) => {
|
||||||
|
const [tab, setTab] = useState<"name" | "location">("name");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!query) return observatories;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
if (tab === "name") {
|
||||||
|
return observatories.filter((o) =>
|
||||||
|
o.name?.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return observatories.filter((o) =>
|
||||||
|
o.location?.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [observatories, query, tab]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto relative">
|
||||||
|
<button
|
||||||
|
onClick={() => { setQuery(""); setExpandedId(null); onClose(); }}
|
||||||
|
className="absolute top-3 right-3 text-2xl text-gray-500 hover:text-black"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<div className="px-6 pt-6 pb-2 border-b flex flex-col md:flex-row items-center gap-2 justify-between">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h2 className="text-xl font-semibold mb-2 md:mb-0">Search Observatories</h2>
|
||||||
|
{/* Open/Closed key */}
|
||||||
|
<div className="flex gap-5 items-center mb-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="inline-block w-3 h-3 rounded-full bg-green-500 mr-1"></span>
|
||||||
|
<span className="text-sm text-gray-700">Open</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="inline-block w-3 h-3 rounded-full bg-red-500 mr-1"></span>
|
||||||
|
<span className="text-sm text-gray-700">Closed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className={`py-1 px-3 rounded-md text-sm ${tab === "name" ? "bg-blue-500 text-white" : "bg-gray-200 text-gray-700"}`}
|
||||||
|
onClick={() => setTab("name")}
|
||||||
|
>
|
||||||
|
By Name
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`py-1 px-3 rounded-md text-sm ${tab === "location" ? "bg-blue-500 text-white" : "bg-gray-200 text-gray-700"}`}
|
||||||
|
onClick={() => setTab("location")}
|
||||||
|
>
|
||||||
|
By Location
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<input
|
||||||
|
className="w-full border rounded px-4 py-2 mb-4 focus:outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
|
type="text"
|
||||||
|
placeholder={tab === "name" ? "Type observatory name..." : "Type location..."}
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<ul>
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<li className="text-gray-500 py-10 text-center">No observatories found.</li>
|
||||||
|
)}
|
||||||
|
{filtered.map((obs) => (
|
||||||
|
<li
|
||||||
|
key={obs.id}
|
||||||
|
className={`border rounded mb-3 transition-shadow ${expandedId === obs.id ? "shadow-lg" : "hover:shadow"} bg-white`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center justify-between px-4 py-2"
|
||||||
|
onClick={() => setExpandedId(expandedId === obs.id ? null : obs.id)}
|
||||||
|
aria-expanded={expandedId === obs.id}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-block w-3 h-3 rounded-full mr-2 ${statusColor(Number(obs.isFunctional))}`}
|
||||||
|
title={Number(obs.isFunctional) === 1 ? "Operational" : "Not operational"}
|
||||||
|
></span>
|
||||||
|
<span className="font-medium">{obs.name}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-600 text-sm">{obs.location}</span>
|
||||||
|
<svg
|
||||||
|
className={`ml-4 w-5 h-5 text-gray-400 transition-transform ${
|
||||||
|
expandedId === obs.id ? "rotate-90" : ""
|
||||||
|
}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{expandedId === obs.id && (
|
||||||
|
<div className="px-8 py-4 bg-gray-50 border-t grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Latitude:</span> {obs.latitude}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Longitude:</span> {obs.longitude}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Date Established:</span> {formatDate(obs.dateEstablished)}
|
||||||
|
</div>
|
||||||
|
{/* Date Closed removed as requested */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="border-t px-6 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => { setQuery(""); setExpandedId(null); onClose(); }}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white rounded py-2 px-6"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchObservatoriesModal;
|
||||||
Loading…
x
Reference in New Issue
Block a user