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/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/components/ResourceLocationPanel.tsx b/app/components/ResourceLocationPanel.tsx new file mode 100644 index 0000000..134ffbc --- /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..8c5b5fc --- /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/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 959568c..726b3b9 100644 --- a/app/routes/authenticated/_layout.tsx +++ b/app/routes/authenticated/_layout.tsx @@ -1,7 +1,9 @@ -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 { 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 +170,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/dashboard.tsx b/app/routes/authenticated/dashboard.tsx index 4323b0c..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, @@ -29,29 +31,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 +85,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/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..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, @@ -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, @@ -31,9 +31,13 @@ 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 { ResourceEdit } from "~/types/ResourceEdit"; +import type { + ResourceChangeLogEntry, + ResourceEdit, +} from "~/types/ResourceEdit"; import type { BathroomTag, DispenserType, @@ -173,6 +177,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 +185,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 +245,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 }>(); @@ -282,7 +290,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, @@ -380,6 +394,52 @@ 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 ?? "—"} + + + ))} + +
+
+
+ )} + @@ -776,14 +836,16 @@ const ReviewDetail = () => { {isPending && ( - + + +
diff --git a/app/routes/authenticated/reviews/index.tsx b/app/routes/authenticated/reviews/index.tsx index cb32608..7fbada9 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, @@ -22,7 +23,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,36 +39,45 @@ 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 = { +type EditRowData = { id: number; - kind: "EDIT" | "NEW"; name: string | null; 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[] = [ - { - accessorKey: "kind", - header: "Kind", - cell: ({ row }) => ( - - ), - }, +type NewRowData = { + id: number; + name: string | null; + resource_type: ResourceEdit["resource_type"]; + submitted_at: string; +}; + +const submittedColumn = < + T extends { submitted_at: string }, +>(): 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", @@ -86,31 +100,138 @@ const columns: ColumnDef[] = [ header: "Existing resource", }, { - accessorKey: "submitted_at", - header: "Submitted", + accessorKey: "competingEdits", + header: "Pending edits", cell: ({ row }) => - new Date(row.original.submitted_at).toLocaleString(undefined, { - dateStyle: "medium", - timeStyle: "short", - }), + row.original.competingEdits > 1 ? ( + + ) : ( + row.original.competingEdits || "—" + ), }, + submittedColumn(), ]; -const ReviewsQueue = () => { - const { edits, newResources } = useLoaderData<{ - edits: ResourceEditQueueRow[]; - newResources: ResourceEdit[]; - }>(); +const newColumns: ColumnDef[] = [ + { + 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 data: RowData[] = useMemo( - () => [ - ...edits.map((edit) => ({ + 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 pendingCountByResource = useMemo( + () => new Map(editCounts.map((c) => [c.resource_id, c.pending_count])), + [editCounts], + ); + + const editData: EditRowData[] = useMemo( + () => + edits.map((edit) => ({ id: edit.id, - kind: "EDIT" as const, name: edit.name ?? null, resource_type: edit.resource_type, resourceLabel: @@ -118,88 +239,58 @@ 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) => ({ + [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, })), - ], - [edits, newResources], + [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 + + +
+
); }; 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/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; +}; 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"; 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",