Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .example.env
Original file line number Diff line number Diff line change
@@ -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=
1 change: 1 addition & 0 deletions amplify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
42 changes: 42 additions & 0 deletions app/api/resource-edits/methods.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 });

Expand Down
101 changes: 101 additions & 0 deletions app/components/ResourceLocationPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Alert severity="info">
Map preview is unavailable — no Google Maps API key is configured (set{" "}
<code>VITE_GOOGLE_MAPS_API_KEY</code>).
</Alert>
);
}

const position = { lat: latitude as number, lng: longitude as number };

return (
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Location
</Typography>
<APIProvider
apiKey={GOOGLE_MAPS_API_KEY}
libraries={["places", "streetView"]}
>
<Stack direction={{ xs: "column", md: "row" }} gap={2}>
<Box
sx={{
flex: 1,
height: PANEL_HEIGHT,
borderRadius: 1,
overflow: "hidden",
}}
>
<GoogleMap
defaultCenter={position}
center={position}
defaultZoom={17}
disableDefaultUI
gestureHandling="cooperative"
mapId="phlask-admin-review-map"
style={{ width: "100%", height: "100%" }}
>
<AdvancedMarker position={position} title={label}>
<Pin />
</AdvancedMarker>
</GoogleMap>
</Box>
<Box
sx={{
flex: 1,
height: PANEL_HEIGHT,
borderRadius: 1,
overflow: "hidden",
}}
>
<ResourceStreetView position={position} gpId={gpId ?? null} />
</Box>
</Stack>
</APIProvider>
</Paper>
);
};

export default ResourceLocationPanel;
136 changes: 136 additions & 0 deletions app/components/ResourceStreetView.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>(null);
const streetViewLibrary = useMapsLibrary("streetView");
const placesLibrary = useMapsLibrary("places");
const [state, setState] = useState<ViewState>("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<google.maps.LatLngLiteral> => {
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 (
<Box
sx={{
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "action.hover",
}}
>
<Typography variant="body2" color="text.secondary">
No Street View available here.
</Typography>
</Box>
);
}

return (
<Box sx={{ position: "relative", height: "100%" }}>
{state === "loading" && (
<Box
sx={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "action.hover",
}}
>
<CircularProgress size={24} />
</Box>
)}
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
</Box>
);
};

export default ResourceStreetView;
3 changes: 2 additions & 1 deletion app/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
61 changes: 35 additions & 26 deletions app/routes/authenticated/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -168,30 +170,37 @@ export default function DashboardLayout() {
</Box>
<ThemeToggle />
</Box>
<Box
component={Link}
to="/logout"
sx={(theme) => ({
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 fontSize="small" />
Logout
<Box component={Form} method="post" action="/logout">
<Box
component="button"
type="submit"
sx={(theme) => ({
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 fontSize="small" />
Logout
</Box>
</Box>
</Box>
</Box>
Expand Down
Loading