From af42ea46aa633ad34532531e5a8d091f709d782f Mon Sep 17 00:00:00 2001 From: tayloraj10 Date: Tue, 11 Aug 2026 20:42:15 -0400 Subject: [PATCH 1/7] Use React Router Form for save-changes submission Convert the "Save changes" button on the review detail page from a bare onClick handler to a , matching the approve/reject actions that already use
. The payload is a nested object rather than flat fields, so it's still sent as JSON via fetcher.submit, but routing it through a real form gives us proper submit semantics (Enter-to-submit, pending state, progressive enhancement) instead of a plain button click. Also swap the deprecated FormEvent type for SubmitEvent, and convert logout to a POST action (was a GET loader) triggered via a in the sidebar, since a GET route shouldn't perform a mutation like signing out. --- app/routes/authenticated/_layout.tsx | 57 ++++++++++++--------- app/routes/authenticated/logout.tsx | 16 ++++-- app/routes/authenticated/reviews/detail.tsx | 28 ++++++---- 3 files changed, 62 insertions(+), 39 deletions(-) diff --git a/app/routes/authenticated/_layout.tsx b/app/routes/authenticated/_layout.tsx index 959568c..9cc5309 100644 --- a/app/routes/authenticated/_layout.tsx +++ b/app/routes/authenticated/_layout.tsx @@ -1,7 +1,7 @@ import { Dashboard, Logout, RateReview } from "@mui/icons-material"; import { Box } from "@mui/material"; import { styled } from "@mui/material/styles"; -import { Link, NavLink, Outlet } from "react-router"; +import { Form, NavLink, Outlet } from "react-router"; import phlasklogo from "~/assets/PHLASK_v2.svg"; import { ThemeToggle } from "~/components/ThemeToggle"; import { WaveDivider } from "~/components/WaveDivider"; @@ -168,30 +168,37 @@ export default function DashboardLayout() { - ({ - display: "flex", - alignItems: "center", - gap: 1.25, - borderRadius: 3, - px: 2, - py: 1.25, - fontSize: "0.875rem", - fontWeight: 500, - textDecoration: "none", - color: navy[600], - transition: "background-color 0.2s ease", - "&:hover": { bgcolor: brand[50] }, - ...theme.applyStyles("dark", { - color: `${brand[100]}cc`, - "&:hover": { bgcolor: navy[800] }, - }), - })} - > - - Logout + + ({ + display: "flex", + width: "100%", + alignItems: "center", + gap: 1.25, + borderRadius: 3, + border: "none", + bgcolor: "transparent", + px: 2, + py: 1.25, + fontSize: "0.875rem", + fontFamily: "inherit", + fontWeight: 500, + textAlign: "left", + cursor: "pointer", + color: navy[600], + transition: "background-color 0.2s ease", + "&:hover": { bgcolor: brand[50] }, + ...theme.applyStyles("dark", { + color: `${brand[100]}cc`, + "&:hover": { bgcolor: navy[800] }, + }), + })} + > + + Logout + diff --git a/app/routes/authenticated/logout.tsx b/app/routes/authenticated/logout.tsx index e2d000d..e0ea3db 100644 --- a/app/routes/authenticated/logout.tsx +++ b/app/routes/authenticated/logout.tsx @@ -1,10 +1,18 @@ -import { type LoaderFunction, redirect } from "react-router"; +import { + type ActionFunction, + type LoaderFunction, + redirect, +} from "react-router"; import { getDatabaseClient } from "~/api/client.server"; -export const loader: LoaderFunction = async ({ request }) => { - const { client } = getDatabaseClient(request); +// Signing out is a mutation, so it belongs in the action (POST), not a GET +// loader. A direct GET to this route just bounces back to the dashboard. +export const loader: LoaderFunction = () => redirect("/"); + +export const action: ActionFunction = async ({ request }) => { + const { client, headers } = getDatabaseClient(request); await client.auth.signOut(); - return redirect("/auth"); + return redirect("/auth", { headers }); }; diff --git a/app/routes/authenticated/reviews/detail.tsx b/app/routes/authenticated/reviews/detail.tsx index 5669d93..65d6544 100644 --- a/app/routes/authenticated/reviews/detail.tsx +++ b/app/routes/authenticated/reviews/detail.tsx @@ -16,7 +16,7 @@ import { TextField, Typography, } from "@mui/material"; -import { useEffect, useState } from "react"; +import { type SubmitEvent, useEffect, useState } from "react"; import { type ActionFunction, data, @@ -282,7 +282,13 @@ const ReviewDetail = () => { value: EditableValues["bathroom"][K], ) => setValues((v) => ({ ...v, bathroom: { ...v.bathroom, [key]: value } })); - const handleSave = () => { + const handleSave = (event: SubmitEvent) => { + // The payload is a nested object (not flat form fields), so it's sent as + // JSON via the fetcher rather than a native form-encoded submission — + // still routed through a real below for the submit + // semantics (Enter-to-submit, pending state, progressive enhancement). + event.preventDefault(); + const payload: Partial = { name: values.name || null, resource_type: values.resource_type, @@ -776,14 +782,16 @@ const ReviewDetail = () => { {isPending && ( - + + + From 134a552266b4e073c81b4bf67cb9bda9a8171a39 Mon Sep 17 00:00:00 2001 From: tayloraj10 Date: Tue, 11 Aug 2026 21:07:05 -0400 Subject: [PATCH 2/7] Add resource edit history and counts to API and dashboard - Introduced new API methods for fetching edit counts and change logs, enhancing the resource edit management capabilities. - Updated the dashboard to utilize these new methods, allowing for a comprehensive view of pending edits and historical changes. - Modified the reviews detail page to display the change log for individual resources, improving audit trail visibility. - Enhanced the reviews index page to show pending edit counts for resources, aiding in resource management and review prioritization. --- app/api/resource-edits/methods.ts | 42 ++++++++++++++++++ app/routes/authenticated/dashboard.tsx | 45 ++++++++++++------- app/routes/authenticated/reviews/detail.tsx | 49 +++++++++++++++++++-- app/routes/authenticated/reviews/index.tsx | 41 ++++++++++++++--- app/types/ResourceEdit.ts | 25 +++++++++++ 5 files changed, 177 insertions(+), 25 deletions(-) diff --git a/app/api/resource-edits/methods.ts b/app/api/resource-edits/methods.ts index d76a1dc..266dccf 100644 --- a/app/api/resource-edits/methods.ts +++ b/app/api/resource-edits/methods.ts @@ -1,7 +1,9 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import type { EditReviewStatus, + ResourceChangeLogEntry, ResourceEdit, + ResourceEditCount, ResourceEditQueueRow, } from "~/types/ResourceEdit"; import type { ResourceEntry } from "~/types/ResourceEntry"; @@ -38,6 +40,46 @@ export const getResourceEditAPI = (client: SupabaseClient) => { return (data ?? []) as ResourceEdit[]; }, + /** Count of PENDING edits per resource — flags resources with multiple + * competing proposed edits. Backed by `resource_edit_counts`. */ + getEditCounts: async () => { + const { data, error } = await client + .from("resource_edit_counts") + .select("*"); + + if (error) { + throw error; + } + + return (data ?? []) as ResourceEditCount[]; + }, + /** Full history of decided (APPROVED/REJECTED) edits, most recent first + * within each resource. Backed by `resource_change_log`. */ + getChangeLog: async () => { + const { data, error } = await client + .from("resource_change_log") + .select("*"); + + if (error) { + throw error; + } + + return (data ?? []) as ResourceChangeLogEntry[]; + }, + /** Decided edit history for a single resource, e.g. for the review + * detail page's audit trail. Backed by `resource_change_log`. */ + getChangeLogForResource: async (resourceId: number) => { + const { data, error } = await client + .from("resource_change_log") + .select("*") + .eq("resource_id", resourceId); + + if (error) { + throw error; + } + + return (data ?? []) as ResourceChangeLogEntry[]; + }, getList: async (params: { review_status?: EditReviewStatus } = {}) => { let query = table.select("*").order("submitted_at", { ascending: false }); diff --git a/app/routes/authenticated/dashboard.tsx b/app/routes/authenticated/dashboard.tsx index 4323b0c..d90430f 100644 --- a/app/routes/authenticated/dashboard.tsx +++ b/app/routes/authenticated/dashboard.tsx @@ -29,29 +29,40 @@ const RESOURCE_TYPES: ResourceType[] = ["WATER", "FOOD", "FORAGE", "BATHROOM"]; export const loader: LoaderFunction = async ({ request }) => { const { client } = getDatabaseClient(request); const editAPI = getResourceEditAPI(client); - const edits = await editAPI.getList(); + + // Every `resource_edits` row is either still PENDING (covered by the two + // queue views) or has been decided (covered by `resource_change_log`), so + // this trio fully reconstructs the stats below without ever scanning the + // full `resource_edits` table (with all its wide proposed-value columns). + const [pendingEdits, newResources, changeLog] = await Promise.all([ + editAPI.getEditsQueue(), + editAPI.getNewResourcesQueue(), + editAPI.getChangeLog(), + ]); + + const pending = [...pendingEdits, ...newResources]; const submitterCounts = new Map(); const approverCounts = new Map(); const outstandingByType = new Map(); - let pendingCount = 0; - for (const edit of edits) { - const creator = edit.creator || "Unknown"; - submitterCounts.set(creator, (submitterCounts.get(creator) ?? 0) + 1); + for (const edit of pending) { + const submitter = edit.submitted_by || "Unknown"; + submitterCounts.set(submitter, (submitterCounts.get(submitter) ?? 0) + 1); + outstandingByType.set( + edit.resource_type, + (outstandingByType.get(edit.resource_type) ?? 0) + 1, + ); + } - if (edit.review_status === "PENDING") { - pendingCount += 1; - outstandingByType.set( - edit.resource_type, - (outstandingByType.get(edit.resource_type) ?? 0) + 1, - ); - } + for (const entry of changeLog) { + const submitter = entry.submitted_by || "Unknown"; + submitterCounts.set(submitter, (submitterCounts.get(submitter) ?? 0) + 1); - if (edit.review_status === "APPROVED" && edit.reviewed_by) { + if (entry.review_status === "APPROVED" && entry.reviewed_by) { approverCounts.set( - edit.reviewed_by, - (approverCounts.get(edit.reviewed_by) ?? 0) + 1, + entry.reviewed_by, + (approverCounts.get(entry.reviewed_by) ?? 0) + 1, ); } } @@ -72,8 +83,8 @@ export const loader: LoaderFunction = async ({ request }) => { })); return { - totalEdits: edits.length, - pendingCount, + totalEdits: pending.length + changeLog.length, + pendingCount: pending.length, topSubmitters, topApprovers, outstanding, diff --git a/app/routes/authenticated/reviews/detail.tsx b/app/routes/authenticated/reviews/detail.tsx index 65d6544..dd54db6 100644 --- a/app/routes/authenticated/reviews/detail.tsx +++ b/app/routes/authenticated/reviews/detail.tsx @@ -33,7 +33,7 @@ import { getResourceEditAPI } from "~/api/resource-edits/methods"; import { getResourceEntryAPI } from "~/api/resources/methods"; import { userContext } from "~/context/user"; import { authMiddleware } from "~/middleware/auth"; -import type { ResourceEdit } from "~/types/ResourceEdit"; +import type { ResourceChangeLogEntry, ResourceEdit } from "~/types/ResourceEdit"; import type { BathroomTag, DispenserType, @@ -173,6 +173,7 @@ export const loader: LoaderFunction = async ({ request, params }) => { const edit = await editAPI.getById(id); let resource: ResourceEntry | null = null; + let changeLog: ResourceChangeLogEntry[] = []; if (edit.mapped_resource !== null) { try { const resourceAPI = getResourceEntryAPI(client); @@ -180,9 +181,11 @@ export const loader: LoaderFunction = async ({ request, params }) => { } catch { resource = null; } + + changeLog = await editAPI.getChangeLogForResource(edit.mapped_resource); } - return { edit, resource }; + return { edit, resource, changeLog }; }; export const action: ActionFunction = async ({ request, params, context }) => { @@ -238,9 +241,10 @@ export const action: ActionFunction = async ({ request, params, context }) => { }; const ReviewDetail = () => { - const { edit, resource } = useLoaderData<{ + const { edit, resource, changeLog } = useLoaderData<{ edit: ResourceEdit; resource: ResourceEntry | null; + changeLog: ResourceChangeLogEntry[]; }>(); const actionData = useActionData<{ message?: string }>(); const fetcher = useFetcher<{ message?: string; ok?: boolean }>(); @@ -386,6 +390,45 @@ const ReviewDetail = () => { )} + {!isNew && changeLog.length > 0 && ( + + + Change history + + + + + {changeLog.map((entry) => ( + + + + + + {entry.reviewed_by ?? "—"} + + + {entry.reviewed_at + ? new Date(entry.reviewed_at).toLocaleString( + undefined, + { dateStyle: "medium", timeStyle: "short" }, + ) + : "—"} + + + {entry.review_notes ?? "—"} + + + ))} + +
+
+
+ )} + diff --git a/app/routes/authenticated/reviews/index.tsx b/app/routes/authenticated/reviews/index.tsx index cb32608..7e50fa2 100644 --- a/app/routes/authenticated/reviews/index.tsx +++ b/app/routes/authenticated/reviews/index.tsx @@ -22,7 +22,11 @@ import { type LoaderFunction, useLoaderData, useNavigate } from "react-router"; import { getDatabaseClient } from "~/api/client.server"; import { getResourceEditAPI } from "~/api/resource-edits/methods"; import { authMiddleware } from "~/middleware/auth"; -import type { ResourceEdit, ResourceEditQueueRow } from "~/types/ResourceEdit"; +import type { + ResourceEdit, + ResourceEditCount, + ResourceEditQueueRow, +} from "~/types/ResourceEdit"; import { resourceTypeChipColor, resourceTypeChipIcon, @@ -34,12 +38,13 @@ export const loader: LoaderFunction = async ({ request }) => { const { client } = getDatabaseClient(request); const editAPI = getResourceEditAPI(client); - const [edits, newResources] = await Promise.all([ + const [edits, newResources, editCounts] = await Promise.all([ editAPI.getEditsQueue(), editAPI.getNewResourcesQueue(), + editAPI.getEditCounts(), ]); - return { edits, newResources }; + return { edits, newResources, editCounts }; }; type RowData = { @@ -49,6 +54,8 @@ type RowData = { resource_type: ResourceEdit["resource_type"]; resourceLabel: string; submitted_at: string; + /** How many PENDING edits (including this one) target the same resource. */ + competingEdits: number; }; const columns: ColumnDef[] = [ @@ -85,6 +92,20 @@ const columns: ColumnDef[] = [ accessorKey: "resourceLabel", header: "Existing resource", }, + { + accessorKey: "competingEdits", + header: "Pending edits", + cell: ({ row }) => + row.original.competingEdits > 1 ? ( + + ) : ( + row.original.competingEdits || "—" + ), + }, { accessorKey: "submitted_at", header: "Submitted", @@ -97,15 +118,21 @@ const columns: ColumnDef[] = [ ]; const ReviewsQueue = () => { - const { edits, newResources } = useLoaderData<{ + const { edits, newResources, editCounts } = useLoaderData<{ edits: ResourceEditQueueRow[]; newResources: ResourceEdit[]; + editCounts: ResourceEditCount[]; }>(); const navigate = useNavigate(); const [sorting, setSorting] = useState([ { id: "submitted_at", desc: true }, ]); + const pendingCountByResource = useMemo( + () => new Map(editCounts.map((c) => [c.resource_id, c.pending_count])), + [editCounts], + ); + const data: RowData[] = useMemo( () => [ ...edits.map((edit) => ({ @@ -118,6 +145,9 @@ const ReviewsQueue = () => { edit.current_resource?.address || `Resource #${edit.mapped_resource}`, submitted_at: edit.submitted_at, + competingEdits: edit.mapped_resource + ? (pendingCountByResource.get(edit.mapped_resource) ?? 1) + : 0, })), ...newResources.map((edit) => ({ id: edit.id, @@ -126,9 +156,10 @@ const ReviewsQueue = () => { resource_type: edit.resource_type, resourceLabel: "—", submitted_at: edit.submitted_at, + competingEdits: 0, })), ], - [edits, newResources], + [edits, newResources, pendingCountByResource], ); const table = useReactTable({ diff --git a/app/types/ResourceEdit.ts b/app/types/ResourceEdit.ts index 934482a..79fe672 100644 --- a/app/types/ResourceEdit.ts +++ b/app/types/ResourceEdit.ts @@ -31,3 +31,28 @@ export type ResourceEdit = ResourceEntry & { export type ResourceEditQueueRow = ResourceEdit & { current_resource: ResourceEntry & { id: number }; }; + +/** + * A row from `resource_edit_counts` — the number of PENDING edits currently + * queued against a given resource. Useful for flagging resources with + * multiple competing proposed edits before a reviewer picks one to approve. + */ +export type ResourceEditCount = { + resource_id: number; + pending_count: number; +}; + +/** + * A row from `resource_change_log` — a decided (APPROVED or REJECTED) edit, + * stripped down to just the review-lifecycle columns. One row per past + * decision, ordered by `resource_id` then most-recently-reviewed first. + */ +export type ResourceChangeLogEntry = { + edit_id: number; + resource_id: number | null; + review_status: EditReviewStatus; + submitted_by: string | null; + reviewed_by: string | null; + reviewed_at: string | null; + review_notes: string | null; +}; From 4a88ea5fb8190eba8f62f4e91fde2b00d82104b8 Mon Sep 17 00:00:00 2001 From: tayloraj10 Date: Tue, 11 Aug 2026 21:35:48 -0400 Subject: [PATCH 3/7] Refactor reviews index to enhance data structure and presentation - Introduced separate types for edit and new row data, improving type safety and clarity. - Updated table columns to include a submitted date for both edits and new resources, enhancing the review process. - Refactored the ReviewQueueTable component to handle sorting and rendering more efficiently. - Improved the overall layout by incorporating a Stack component for better spacing and organization. --- app/routes/authenticated/reviews/index.tsx | 266 +++++++++++++-------- 1 file changed, 162 insertions(+), 104 deletions(-) diff --git a/app/routes/authenticated/reviews/index.tsx b/app/routes/authenticated/reviews/index.tsx index 7e50fa2..a158234 100644 --- a/app/routes/authenticated/reviews/index.tsx +++ b/app/routes/authenticated/reviews/index.tsx @@ -1,6 +1,7 @@ import { Chip, Paper, + Stack, Table, TableBody, TableCell, @@ -47,9 +48,8 @@ export const loader: LoaderFunction = async ({ request }) => { return { edits, newResources, editCounts }; }; -type RowData = { +type EditRowData = { id: number; - kind: "EDIT" | "NEW"; name: string | null; resource_type: ResourceEdit["resource_type"]; resourceLabel: string; @@ -58,19 +58,24 @@ type RowData = { competingEdits: number; }; -const columns: ColumnDef[] = [ - { - accessorKey: "kind", - header: "Kind", - cell: ({ row }) => ( - - ), - }, +type NewRowData = { + id: number; + name: string | null; + resource_type: ResourceEdit["resource_type"]; + submitted_at: string; +}; + +const submittedColumn = (): ColumnDef => ({ + accessorKey: "submitted_at", + header: "Submitted", + cell: ({ row }) => + new Date(row.original.submitted_at).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }), +}); + +const editColumns: ColumnDef[] = [ { accessorKey: "name", header: "Proposed name", @@ -106,38 +111,125 @@ const columns: ColumnDef[] = [ row.original.competingEdits || "—" ), }, + submittedColumn(), +]; + +const newColumns: ColumnDef[] = [ { - accessorKey: "submitted_at", - header: "Submitted", - cell: ({ row }) => - new Date(row.original.submitted_at).toLocaleString(undefined, { - dateStyle: "medium", - timeStyle: "short", - }), + accessorKey: "name", + header: "Proposed name", + cell: ({ row }) => row.original.name || "—", + }, + { + accessorKey: "resource_type", + header: "Type", + cell: ({ row }) => ( + + ), }, + submittedColumn(), ]; +type ReviewQueueTableProps = { + data: T[]; + columns: ColumnDef[]; + emptyMessage: string; +}; + +function ReviewQueueTable({ + data, + columns, + emptyMessage, +}: ReviewQueueTableProps) { + const navigate = useNavigate(); + const [sorting, setSorting] = useState([ + { id: "submitted_at", desc: true }, + ]); + + const table = useReactTable({ + data, + columns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + return ( + +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + {{ asc: " ↑", desc: " ↓" }[ + header.column.getIsSorted() as string + ] ?? null} + + ))} + + ))} + + + {table.getRowModel().rows.length === 0 && ( + + + + {emptyMessage} + + + + )} + {table.getRowModel().rows.map((row) => ( + navigate(`/reviews/${row.original.id}`)} + hover + sx={{ cursor: "pointer" }} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+
+ ); +} + const ReviewsQueue = () => { const { edits, newResources, editCounts } = useLoaderData<{ edits: ResourceEditQueueRow[]; newResources: ResourceEdit[]; editCounts: ResourceEditCount[]; }>(); - const navigate = useNavigate(); - const [sorting, setSorting] = useState([ - { id: "submitted_at", desc: true }, - ]); const pendingCountByResource = useMemo( () => new Map(editCounts.map((c) => [c.resource_id, c.pending_count])), [editCounts], ); - const data: RowData[] = useMemo( - () => [ - ...edits.map((edit) => ({ + const editData: EditRowData[] = useMemo( + () => + edits.map((edit) => ({ id: edit.id, - kind: "EDIT" as const, name: edit.name ?? null, resource_type: edit.resource_type, resourceLabel: @@ -149,88 +241,54 @@ const ReviewsQueue = () => { ? (pendingCountByResource.get(edit.mapped_resource) ?? 1) : 0, })), - ...newResources.map((edit) => ({ + [edits, pendingCountByResource], + ); + + const newData: NewRowData[] = useMemo( + () => + newResources.map((edit) => ({ id: edit.id, - kind: "NEW" as const, name: edit.name ?? null, resource_type: edit.resource_type, - resourceLabel: "—", submitted_at: edit.submitted_at, - competingEdits: 0, })), - ], - [edits, newResources, pendingCountByResource], + [newResources], ); - const table = useReactTable({ - data, - columns, - state: { sorting }, - onSortingChange: setSorting, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - }); return ( -
- - Resource reviews - - - Proposed edits to existing PHLask resources and brand-new site - submissions, pending approval. - - - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {flexRender( - header.column.columnDef.header, - header.getContext(), - )} - {{ asc: " ↑", desc: " ↓" }[ - header.column.getIsSorted() as string - ] ?? null} - - ))} - - ))} - - - {table.getRowModel().rows.length === 0 && ( - - - - No pending reviews. Nothing to do here right now. - - - - )} - {table.getRowModel().rows.map((row) => ( - navigate(`/reviews/${row.original.id}`)} - hover - sx={{ cursor: "pointer" }} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} - -
-
-
+ +
+ + Resource reviews + + + Proposed edits to existing PHLask resources and brand-new site + submissions, pending approval. + +
+ +
+ + New site submissions + + +
+ +
+ + Edits to existing resources + + +
+
); }; From b1e77ce0e149a7bc5ad9fd2bee9c32e5cff7d184 Mon Sep 17 00:00:00 2001 From: tayloraj10 Date: Tue, 11 Aug 2026 21:40:56 -0400 Subject: [PATCH 4/7] biome formatting --- app/routes/authenticated/reviews/detail.tsx | 5 ++++- app/routes/authenticated/reviews/index.tsx | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/routes/authenticated/reviews/detail.tsx b/app/routes/authenticated/reviews/detail.tsx index dd54db6..8f3257c 100644 --- a/app/routes/authenticated/reviews/detail.tsx +++ b/app/routes/authenticated/reviews/detail.tsx @@ -33,7 +33,10 @@ import { getResourceEditAPI } from "~/api/resource-edits/methods"; import { getResourceEntryAPI } from "~/api/resources/methods"; import { userContext } from "~/context/user"; import { authMiddleware } from "~/middleware/auth"; -import type { ResourceChangeLogEntry, ResourceEdit } from "~/types/ResourceEdit"; +import type { + ResourceChangeLogEntry, + ResourceEdit, +} from "~/types/ResourceEdit"; import type { BathroomTag, DispenserType, diff --git a/app/routes/authenticated/reviews/index.tsx b/app/routes/authenticated/reviews/index.tsx index a158234..7fbada9 100644 --- a/app/routes/authenticated/reviews/index.tsx +++ b/app/routes/authenticated/reviews/index.tsx @@ -65,7 +65,9 @@ type NewRowData = { submitted_at: string; }; -const submittedColumn = (): ColumnDef => ({ +const submittedColumn = < + T extends { submitted_at: string }, +>(): ColumnDef => ({ accessorKey: "submitted_at", header: "Submitted", cell: ({ row }) => From 36c76994983aae901ead89cd7478b68730aae83f Mon Sep 17 00:00:00 2001 From: tayloraj10 Date: Tue, 18 Aug 2026 19:58:50 -0400 Subject: [PATCH 5/7] Add Google Maps integration for resource location display - Introduced ResourceLocationPanel and ResourceStreetView components to render a map and street view for resource locations on the review detail page. - Updated .env configuration to include VITE_GOOGLE_MAPS_API_KEY for client-side access. - Added dependencies for Google Maps API and types in package.json. - Enhanced the build process to include the new environment variable. - Updated the reviews detail page to incorporate the new components, improving the user experience for reviewers. --- .example.env | 7 + amplify.yml | 1 + app/components/ResourceLocationPanel.tsx | 101 +++++++++++++++ app/components/ResourceStreetView.tsx | 136 ++++++++++++++++++++ app/routes/authenticated/reviews/detail.tsx | 8 ++ package.json | 2 + pnpm-lock.yaml | 38 ++++++ tsconfig.json | 2 +- 8 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 app/components/ResourceLocationPanel.tsx create mode 100644 app/components/ResourceStreetView.tsx diff --git a/.example.env b/.example.env index 71b2fe6..0e2d28f 100644 --- a/.example.env +++ b/.example.env @@ -1,2 +1,9 @@ SUPABASE_URL=https://wantycfbnzzocsbthqzs.supabase.co SUPABASE_PUBLISHABLE_KEY= + +# Reuses the same Google Maps Platform key as the phlask-map repo (see its +# .env / VITE_REACT_GOOGLE_MAPS_API_KEY). Must be VITE_-prefixed so Vite +# inlines it into the client bundle — it's used client-side only, to render +# the map + Street View panel on the /reviews/:id detail page. Ask in +# #phlask-data if you need the value. +VITE_GOOGLE_MAPS_API_KEY= diff --git a/amplify.yml b/amplify.yml index 26ab8ee..36ec544 100644 --- a/amplify.yml +++ b/amplify.yml @@ -11,6 +11,7 @@ frontend: commands: - env | grep -e SUPABASE_URL >> .env - env | grep -e SUPABASE_PUBLISHABLE_KEY >> .env + - env | grep -e VITE_GOOGLE_MAPS_API_KEY >> .env - pnpm run build - bash bin/postbuild.sh artifacts: diff --git a/app/components/ResourceLocationPanel.tsx b/app/components/ResourceLocationPanel.tsx new file mode 100644 index 0000000..c5c166e --- /dev/null +++ b/app/components/ResourceLocationPanel.tsx @@ -0,0 +1,101 @@ +import { Alert, Box, Paper, Stack, Typography } from "@mui/material"; +import { + AdvancedMarker, + APIProvider, + Map as GoogleMap, + Pin, +} from "@vis.gl/react-google-maps"; +import ResourceStreetView from "./ResourceStreetView"; + +// Inlined by Vite at build time (VITE_-prefixed vars are exposed to the +// client bundle) — this key is meant to be public/client-visible, reused +// from the phlask-map repo and restricted by HTTP referrer in GCP Console. +const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY as + | string + | undefined; + +type ResourceLocationPanelProps = { + latitude: number | null | undefined; + longitude: number | null | undefined; + /** Google Place ID, if known — improves Street View accuracy. */ + gpId?: string | null; + label?: string; +}; + +const PANEL_HEIGHT = 280; + +/** + * Map + Street View preview for a proposed (or current) resource location, + * used on the review detail page so a reviewer can visually confirm a + * submission without leaving the dashboard. + */ +const ResourceLocationPanel = ({ + latitude, + longitude, + gpId, + label, +}: ResourceLocationPanelProps) => { + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { + return null; + } + + if (!GOOGLE_MAPS_API_KEY) { + return ( + + Map preview is unavailable — no Google Maps API key is configured + (set VITE_GOOGLE_MAPS_API_KEY). + + ); + } + + const position = { lat: latitude as number, lng: longitude as number }; + + return ( + + + Location + + + + + + + + + + + + + + + + + ); +}; + +export default ResourceLocationPanel; diff --git a/app/components/ResourceStreetView.tsx b/app/components/ResourceStreetView.tsx new file mode 100644 index 0000000..ebc7e7b --- /dev/null +++ b/app/components/ResourceStreetView.tsx @@ -0,0 +1,136 @@ +import { Box, CircularProgress, Typography } from "@mui/material"; +import { useMapsLibrary } from "@vis.gl/react-google-maps"; +import { useEffect, useRef, useState } from "react"; + +type ResourceStreetViewProps = { + position: google.maps.LatLngLiteral; + /** Google Place ID, if known — used to look up a more precise location + * than the resource's own stored lat/lng before searching for imagery. */ + gpId: string | null; +}; + +type ViewState = "loading" | "available" | "unavailable"; + +/** + * Renders a Street View panorama near `position`, preferring the precise + * location of `gpId` (a Google Place) when available. Google's classic + * Street View service has no direct "look up by place id" request, so a + * Place lookup (for its `location`) is done first, then panorama presence + * is checked via `StreetViewService.getPanorama`. + */ +const ResourceStreetView = ({ position, gpId }: ResourceStreetViewProps) => { + const containerRef = useRef(null); + const streetViewLibrary = useMapsLibrary("streetView"); + const placesLibrary = useMapsLibrary("places"); + const [state, setState] = useState("loading"); + + const { lat, lng } = position; + + useEffect(() => { + if (!streetViewLibrary || !containerRef.current) { + return; + } + + let cancelled = false; + const container = containerRef.current; + const fallbackLocation = { lat, lng }; + setState("loading"); + + const renderPanoramaAt = (location: google.maps.LatLngLiteral) => { + const streetViewService = new streetViewLibrary.StreetViewService(); + + streetViewService.getPanorama( + { + location, + radius: 50, + source: google.maps.StreetViewSource.OUTDOOR, + }, + (data, status) => { + if (cancelled) { + return; + } + + if (status !== "OK" || !data?.location?.pano) { + setState("unavailable"); + return; + } + + new streetViewLibrary.StreetViewPanorama(container, { + pano: data.location.pano, + addressControl: false, + fullscreenControl: false, + motionTrackingControl: false, + }); + setState("available"); + }, + ); + }; + + const resolveLocation = async (): Promise => { + if (!gpId || !placesLibrary) { + return fallbackLocation; + } + + try { + const place = new placesLibrary.Place({ id: gpId }); + await place.fetchFields({ fields: ["location"] }); + const location = place.location; + return location + ? { lat: location.lat(), lng: location.lng() } + : fallbackLocation; + } catch { + return fallbackLocation; + } + }; + + resolveLocation().then((location) => { + if (!cancelled) { + renderPanoramaAt(location); + } + }); + + return () => { + cancelled = true; + }; + }, [streetViewLibrary, placesLibrary, gpId, lat, lng]); + + if (state === "unavailable") { + return ( + + + No Street View available here. + + + ); + } + + return ( + + {state === "loading" && ( + + + + )} +
+ + ); +}; + +export default ResourceStreetView; diff --git a/app/routes/authenticated/reviews/detail.tsx b/app/routes/authenticated/reviews/detail.tsx index 8f3257c..42efc5e 100644 --- a/app/routes/authenticated/reviews/detail.tsx +++ b/app/routes/authenticated/reviews/detail.tsx @@ -31,6 +31,7 @@ import { import { getDatabaseClient } from "~/api/client.server"; import { getResourceEditAPI } from "~/api/resource-edits/methods"; import { getResourceEntryAPI } from "~/api/resources/methods"; +import ResourceLocationPanel from "~/components/ResourceLocationPanel"; import { userContext } from "~/context/user"; import { authMiddleware } from "~/middleware/auth"; import type { @@ -393,6 +394,13 @@ const ReviewDetail = () => { )} + + {!isNew && changeLog.length > 0 && ( diff --git a/package.json b/package.json index 837a91a..dc150ed 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.96.0", "@tanstack/react-table": "^8.21.3", + "@vis.gl/react-google-maps": "^1.9.0", "composable-functions": "^5.0.0", "express": "^5.2.1", "isbot": "^5.1.35", @@ -35,6 +36,7 @@ "devDependencies": { "@biomejs/biome": "2.3.14", "@react-router/dev": "^7.13.0", + "@types/google.maps": "^3.65.5", "@types/node": "^22.19.11", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ff0d9c..919062e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@vis.gl/react-google-maps': + specifier: ^1.9.0 + version: 1.9.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) composable-functions: specifier: ^5.0.0 version: 5.0.0 @@ -72,6 +75,9 @@ importers: '@react-router/dev': specifier: ^7.13.0 version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@22.19.11)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)) + '@types/google.maps': + specifier: ^3.65.5 + version: 3.65.5 '@types/node': specifier: ^22.19.11 version: 22.19.11 @@ -496,6 +502,9 @@ packages: cpu: [x64] os: [win32] + '@googlemaps/js-api-loader@2.1.1': + resolution: {integrity: sha512-yUpAwksbHrlZIWD49JmveNSfBG4oAK0AwMknfSaPMnP5N7UT8oFRVCqwjGb1XQovi//7KLbPQKZpbofiLGzpDw==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -845,6 +854,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/google.maps@3.65.5': + resolution: {integrity: sha512-oR9Hq/WaKGzIguZEwoCuyQ0ofDhCbefmS9RrjLUZ5scMziwTE6xA90drJYt2ZsyUCYmCqnxQcDpfGqeRE/Zgog==} + '@types/node@22.19.11': resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==} @@ -873,6 +885,12 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vis.gl/react-google-maps@1.9.0': + resolution: {integrity: sha512-ELL/zo8mRMPGDQZwHRClf2tE1/PMYYKjg+C/dUn8rmlQ0e+s7rkcNmCgaeBAV48GJzEcrBsr+vjDv4AvzJrlUA==} + peerDependencies: + react: '>=16.8.0 || ^19.0 || ^19.0.0-rc' + react-dom: '>=16.8.0 || ^19.0 || ^19.0.0-rc' + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -1114,6 +1132,10 @@ packages: exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + fast-equals@6.0.2: + resolution: {integrity: sha512-sAjhj9ZhOxYCGiNMnZLaucOqf5ZeFnHNoKoAZiD9thhJ0N8RP85qJK759/97C/3L7NzzmGVB5uiX9AUpySZmUQ==} + engines: {node: '>=6.0.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2260,6 +2282,10 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@googlemaps/js-api-loader@2.1.1': + dependencies: + '@types/google.maps': 3.65.5 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2588,6 +2614,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/google.maps@3.65.5': {} + '@types/node@22.19.11': dependencies: undici-types: 6.21.0 @@ -2614,6 +2642,14 @@ snapshots: dependencies: '@types/node': 22.19.11 + '@vis.gl/react-google-maps@1.9.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@googlemaps/js-api-loader': 2.1.1 + '@types/google.maps': 3.65.5 + fast-equals: 6.0.2 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -2926,6 +2962,8 @@ snapshots: exsolve@1.0.8: {} + fast-equals@6.0.2: {} + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 diff --git a/tsconfig.json b/tsconfig.json index cbe49c7..348b505 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ ], "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ES2022"], - "types": ["node", "vite/client"], + "types": ["node", "vite/client", "google.maps"], "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", From 51a841b1fdd41f54870712e9b77f535e803bc049 Mon Sep 17 00:00:00 2001 From: tayloraj10 Date: Tue, 18 Aug 2026 19:58:56 -0400 Subject: [PATCH 6/7] Refactor icon imports in multiple components for improved clarity - Updated imports for MUI icons in ThemeToggle, authenticated layout, dashboard, reviews detail, login, reset-password, and chipColors files to use default imports instead of named imports, enhancing code readability and consistency. --- app/components/ThemeToggle.tsx | 3 ++- app/routes/authenticated/_layout.tsx | 4 +++- app/routes/authenticated/dashboard.tsx | 4 +++- app/routes/authenticated/reviews/detail.tsx | 2 +- app/routes/unauthenticated/login.tsx | 3 ++- app/routes/unauthenticated/reset-password.tsx | 3 ++- app/utils/chipColors.tsx | 5 ++++- 7 files changed, 17 insertions(+), 7 deletions(-) diff --git a/app/components/ThemeToggle.tsx b/app/components/ThemeToggle.tsx index 206b561..846f100 100644 --- a/app/components/ThemeToggle.tsx +++ b/app/components/ThemeToggle.tsx @@ -1,4 +1,5 @@ -import { DarkMode, LightMode } from "@mui/icons-material"; +import DarkMode from "@mui/icons-material/DarkMode"; +import LightMode from "@mui/icons-material/LightMode"; import { IconButton, Tooltip } from "@mui/material"; import { useThemeMode } from "~/theme/ThemeModeProvider"; import { brand, navy } from "~/theme/theme"; diff --git a/app/routes/authenticated/_layout.tsx b/app/routes/authenticated/_layout.tsx index 9cc5309..726b3b9 100644 --- a/app/routes/authenticated/_layout.tsx +++ b/app/routes/authenticated/_layout.tsx @@ -1,4 +1,6 @@ -import { Dashboard, Logout, RateReview } from "@mui/icons-material"; +import Dashboard from "@mui/icons-material/Dashboard"; +import Logout from "@mui/icons-material/Logout"; +import RateReview from "@mui/icons-material/RateReview"; import { Box } from "@mui/material"; import { styled } from "@mui/material/styles"; import { Form, NavLink, Outlet } from "react-router"; diff --git a/app/routes/authenticated/dashboard.tsx b/app/routes/authenticated/dashboard.tsx index d90430f..fa6efd8 100644 --- a/app/routes/authenticated/dashboard.tsx +++ b/app/routes/authenticated/dashboard.tsx @@ -1,4 +1,6 @@ -import { HourglassTop, PendingActions, WaterDrop } from "@mui/icons-material"; +import HourglassTop from "@mui/icons-material/HourglassTop"; +import PendingActions from "@mui/icons-material/PendingActions"; +import WaterDrop from "@mui/icons-material/WaterDrop"; import { Box, Chip, diff --git a/app/routes/authenticated/reviews/detail.tsx b/app/routes/authenticated/reviews/detail.tsx index 42efc5e..31d4245 100644 --- a/app/routes/authenticated/reviews/detail.tsx +++ b/app/routes/authenticated/reviews/detail.tsx @@ -1,4 +1,4 @@ -import { ArrowBack } from "@mui/icons-material"; +import ArrowBack from "@mui/icons-material/ArrowBack"; import { Alert, Autocomplete, diff --git a/app/routes/unauthenticated/login.tsx b/app/routes/unauthenticated/login.tsx index 72cca10..691a4e3 100644 --- a/app/routes/unauthenticated/login.tsx +++ b/app/routes/unauthenticated/login.tsx @@ -1,4 +1,5 @@ -import { Visibility, VisibilityOff } from "@mui/icons-material"; +import Visibility from "@mui/icons-material/Visibility"; +import VisibilityOff from "@mui/icons-material/VisibilityOff"; import { Alert, Button, diff --git a/app/routes/unauthenticated/reset-password.tsx b/app/routes/unauthenticated/reset-password.tsx index 12ff94d..1f8f52c 100644 --- a/app/routes/unauthenticated/reset-password.tsx +++ b/app/routes/unauthenticated/reset-password.tsx @@ -1,4 +1,5 @@ -import { Visibility, VisibilityOff } from "@mui/icons-material"; +import Visibility from "@mui/icons-material/Visibility"; +import VisibilityOff from "@mui/icons-material/VisibilityOff"; import { Alert, Button, diff --git a/app/utils/chipColors.tsx b/app/utils/chipColors.tsx index 6c15edb..79cd5cc 100644 --- a/app/utils/chipColors.tsx +++ b/app/utils/chipColors.tsx @@ -1,4 +1,7 @@ -import { LocalDining, Park, WaterDrop, Wc } from "@mui/icons-material"; +import LocalDining from "@mui/icons-material/LocalDining"; +import Park from "@mui/icons-material/Park"; +import WaterDrop from "@mui/icons-material/WaterDrop"; +import Wc from "@mui/icons-material/Wc"; import type { ChipProps } from "@mui/material"; import type { ReactElement } from "react"; import type { EditReviewStatus } from "~/types/ResourceEdit"; From 1e6aea74583060c818456883e57c35c9a9c9a7e4 Mon Sep 17 00:00:00 2001 From: tayloraj10 Date: Tue, 18 Aug 2026 20:00:52 -0400 Subject: [PATCH 7/7] run biome check --- app/components/ResourceLocationPanel.tsx | 202 ++++++++--------- app/components/ResourceStreetView.tsx | 272 +++++++++++------------ 2 files changed, 237 insertions(+), 237 deletions(-) diff --git a/app/components/ResourceLocationPanel.tsx b/app/components/ResourceLocationPanel.tsx index c5c166e..134ffbc 100644 --- a/app/components/ResourceLocationPanel.tsx +++ b/app/components/ResourceLocationPanel.tsx @@ -1,101 +1,101 @@ -import { Alert, Box, Paper, Stack, Typography } from "@mui/material"; -import { - AdvancedMarker, - APIProvider, - Map as GoogleMap, - Pin, -} from "@vis.gl/react-google-maps"; -import ResourceStreetView from "./ResourceStreetView"; - -// Inlined by Vite at build time (VITE_-prefixed vars are exposed to the -// client bundle) — this key is meant to be public/client-visible, reused -// from the phlask-map repo and restricted by HTTP referrer in GCP Console. -const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY as - | string - | undefined; - -type ResourceLocationPanelProps = { - latitude: number | null | undefined; - longitude: number | null | undefined; - /** Google Place ID, if known — improves Street View accuracy. */ - gpId?: string | null; - label?: string; -}; - -const PANEL_HEIGHT = 280; - -/** - * Map + Street View preview for a proposed (or current) resource location, - * used on the review detail page so a reviewer can visually confirm a - * submission without leaving the dashboard. - */ -const ResourceLocationPanel = ({ - latitude, - longitude, - gpId, - label, -}: ResourceLocationPanelProps) => { - if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { - return null; - } - - if (!GOOGLE_MAPS_API_KEY) { - return ( - - Map preview is unavailable — no Google Maps API key is configured - (set VITE_GOOGLE_MAPS_API_KEY). - - ); - } - - const position = { lat: latitude as number, lng: longitude as number }; - - return ( - - - Location - - - - - - - - - - - - - - - - - ); -}; - -export default ResourceLocationPanel; +import { Alert, Box, Paper, Stack, Typography } from "@mui/material"; +import { + AdvancedMarker, + APIProvider, + Map as GoogleMap, + Pin, +} from "@vis.gl/react-google-maps"; +import ResourceStreetView from "./ResourceStreetView"; + +// Inlined by Vite at build time (VITE_-prefixed vars are exposed to the +// client bundle) — this key is meant to be public/client-visible, reused +// from the phlask-map repo and restricted by HTTP referrer in GCP Console. +const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY as + | string + | undefined; + +type ResourceLocationPanelProps = { + latitude: number | null | undefined; + longitude: number | null | undefined; + /** Google Place ID, if known — improves Street View accuracy. */ + gpId?: string | null; + label?: string; +}; + +const PANEL_HEIGHT = 280; + +/** + * Map + Street View preview for a proposed (or current) resource location, + * used on the review detail page so a reviewer can visually confirm a + * submission without leaving the dashboard. + */ +const ResourceLocationPanel = ({ + latitude, + longitude, + gpId, + label, +}: ResourceLocationPanelProps) => { + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { + return null; + } + + if (!GOOGLE_MAPS_API_KEY) { + return ( + + Map preview is unavailable — no Google Maps API key is configured (set{" "} + VITE_GOOGLE_MAPS_API_KEY). + + ); + } + + const position = { lat: latitude as number, lng: longitude as number }; + + return ( + + + Location + + + + + + + + + + + + + + + + + ); +}; + +export default ResourceLocationPanel; diff --git a/app/components/ResourceStreetView.tsx b/app/components/ResourceStreetView.tsx index ebc7e7b..8c5b5fc 100644 --- a/app/components/ResourceStreetView.tsx +++ b/app/components/ResourceStreetView.tsx @@ -1,136 +1,136 @@ -import { Box, CircularProgress, Typography } from "@mui/material"; -import { useMapsLibrary } from "@vis.gl/react-google-maps"; -import { useEffect, useRef, useState } from "react"; - -type ResourceStreetViewProps = { - position: google.maps.LatLngLiteral; - /** Google Place ID, if known — used to look up a more precise location - * than the resource's own stored lat/lng before searching for imagery. */ - gpId: string | null; -}; - -type ViewState = "loading" | "available" | "unavailable"; - -/** - * Renders a Street View panorama near `position`, preferring the precise - * location of `gpId` (a Google Place) when available. Google's classic - * Street View service has no direct "look up by place id" request, so a - * Place lookup (for its `location`) is done first, then panorama presence - * is checked via `StreetViewService.getPanorama`. - */ -const ResourceStreetView = ({ position, gpId }: ResourceStreetViewProps) => { - const containerRef = useRef(null); - const streetViewLibrary = useMapsLibrary("streetView"); - const placesLibrary = useMapsLibrary("places"); - const [state, setState] = useState("loading"); - - const { lat, lng } = position; - - useEffect(() => { - if (!streetViewLibrary || !containerRef.current) { - return; - } - - let cancelled = false; - const container = containerRef.current; - const fallbackLocation = { lat, lng }; - setState("loading"); - - const renderPanoramaAt = (location: google.maps.LatLngLiteral) => { - const streetViewService = new streetViewLibrary.StreetViewService(); - - streetViewService.getPanorama( - { - location, - radius: 50, - source: google.maps.StreetViewSource.OUTDOOR, - }, - (data, status) => { - if (cancelled) { - return; - } - - if (status !== "OK" || !data?.location?.pano) { - setState("unavailable"); - return; - } - - new streetViewLibrary.StreetViewPanorama(container, { - pano: data.location.pano, - addressControl: false, - fullscreenControl: false, - motionTrackingControl: false, - }); - setState("available"); - }, - ); - }; - - const resolveLocation = async (): Promise => { - if (!gpId || !placesLibrary) { - return fallbackLocation; - } - - try { - const place = new placesLibrary.Place({ id: gpId }); - await place.fetchFields({ fields: ["location"] }); - const location = place.location; - return location - ? { lat: location.lat(), lng: location.lng() } - : fallbackLocation; - } catch { - return fallbackLocation; - } - }; - - resolveLocation().then((location) => { - if (!cancelled) { - renderPanoramaAt(location); - } - }); - - return () => { - cancelled = true; - }; - }, [streetViewLibrary, placesLibrary, gpId, lat, lng]); - - if (state === "unavailable") { - return ( - - - No Street View available here. - - - ); - } - - return ( - - {state === "loading" && ( - - - - )} -
- - ); -}; - -export default ResourceStreetView; +import { Box, CircularProgress, Typography } from "@mui/material"; +import { useMapsLibrary } from "@vis.gl/react-google-maps"; +import { useEffect, useRef, useState } from "react"; + +type ResourceStreetViewProps = { + position: google.maps.LatLngLiteral; + /** Google Place ID, if known — used to look up a more precise location + * than the resource's own stored lat/lng before searching for imagery. */ + gpId: string | null; +}; + +type ViewState = "loading" | "available" | "unavailable"; + +/** + * Renders a Street View panorama near `position`, preferring the precise + * location of `gpId` (a Google Place) when available. Google's classic + * Street View service has no direct "look up by place id" request, so a + * Place lookup (for its `location`) is done first, then panorama presence + * is checked via `StreetViewService.getPanorama`. + */ +const ResourceStreetView = ({ position, gpId }: ResourceStreetViewProps) => { + const containerRef = useRef(null); + const streetViewLibrary = useMapsLibrary("streetView"); + const placesLibrary = useMapsLibrary("places"); + const [state, setState] = useState("loading"); + + const { lat, lng } = position; + + useEffect(() => { + if (!streetViewLibrary || !containerRef.current) { + return; + } + + let cancelled = false; + const container = containerRef.current; + const fallbackLocation = { lat, lng }; + setState("loading"); + + const renderPanoramaAt = (location: google.maps.LatLngLiteral) => { + const streetViewService = new streetViewLibrary.StreetViewService(); + + streetViewService.getPanorama( + { + location, + radius: 50, + source: google.maps.StreetViewSource.OUTDOOR, + }, + (data, status) => { + if (cancelled) { + return; + } + + if (status !== "OK" || !data?.location?.pano) { + setState("unavailable"); + return; + } + + new streetViewLibrary.StreetViewPanorama(container, { + pano: data.location.pano, + addressControl: false, + fullscreenControl: false, + motionTrackingControl: false, + }); + setState("available"); + }, + ); + }; + + const resolveLocation = async (): Promise => { + if (!gpId || !placesLibrary) { + return fallbackLocation; + } + + try { + const place = new placesLibrary.Place({ id: gpId }); + await place.fetchFields({ fields: ["location"] }); + const location = place.location; + return location + ? { lat: location.lat(), lng: location.lng() } + : fallbackLocation; + } catch { + return fallbackLocation; + } + }; + + resolveLocation().then((location) => { + if (!cancelled) { + renderPanoramaAt(location); + } + }); + + return () => { + cancelled = true; + }; + }, [streetViewLibrary, placesLibrary, gpId, lat, lng]); + + if (state === "unavailable") { + return ( + + + No Street View available here. + + + ); + } + + return ( + + {state === "loading" && ( + + + + )} +
+ + ); +}; + +export default ResourceStreetView;