From d51dc45dc3d95219703b62cb82b450ec74dc2105 Mon Sep 17 00:00:00 2001 From: shaynesidman <147111519+shaynesidman@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:57:51 -0400 Subject: [PATCH 1/2] refactor: use same map component for both settings page and school profile page --- src/app/schools/[name]/page.tsx | 45 +--- src/app/settings/page.tsx | 252 +------------------- src/components/SchoolLocationEditor.tsx | 295 ++++++++++++++++++++++++ 3 files changed, 306 insertions(+), 286 deletions(-) create mode 100644 src/components/SchoolLocationEditor.tsx diff --git a/src/app/schools/[name]/page.tsx b/src/app/schools/[name]/page.tsx index cc06ba5..d982381 100644 --- a/src/app/schools/[name]/page.tsx +++ b/src/app/schools/[name]/page.tsx @@ -17,7 +17,7 @@ import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { SchoolProfileSkeleton } from "@/components/skeletons/SchoolProfileSkeleton"; import { Skeleton } from "@/components/ui/skeleton"; -import { MapPlacer } from "@/components/ui/mapPlacer"; +import { SchoolLocationEditor } from "@/components/SchoolLocationEditor"; import { SchoolInfoRow } from "@/components/SchoolInfoRow"; import { StatCard } from "@/components/ui/stat-card"; import { ENTITY_CONFIG } from "@/lib/entity-config"; @@ -52,11 +52,6 @@ type SchoolData = { schoolType: string; }; -type MapCoordinates = { - latitude: number | null; - longitude: number | null; -}; - type ProjectRow = EditableProjectRow; export default function SchoolProfilePage() { @@ -66,7 +61,7 @@ export default function SchoolProfilePage() { const [schoolData, setSchoolData] = useState(null); const [prevYearData, setPrevYearData] = useState(null); - const [coordinates, setCoordinates] = useState(null); + const [year, setYear] = useState(null); const [projects, setProjects] = useState([]); const [editingName, setEditingName] = useState(false); @@ -397,40 +392,12 @@ export default function SchoolProfilePage() { {/* School location map */}
-

+

School Location

-
- -
-
-
- {coordinates && - coordinates.latitude !== null && - coordinates.longitude !== null && ( -
- - Coordinates:{" "} - {coordinates.latitude.toFixed(6)},{" "} - {coordinates.longitude.toFixed(6)} - -
- )} -
-
- {/* TO DO: Replace with actual dates from db */} - Last Updated:{" "} - {new Date().toLocaleDateString("en-US", { - month: "2-digit", - day: "2-digit", - year: "numeric", - })} -
-
+
{/* Editable project data table */} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 63e6e71..5f66f8a 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -11,23 +11,16 @@ "use client"; -import { - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useRef, - useState, -} from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; -import { Combobox } from "@/components/Combobox"; import YearsOfData, { YearsOfDataHandle } from "@/components/YearsOfData"; -import { Map, MapMarker, MarkerContent, useMap } from "@/components/ui/map"; -import { toast } from "sonner"; import GatewaySchools, { GatewaySchoolsHandle, } from "@/components/GatewaySchools"; -import { standardize } from "@/lib/string-standardize"; +import { + SchoolLocationEditor, + type SchoolLocationEditorHandle, +} from "@/components/SchoolLocationEditor"; import { useRouter } from "next/navigation"; import { useUnsavedChanges } from "@/components/UnsavedChangesContext"; import { @@ -190,238 +183,3 @@ export default function Settings() { ); } - -// Helper: registers click events on the map for the settings editor -function SettingsMapClickHandler({ - onMapClick, -}: { - onMapClick: (lng: number, lat: number) => void; -}) { - const { map } = useMap(); - - useEffect(() => { - if (!map) return; - - const handleClick = (e: { lngLat: { lng: number; lat: number } }) => { - onMapClick(e.lngLat.lng, e.lngLat.lat); - }; - - map.on("click", handleClick); - map.getCanvas().style.cursor = "crosshair"; - - return () => { - map.off("click", handleClick); - map.getCanvas().style.cursor = ""; - }; - }, [map, onMapClick]); - - return null; -} - -interface SchoolEntry { - id: number; - name: string; - latitude: number | null; - longitude: number | null; -} - -export interface SchoolLocationEditorHandle { - save: () => Promise; - discard: () => void; -} - -const SchoolLocationEditor = forwardRef< - SchoolLocationEditorHandle, - { onUnsavedChange: () => void } ->(function SchoolLocationEditor({ onUnsavedChange }, ref) { - const [schools, setSchools] = useState([]); - const [selectedSchoolId, setSelectedSchoolId] = useState(""); - const [editing, setEditing] = useState(false); - const [newPin, setNewPin] = useState<{ - latitude: number; - longitude: number; - } | null>(null); - - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); - - useEffect(() => { - fetch("/api/schools?list=true") - .then((res) => res.json()) - .then((data) => setSchools(data)); - }, []); - - const selectedSchool = schools.find( - (s) => String(s.id) === selectedSchoolId, - ); - - const schoolOptions = schools.map((s) => ({ - value: String(s.id), - label: s.name, - })); - - const handleSchoolChange = (value: string) => { - setSelectedSchoolId(value); - setEditing(true); - setNewPin(null); - }; - - const handleMapClick = useCallback( - async (long: number, lat: number) => { - let validLocation: boolean = false; - - try { - const res = await fetch( - `/api/coordinate-to-region/?lat=${lat}&long=${long}`, - ); - const data = await res.json(); - - // Location is only in MA if it has a region - if (res.ok && data.region) { - validLocation = true; - } else { - toast.error( - "A school's location must fall within Massachusetts.", - ); - } - } catch { - toast.error("Error validating school location"); - } - - if (validLocation) { - setNewPin({ latitude: lat, longitude: long }); - onUnsavedChange(); - } - }, - [onUnsavedChange], - ); - - const handleSave = async () => { - if (!newPin || !selectedSchool) return; - - try { - const slugName = standardize(selectedSchool.name); - const response = await fetch(`/api/schools/${slugName}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - latitude: newPin.latitude, - longitude: newPin.longitude, - }), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error( - errorData.error || "Failed to update school location", - ); - } - - // Update local state - setSchools((prev) => - prev.map((s) => - s.id === selectedSchool.id - ? { - ...s, - latitude: newPin.latitude, - longitude: newPin.longitude, - } - : s, - ), - ); - setNewPin(null); - toast.success(`Location updated for ${selectedSchool.name}`); - } catch (err) { - const errorMsg = - err instanceof Error ? err.message : "Failed to save location"; - toast.error(errorMsg); - } - }; - - const handleCancel = () => { - setNewPin(null); - }; - - useImperativeHandle(ref, () => ({ - save: handleSave, - discard: handleCancel, - })); - - const mapCenter: [number, number] = - selectedSchool?.longitude && selectedSchool?.latitude - ? [selectedSchool?.longitude, selectedSchool?.latitude] - : [-72, 42.272]; - - return ( -
-
-

School Locations

-
-
- -
- - {selectedSchool && mounted && ( -
-
- - {/* Current school location (red) */} - {selectedSchool.latitude && - selectedSchool.longitude && ( - - -
- - - )} - {/* New pin (blue) */} - {newPin && ( - - -
- - - )} - {editing && ( - - )} - -
- -
- {newPin ? ( -
{`New location: ${newPin.latitude.toFixed(4)}, ${newPin.longitude.toFixed(4)}`}
- ) : ( - "Click on the map to set a new location" - )} -
-
- )} -
- ); -}); diff --git a/src/components/SchoolLocationEditor.tsx b/src/components/SchoolLocationEditor.tsx new file mode 100644 index 0000000..7edc8ac --- /dev/null +++ b/src/components/SchoolLocationEditor.tsx @@ -0,0 +1,295 @@ +/*************************************************************** + * + * SchoolLocationEditor.tsx + * + * Author: Will, Hansini, and Justin + * Date: 12/6/2025 + * + * Summary: Inline map editor for updating a school's location. + * Used on settings page (with school selector dropdown) + * and on the school profile page (locked to a specific school). + * + **************************************************************/ + +"use client"; + +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useState, +} from "react"; +import { Map, MapMarker, MarkerContent, useMap } from "@/components/ui/map"; +import { Combobox } from "@/components/Combobox"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { standardize } from "@/lib/string-standardize"; + +// Registers crosshair click events on the map +function MapClickHandler({ + onMapClick, +}: { + onMapClick: (lng: number, lat: number) => void; +}) { + const { map } = useMap(); + + useEffect(() => { + if (!map) return; + const handleClick = (e: { lngLat: { lng: number; lat: number } }) => { + onMapClick(e.lngLat.lng, e.lngLat.lat); + }; + map.on("click", handleClick); + map.getCanvas().style.cursor = "crosshair"; + return () => { + map.off("click", handleClick); + map.getCanvas().style.cursor = ""; + }; + }, [map, onMapClick]); + + return null; +} + +interface SchoolEntry { + id: number; + name: string; + latitude: number | null; + longitude: number | null; +} + +export interface SchoolLocationEditorHandle { + save: () => Promise; + discard: () => void; +} + +type SchoolLocationEditorProps = { + onUnsavedChange?: () => void; + /** + * When provided, hides the school selector dropdown and locks the editor + * to this specific school. Save/Cancel buttons are shown inline. + */ + fixedSchool?: { name: string }; +}; + +export const SchoolLocationEditor = forwardRef< + SchoolLocationEditorHandle, + SchoolLocationEditorProps +>(function SchoolLocationEditor( + { onUnsavedChange = () => {}, fixedSchool }, + ref, +) { + const [schools, setSchools] = useState([]); + const [selectedSchoolId, setSelectedSchoolId] = useState(""); + const [editing, setEditing] = useState(false); + const [newPin, setNewPin] = useState<{ + latitude: number; + longitude: number; + } | null>(null); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + fetch("/api/schools?list=true") + .then((res) => res.json()) + .then((data) => setSchools(data)); + }, []); + + // When locked to a fixed school, auto-select it once the list loads + useEffect(() => { + if (!fixedSchool || schools.length === 0) return; + const match = schools.find( + (s) => standardize(s.name) === standardize(fixedSchool.name), + ); + if (match) { + setSelectedSchoolId(String(match.id)); + setEditing(true); + } + }, [fixedSchool, schools]); + + const selectedSchool = schools.find( + (s) => String(s.id) === selectedSchoolId, + ); + + const schoolOptions = schools.map((s) => ({ + value: String(s.id), + label: s.name, + })); + + const handleSchoolChange = (value: string) => { + setSelectedSchoolId(value); + setEditing(true); + setNewPin(null); + }; + + const handleMapClick = useCallback( + async (long: number, lat: number) => { + let validLocation = false; + try { + const res = await fetch( + `/api/coordinate-to-region/?lat=${lat}&long=${long}`, + ); + const data = await res.json(); + if (res.ok && data.region) { + validLocation = true; + } else { + toast.error( + "A school's location must fall within Massachusetts.", + ); + } + } catch { + toast.error("Error validating school location"); + } + if (validLocation) { + setNewPin({ latitude: lat, longitude: long }); + onUnsavedChange(); + } + }, + [onUnsavedChange], + ); + + const handleSave = async () => { + if (!newPin || !selectedSchool) return; + try { + const slugName = standardize(selectedSchool.name); + const response = await fetch(`/api/schools/${slugName}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + latitude: newPin.latitude, + longitude: newPin.longitude, + }), + }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData.error || "Failed to update school location", + ); + } + setSchools((prev) => + prev.map((s) => + s.id === selectedSchool.id + ? { + ...s, + latitude: newPin.latitude, + longitude: newPin.longitude, + } + : s, + ), + ); + setNewPin(null); + toast.success(`Location updated for ${selectedSchool.name}`); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to save location", + ); + } + }; + + const handleCancel = () => { + setNewPin(null); + }; + + useImperativeHandle(ref, () => ({ + save: handleSave, + discard: handleCancel, + })); + + const mapCenter: [number, number] = + selectedSchool?.longitude && selectedSchool?.latitude + ? [selectedSchool.longitude, selectedSchool.latitude] + : [-72, 42.272]; + + return ( +
+ {!fixedSchool && ( + <> +
+

School Locations

+
+
+ +
+ + )} + + {selectedSchool && mounted && ( +
+
+ + {/* Current location (red) */} + {selectedSchool.latitude && + selectedSchool.longitude && ( + + +
+ + + )} + {/* New pin (blue) */} + {newPin && ( + + +
+ + + )} + {editing && ( + + )} + +
+ +
+
+ {newPin ? ( +
{`New location: ${newPin.latitude.toFixed(4)}, ${newPin.longitude.toFixed(4)}`}
+ ) : ( + + Click on the map to set a new location + + )} +
+ {/* Inline save/cancel only in fixed-school mode */} + {fixedSchool && newPin && ( +
+ + +
+ )} +
+
+ )} +
+ ); +}); From 3223cfdae9d2a7e8cc181f624b485be242d2a253 Mon Sep 17 00:00:00 2001 From: shaynesidman <147111519+shaynesidman@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:10:31 -0400 Subject: [PATCH 2/2] feat: ordered list of returned schools by name --- src/app/api/schools/route.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/api/schools/route.ts b/src/app/api/schools/route.ts index 5b4ea8f..65c22a2 100644 --- a/src/app/api/schools/route.ts +++ b/src/app/api/schools/route.ts @@ -41,7 +41,8 @@ export async function GET(req: NextRequest) { region: schools.region, gateway: schools.gateway, }) - .from(schools); + .from(schools) + .orderBy(schools.name); // Only filter if gateway=true is explicitly passed const allSchools = await (isGateway