diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 2f16260..0411bae 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -16,6 +16,6 @@ jobs: - name: Setup Biome uses: biomejs/setup-biome@v2 with: - version: latest + version: 2.3.14 - name: Run Biome run: biome ci . diff --git a/.gitignore b/.gitignore index f25ae95..89c5664 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,4 @@ # React Router /.react-router/ /build/ - - +/docker/build/ diff --git a/README.md b/README.md index 1d02163..d0c1af2 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,12 @@ This is the admin dashboard for the [PHLASK](https://github.com/phlask/phlask-ma ## Key Features +- Authenticate via Supabase (login, password reset, email confirmation) - View and manage resources from the Supabase database - Review and approve/reject suggested edits to resources - View and resolve reports on resources - View resource changelogs and roll back changes if needed +- Light/dark theme support ## Getting Started @@ -65,23 +67,46 @@ Replace 3000:3000 with the actual port your application listens on if it is diff app/ ├── api/ │ ├── resources/ -│ │ └── methods.ts # API methods related to resources -│ ├── client.ts # API client setup -│ └── types.ts # Shared API request/response types +│ │ └── methods.ts # API methods related to resources +│ ├── resource-revisions/ +│ │ └── methods.ts # API methods related to resource revisions/changelogs +│ ├── client.server.ts # Supabase client setup (server-side) +│ └── types.ts # Shared API request/response types ├── assets/ -│ └── PHILASK_v2.svg # Static assets (logos, images, etc.) +│ └── PHILASK_v2.svg # Static assets (logos, images, etc.) +├── components/ +│ ├── ThemeToggle.tsx # Light/dark theme toggle control +│ └── WaveDivider.tsx # Decorative UI component ├── constants/ -│ └── db.ts # Database-related constants/config +│ └── db.ts # Database-related constants/config +├── context/ +│ └── user.ts # Authenticated user context +├── middleware/ +│ └── auth.ts # Route authentication middleware ├── routes/ -│ ├── _layout.tsx # Shared layout for route pages -│ ├── dashboard.tsx # Dashboard page component +│ ├── authenticated/ # Routes that require a logged-in user +│ │ ├── reviews/ # Review & approve/reject suggested edits +│ │ ├── _layout.tsx +│ │ ├── dashboard.tsx +│ │ └── logout.tsx +│ ├── unauthenticated/ # Public auth routes (login, password reset, etc.) +│ │ ├── _layout.tsx +│ │ ├── login.tsx +│ │ ├── forgot-password.tsx +│ │ ├── reset-password.tsx +│ │ └── confirm.tsx +├── schemas/ +│ └── *.ts # Zod schemas for auth forms +├── theme/ +│ ├── theme.ts # MUI theme configuration +│ └── ThemeModeProvider.tsx # Light/dark theme context provider ├── types/ -│ └── ResourceEntry.ts # Domain-specific TypeScript types +│ └── ResourceEntry.ts # Domain-specific TypeScript types ├── utils/ -│ └── distance.ts # Distance calculation utilities -├── app.css # Global application styles -├── root.tsx # App root component / Entry point -└── routes.ts # Route definitions and router configuration +│ └── distance.ts # Distance calculation utilities +├── app.css # Global application styles +├── root.tsx # App root component / Entry point +└── routes.ts # Route definitions and router configuration ``` ## How to Contribute / Next Steps diff --git "a/Screenshot 2026-07-07 at 8.06.36\342\200\257PM.png" "b/Screenshot 2026-07-07 at 8.06.36\342\200\257PM.png" new file mode 100644 index 0000000..81ced28 Binary files /dev/null and "b/Screenshot 2026-07-07 at 8.06.36\342\200\257PM.png" differ diff --git a/app/api/resource-revisions/methods.ts b/app/api/resource-revisions/methods.ts new file mode 100644 index 0000000..a703e6d --- /dev/null +++ b/app/api/resource-revisions/methods.ts @@ -0,0 +1,69 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { + ResourceRevision, + RevisionStatus, +} from "~/types/ResourceRevision"; + +const TABLE_NAME = "resource_revisions"; + +export const getResourceRevisionAPI = (client: SupabaseClient) => { + const table = client.from(TABLE_NAME); + + return { + getList: async (params: { status?: RevisionStatus } = {}) => { + let query = table.select("*").order("date_created", { ascending: false }); + + if (params.status) { + query = query.eq("status", params.status); + } + + const { data, error } = await query; + + if (error) { + throw error; + } + + return (data ?? []) as ResourceRevision[]; + }, + getById: async (id: number) => { + const { data, error } = await table + .select("*") + .eq("id", id) + .single(); + + if (error) { + throw error; + } + + return data; + }, + updateStatus: async (id: number, status: RevisionStatus) => { + const { data, error } = await table + .update({ status }) + .eq("id", id) + .select() + .single(); + + if (error) { + throw error; + } + + return data; + }, + updateFields: async (id: number, values: Partial) => { + const { data, error } = await table + .update(values) + .eq("id", id) + .select() + .single(); + + if (error) { + throw error; + } + + return data; + }, + }; +}; + +export default getResourceRevisionAPI; diff --git a/app/app.css b/app/app.css index 75c6338..342b3a5 100644 --- a/app/app.css +++ b/app/app.css @@ -1,16 +1,105 @@ -@import 'tailwindcss'; +/* Dark mode is toggled by adding/removing `.dark` on (see ThemeModeProvider), + rather than following the OS preference, so it can stay in sync with the MUI theme. */ -@theme { - --font-sans: - 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', - 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; +:root { + /* Water/brand blue, anchored on the PHLASK logo color (#10B6FF = brand-500). + Mirrored in app/theme/theme.ts (kept in sync manually). */ + --color-brand-50: #eafaff; + --color-brand-100: #d6f4ff; + --color-brand-300: #6fd6ff; + + /* Deep ocean navy, used for dark-mode surfaces */ + --color-navy-950: #071522; + --color-navy-900: #0a1929; + --color-navy-600: #1f4560; } html, body { - @apply bg-white dark:bg-gray-950; + background-color: var(--color-brand-50); + color: var(--color-navy-900); + color-scheme: light; +} + +html.dark, +html.dark body { + background-color: var(--color-navy-950); + color: var(--color-brand-50); +} + +html.dark { + color-scheme: dark; +} + +@keyframes wave-drift { + from { + transform: translateX(0); + } + to { + transform: translateX(-50%); + } +} + +@keyframes bubble-rise { + from { + transform: translateY(0) scale(1); + opacity: 0.5; + } + to { + transform: translateY(-140px) scale(1.15); + opacity: 0; + } +} + +/* Ambient per-type motion for resource-type chip icons (see chipColors.tsx) */ +@keyframes chip-icon-bob { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-2px); + } +} + +@keyframes chip-icon-pulse { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.18); + } +} + +@keyframes chip-icon-sway { + 0%, + 100% { + transform: rotate(-10deg); + } + 50% { + transform: rotate(10deg); + } +} - @media (prefers-color-scheme: dark) { - color-scheme: dark; +@keyframes chip-icon-wiggle { + 0%, + 100% { + transform: rotate(0deg); + } + 25% { + transform: rotate(-12deg); + } + 75% { + transform: rotate(12deg); } } + +/* Water-themed scrollbar */ +* { + scrollbar-color: var(--color-brand-300) transparent; +} + +html.dark * { + scrollbar-color: var(--color-navy-600) transparent; +} diff --git a/app/components/ThemeToggle.tsx b/app/components/ThemeToggle.tsx new file mode 100644 index 0000000..206b561 --- /dev/null +++ b/app/components/ThemeToggle.tsx @@ -0,0 +1,45 @@ +import { DarkMode, LightMode } from "@mui/icons-material"; +import { IconButton, Tooltip } from "@mui/material"; +import { useThemeMode } from "~/theme/ThemeModeProvider"; +import { brand, navy } from "~/theme/theme"; + +// Styling and icon visibility are driven purely by the `.dark` class (via +// theme.applyStyles) rather than the `mode` value from React state, so this +// can never mismatch between SSR and hydration. +export function ThemeToggle() { + const { toggleMode } = useThemeMode(); + + return ( + + ({ + bgcolor: brand[100], + color: brand[700], + transition: "transform 0.35s ease, background-color 0.2s ease", + "&:hover": { bgcolor: brand[200], transform: "rotate(-14deg)" }, + ...theme.applyStyles("dark", { + bgcolor: navy[800], + color: brand[300], + "&:hover": { bgcolor: navy[700] }, + }), + })} + > + ({ + ...theme.applyStyles("dark", { display: "none" }), + })} + /> + ({ + display: "none", + ...theme.applyStyles("dark", { display: "inline-block" }), + })} + /> + + + ); +} diff --git a/app/components/WaveDivider.tsx b/app/components/WaveDivider.tsx new file mode 100644 index 0000000..9618bfc --- /dev/null +++ b/app/components/WaveDivider.tsx @@ -0,0 +1,59 @@ +import { Box, type SxProps, type Theme } from "@mui/material"; + +// Two periods of the same wave back-to-back so a -50% translateX loops seamlessly. +const WAVE_PATH = + "M0,40 C150,90 350,0 600,40 C850,80 1050,0 1200,40 C1350,80 1550,0 1800,40 C1950,80 2150,0 2400,40 L2400,120 L0,120 Z"; + +type WaveDividerProps = { + color: string; + height?: number; + opacity?: number; + duration?: number; + reverse?: boolean; + sx?: SxProps; +}; + +export function WaveDivider({ + color, + height = 60, + opacity = 1, + duration = 18, + reverse = false, + sx, +}: WaveDividerProps) { + return ( + + + + ); +} diff --git a/app/root.tsx b/app/root.tsx index 9fc6636..e7f8594 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,3 +1,4 @@ +import { Box } from "@mui/material"; import { isRouteErrorResponse, Links, @@ -5,10 +6,20 @@ import { Outlet, Scripts, ScrollRestoration, + useLoaderData, + useRouteLoaderData, } from "react-router"; - import type { Route } from "./+types/root"; import "./app.css"; +import { ThemeModeProvider } from "./theme/ThemeModeProvider"; +import { getThemeMode } from "./theme/theme-cookie.server"; + +// The theme cookie (see theme-cookie.server.ts) lets the server render the +// right `dark` class on up front, so there's no flash of the wrong +// theme and no need for a blocking inline script before hydration. +export function loader({ request }: Route.LoaderArgs) { + return { themeMode: getThemeMode(request) }; +} export const links: Route.LinksFunction = () => [ { rel: "preconnect", href: "https://fonts.googleapis.com" }, @@ -24,8 +35,12 @@ export const links: Route.LinksFunction = () => [ ]; export function Layout({ children }: { children: React.ReactNode }) { + // useRouteLoaderData (not useLoaderData) because Layout also renders the + // ErrorBoundary path, where the root loader may not have run. + const themeMode = useRouteLoaderData("root")?.themeMode; + return ( - + @@ -42,7 +57,13 @@ export function Layout({ children }: { children: React.ReactNode }) { } export default function App() { - return ; + const { themeMode } = useLoaderData(); + + return ( + + + + ); } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { @@ -62,14 +83,17 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { } return ( -
+

{message}

{details}

{stack && ( -
+        
           {stack}
-        
+
)} -
+ ); } diff --git a/app/routes.ts b/app/routes.ts index de7ef64..e9dd613 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -8,9 +8,14 @@ import { export default [ layout("routes/authenticated/_layout.tsx", [ index("routes/authenticated/dashboard.tsx"), + route("reviews", "routes/authenticated/reviews/index.tsx"), + route("reviews/:id", "routes/authenticated/reviews/detail.tsx"), route("logout", "routes/authenticated/logout.tsx"), ]), route("auth", "routes/unauthenticated/_layout.tsx", [ index("routes/unauthenticated/login.tsx"), + route("forgot-password", "routes/unauthenticated/forgot-password.tsx"), + route("confirm", "routes/unauthenticated/confirm.tsx"), + route("reset-password", "routes/unauthenticated/reset-password.tsx"), ]), ] satisfies RouteConfig; diff --git a/app/routes/authenticated/_layout.tsx b/app/routes/authenticated/_layout.tsx index a0983a8..959568c 100644 --- a/app/routes/authenticated/_layout.tsx +++ b/app/routes/authenticated/_layout.tsx @@ -1,50 +1,207 @@ +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 phlasklogo from "~/assets/PHLASK_v2.svg"; +import { ThemeToggle } from "~/components/ThemeToggle"; +import { WaveDivider } from "~/components/WaveDivider"; +import { brand, navy } from "~/theme/theme"; export const action = () => {}; +const SidebarNavLink = styled(NavLink)(({ theme }) => ({ + display: "flex", + alignItems: "center", + gap: theme.spacing(1.25), + borderRadius: 12, + padding: `${theme.spacing(1.25)} ${theme.spacing(2)}`, + fontSize: "0.875rem", + fontWeight: 500, + textDecoration: "none", + color: navy[600], + transition: "color 0.2s ease, background-color 0.2s ease", + "&:hover": { backgroundColor: brand[50] }, + "&.active": { + color: brand[700], + backgroundColor: brand[50], + }, + ...theme.applyStyles("dark", { + color: `${brand[100]}cc`, + "&:hover": { backgroundColor: navy[800] }, + "&.active": { + color: brand[300], + backgroundColor: navy[800], + }, + }), +})); + export default function DashboardLayout() { return ( -
- + + ({ + fontSize: "0.75rem", + fontWeight: 600, + ...theme.applyStyles("dark", { color: `${brand[100]}b3` }), + })} + > + 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 + + + + -
-
+ + -
-
-
+ + + ); } diff --git a/app/routes/authenticated/dashboard.tsx b/app/routes/authenticated/dashboard.tsx index 83ac4cf..845c394 100644 --- a/app/routes/authenticated/dashboard.tsx +++ b/app/routes/authenticated/dashboard.tsx @@ -1,14 +1,235 @@ -import type { MiddlewareFunction } from "react-router"; +import { HourglassTop, PendingActions, WaterDrop } from "@mui/icons-material"; +import { + Box, + Chip, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + Tooltip, + Typography, +} from "@mui/material"; +import type { LoaderFunction } from "react-router"; +import { Link, useLoaderData } from "react-router"; +import { getDatabaseClient } from "~/api/client.server"; +import { getResourceRevisionAPI } from "~/api/resource-revisions/methods"; import { authMiddleware } from "~/middleware/auth"; +import type { ResourceType } from "~/types/ResourceEntry"; +import { + resourceTypeChipColor, + resourceTypeChipIcon, +} from "~/utils/chipColors"; -export const middleware: MiddlewareFunction[] = [authMiddleware]; +export const middleware = [authMiddleware]; + +const RESOURCE_TYPES: ResourceType[] = ["WATER", "FOOD", "FORAGE", "BATHROOM"]; + +export const loader: LoaderFunction = async ({ request }) => { + const { client } = getDatabaseClient(request); + const revisionAPI = getResourceRevisionAPI(client); + const revisions = await revisionAPI.getList(); + + const submitterCounts = new Map(); + const outstandingByType = new Map(); + let pendingCount = 0; + + for (const revision of revisions) { + const creator = revision.creator || "Unknown"; + submitterCounts.set(creator, (submitterCounts.get(creator) ?? 0) + 1); + + if (revision.status === "PENDING") { + pendingCount += 1; + outstandingByType.set( + revision.resource_type, + (outstandingByType.get(revision.resource_type) ?? 0) + 1, + ); + } + } + + const topSubmitters = [...submitterCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([creator, count]) => ({ creator, count })); + + const outstanding = RESOURCE_TYPES.map((type) => ({ + type, + count: outstandingByType.get(type) ?? 0, + })); + + return { + totalRevisions: revisions.length, + pendingCount, + topSubmitters, + outstanding, + }; +}; + +type LoaderData = { + totalRevisions: number; + pendingCount: number; + topSubmitters: { creator: string; count: number }[]; + outstanding: { type: string; count: number }[]; +}; + +const StatCard = ({ + label, + value, + icon, +}: { + label: string; + value: string | number; + icon: React.ReactNode; +}) => ( + + + {icon} + + + + {label} + + + {value} + + + +); + +const Dashboard = () => { + const { totalRevisions, pendingCount, topSubmitters, outstanding } = + useLoaderData(); -export default function Dashboard() { return ( -
-

- Dashboard Overview -

-
+ + + + Dashboard overview + + + Snapshot of resource edit submissions and review activity. + + + + + } + /> + } + /> + + + + + + + + Outstanding by resource type + + + + + + {outstanding.map(({ type, count }) => ( + + + + + + {count} + + + ))} + +
+
+ + View pending reviews → + +
+ + + + Top submitters + + {topSubmitters.length === 0 ? ( + + No submissions yet. + + ) : ( + + + + {topSubmitters.map(({ creator, count }) => ( + + {creator} + + {count} + + + ))} + +
+
+ )} +
+ + + + + Top approvers + + + + Not trackable yet — approvals don't record who approved them. + + +
+
); -} +}; + +export default Dashboard; diff --git a/app/routes/authenticated/reviews/detail.tsx b/app/routes/authenticated/reviews/detail.tsx new file mode 100644 index 0000000..d6c7acf --- /dev/null +++ b/app/routes/authenticated/reviews/detail.tsx @@ -0,0 +1,793 @@ +import { ArrowBack } from "@mui/icons-material"; +import { + Alert, + Autocomplete, + Box, + Button, + Chip, + MenuItem, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import { useEffect, useState } from "react"; +import { + type ActionFunction, + data, + Form, + type LoaderFunction, + Link as RouterLink, + redirect, + useActionData, + useFetcher, + useLoaderData, +} from "react-router"; +import { getDatabaseClient } from "~/api/client.server"; +import { getResourceRevisionAPI } from "~/api/resource-revisions/methods"; +import { getResourceEntryAPI } from "~/api/resources/methods"; +import { authMiddleware } from "~/middleware/auth"; +import type { + BathroomTag, + DispenserType, + DistributionType, + EntryType, + FoodType, + ForageTag, + ForageType, + OrganizationType, + ResourceEntry, + ResourceType, + WaterTag, +} from "~/types/ResourceEntry"; +import type { ResourceRevision } from "~/types/ResourceRevision"; +import { statusChipColor } from "~/utils/chipColors"; + +export const middleware = [authMiddleware]; + +const RESOURCE_TYPES: ResourceType[] = ["WATER", "FOOD", "FORAGE", "BATHROOM"]; +const ENTRY_TYPES: EntryType[] = ["OPEN", "RESTRICTED", "UNSURE"]; +const DISPENSER_TYPES: DispenserType[] = [ + "DRINKING_FOUNTAIN", + "BOTTLE_FILLER", + "SINK", + "JUG", + "SODA_MACHINE", + "PITCHER", + "WATER_COOLER", +]; +const WATER_TAGS: WaterTag[] = [ + "WHEELCHAIR_ACCESSIBLE", + "FILTERED", + "BYOB", + "ID_REQUIRED", +]; +const FOOD_TYPES: FoodType[] = ["PERISHABLE", "NON_PERISHABLE", "PREPARED"]; +const DISTRIBUTION_TYPES: DistributionType[] = [ + "EAT_ON_SITE", + "DELIVERY", + "PICKUP", +]; +const ORGANIZATION_TYPES: OrganizationType[] = [ + "GOVERNMENT", + "BUSINESS", + "NON_PROFIT", + "UNSURE", +]; +const FORAGE_TYPES: ForageType[] = [ + "NUT", + "FRUIT", + "LEAVES", + "BARK", + "FLOWERS", +]; +const FORAGE_TAGS: ForageTag[] = ["MEDICINAL", "IN_SEASON", "COMMUNITY_GARDEN"]; +const BATHROOM_TAGS: BathroomTag[] = [ + "WHEELCHAIR_ACCESSIBLE", + "GENDER_NEUTRAL", + "CHANGING_TABLE", + "SINGLE_OCCUPANCY", + "FAMILY", +]; + +const formatValue = (value: unknown): string => { + if (value === null || value === undefined || value === "") { + return "—"; + } + if (typeof value === "object") { + return JSON.stringify(value); + } + return String(value); +}; + +type EditableValues = { + name: string; + resource_type: ResourceType; + entry_type: EntryType | ""; + address: string; + city: string; + state: string; + zip_code: string; + latitude: number; + longitude: number; + description: string; + guidelines: string; + water: { dispenser_type: DispenserType[]; tags: WaterTag[] }; + food: { + food_type: FoodType[]; + distribution_type: DistributionType[]; + organization_type: OrganizationType[]; + organization_name: string; + organization_url: string; + }; + forage: { forage_type: ForageType[]; tags: ForageTag[] }; + bathroom: { tags: BathroomTag[] }; +}; + +const toEditableValues = (revision: ResourceRevision): EditableValues => ({ + name: revision.name ?? "", + resource_type: revision.resource_type, + entry_type: revision.entry_type ?? "", + address: revision.address ?? "", + city: revision.city ?? "", + state: revision.state ?? "", + zip_code: revision.zip_code ?? "", + latitude: revision.latitude, + longitude: revision.longitude, + description: revision.description ?? "", + guidelines: revision.guidelines ?? "", + water: { + dispenser_type: revision.water?.dispenser_type ?? [], + tags: revision.water?.tags ?? [], + }, + food: { + food_type: revision.food?.food_type ?? [], + distribution_type: revision.food?.distribution_type ?? [], + organization_type: revision.food?.organization_type ?? [], + organization_name: revision.food?.organization_name ?? "", + organization_url: revision.food?.organization_url ?? "", + }, + forage: { + forage_type: revision.forage?.forage_type ?? [], + tags: revision.forage?.tags ?? [], + }, + bathroom: { + tags: revision.bathroom?.tags ?? [], + }, +}); + +export const loader: LoaderFunction = async ({ request, params }) => { + const id = Number(params.id); + if (!Number.isInteger(id)) { + throw data("Not found", { status: 404 }); + } + + const { client } = getDatabaseClient(request); + const revisionAPI = getResourceRevisionAPI(client); + const revision = await revisionAPI.getById(id); + + let resource: ResourceEntry | null = null; + try { + const resourceAPI = getResourceEntryAPI(client); + resource = await resourceAPI.getById(String(revision.mapped_resources)); + } catch { + resource = null; + } + + return { revision, resource }; +}; + +export const action: ActionFunction = async ({ request, params }) => { + const id = Number(params.id); + if (!Number.isInteger(id)) { + throw data("Not found", { status: 404 }); + } + + const { client, headers } = getDatabaseClient(request); + const revisionAPI = getResourceRevisionAPI(client); + const contentType = request.headers.get("content-type") || ""; + + if (contentType.includes("application/json")) { + const body = await request.json(); + + if (body.intent !== "save") { + return data({ message: "Unknown action" }, { status: 400 }); + } + + try { + const updated = await revisionAPI.updateFields(id, body.values); + return data({ message: "Changes saved.", ok: true, revision: updated }); + } catch { + return data({ message: "Failed to save changes." }, { status: 400 }); + } + } + + const formData = await request.formData(); + const intent = formData.get("intent"); + + if (intent !== "approve" && intent !== "reject") { + return data({ message: "Unknown action" }, { status: 400 }); + } + + await revisionAPI.updateStatus( + id, + intent === "approve" ? "APPROVED" : "REJECTED", + ); + + return redirect("/reviews", { headers }); +}; + +const ReviewDetail = () => { + const { revision, resource } = useLoaderData<{ + revision: ResourceRevision; + resource: ResourceEntry | null; + }>(); + const actionData = useActionData<{ message?: string }>(); + const fetcher = useFetcher<{ message?: string; ok?: boolean }>(); + + const [values, setValues] = useState(() => + toEditableValues(revision), + ); + + useEffect(() => { + setValues(toEditableValues(revision)); + }, [revision]); + + const isPending = revision.status === "PENDING"; + const isSaving = fetcher.state !== "idle"; + + const setField = ( + key: K, + value: EditableValues[K], + ) => setValues((v) => ({ ...v, [key]: value })); + + const setWater = ( + key: K, + value: EditableValues["water"][K], + ) => setValues((v) => ({ ...v, water: { ...v.water, [key]: value } })); + + const setFood = ( + key: K, + value: EditableValues["food"][K], + ) => setValues((v) => ({ ...v, food: { ...v.food, [key]: value } })); + + const setForage = ( + key: K, + value: EditableValues["forage"][K], + ) => setValues((v) => ({ ...v, forage: { ...v.forage, [key]: value } })); + + const setBathroom = ( + key: K, + value: EditableValues["bathroom"][K], + ) => setValues((v) => ({ ...v, bathroom: { ...v.bathroom, [key]: value } })); + + const handleSave = () => { + const payload: Partial = { + name: values.name || null, + resource_type: values.resource_type, + entry_type: values.entry_type || null, + address: values.address || null, + city: values.city || null, + state: values.state || null, + zip_code: values.zip_code || null, + latitude: values.latitude, + longitude: values.longitude, + description: values.description || null, + guidelines: values.guidelines || null, + water: values.resource_type === "WATER" ? values.water : null, + food: + values.resource_type === "FOOD" + ? { + ...values.food, + organization_name: values.food.organization_name || undefined, + organization_url: values.food.organization_url || undefined, + } + : null, + forage: values.resource_type === "FORAGE" ? values.forage : null, + bathroom: values.resource_type === "BATHROOM" ? values.bathroom : null, + }; + + // biome-ignore lint/suspicious/noExplicitAny: react-router's JsonValue submit-target type isn't exported to cast against + fetcher.submit({ intent: "save", values: payload } as any, { + method: "post", + encType: "application/json", + }); + }; + + return ( + + + + + Back to review queue + + + + + Review proposed edit + + + + + Submitted{" "} + {new Date(revision.date_created).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + })} + {revision.creator ? ` by ${revision.creator}` : ""} + + + + + {!resource && ( + + The resource this revision targets (#{revision.mapped_resources}) + could not be found — it may have been deleted. + + )} + + + + + + Field + Current + Proposed + + + + Name + {formatValue(resource?.name)} + + setField("name", e.target.value)} + /> + + + + + + Resource type + + {formatValue(resource?.resource_type)} + + + setField("resource_type", e.target.value as ResourceType) + } + > + {RESOURCE_TYPES.map((type) => ( + + {type} + + ))} + + + + + + Entry type + {formatValue(resource?.entry_type)} + + + setField("entry_type", e.target.value as EntryType) + } + > + {ENTRY_TYPES.map((type) => ( + + {type} + + ))} + + + + + + + Description + + {formatValue(resource?.description)} + + setField("description", e.target.value)} + /> + + + + + Guidelines + {formatValue(resource?.guidelines)} + + setField("guidelines", e.target.value)} + /> + + + + + Address + {formatValue(resource?.address)} + + setField("address", e.target.value)} + /> + + + + + City + {formatValue(resource?.city)} + + setField("city", e.target.value)} + /> + + + + + State + {formatValue(resource?.state)} + + setField("state", e.target.value)} + /> + + + + + Zip code + {formatValue(resource?.zip_code)} + + setField("zip_code", e.target.value)} + /> + + + + + Latitude + {formatValue(resource?.latitude)} + + setField("latitude", Number(e.target.value))} + /> + + + + + Longitude + {formatValue(resource?.longitude)} + + + setField("longitude", Number(e.target.value)) + } + /> + + + + + Hours + {formatValue(resource?.hours)} + {formatValue(revision.hours)} + + + + Images + {formatValue(resource?.images)} + {formatValue(revision.images)} + + +
+
+ + {values.resource_type === "WATER" && ( + + + Water details + + + + + Current + + + {formatValue(resource?.water)} + + + + setWater("dispenser_type", val)} + renderInput={(params) => ( + + )} + /> + setWater("tags", val)} + renderInput={(params) => } + /> + + + + )} + + {values.resource_type === "FOOD" && ( + + + Food details + + + + + Current + + + {formatValue(resource?.food)} + + + + setFood("food_type", val)} + renderInput={(params) => ( + + )} + /> + setFood("distribution_type", val)} + renderInput={(params) => ( + + )} + /> + setFood("organization_type", val)} + renderInput={(params) => ( + + )} + /> + setFood("organization_name", e.target.value)} + /> + setFood("organization_url", e.target.value)} + /> + + + + )} + + {values.resource_type === "FORAGE" && ( + + + Forage details + + + + + Current + + + {formatValue(resource?.forage)} + + + + setForage("forage_type", val)} + renderInput={(params) => ( + + )} + /> + setForage("tags", val)} + renderInput={(params) => } + /> + + + + )} + + {values.resource_type === "BATHROOM" && ( + + + Bathroom details + + + + + Current + + + {formatValue(resource?.bathroom)} + + + + setBathroom("tags", val)} + renderInput={(params) => } + /> + + + + )} + + {fetcher.data?.message && ( + + {fetcher.data.message} + + )} + + {actionData?.message && ( + {actionData.message} + )} + + {isPending && ( + + + +
+ + + + +
+
+ )} +
+ ); +}; + +export default ReviewDetail; diff --git a/app/routes/authenticated/reviews/index.tsx b/app/routes/authenticated/reviews/index.tsx new file mode 100644 index 0000000..3c13900 --- /dev/null +++ b/app/routes/authenticated/reviews/index.tsx @@ -0,0 +1,189 @@ +import { + Chip, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getSortedRowModel, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { useMemo, useState } from "react"; +import { type LoaderFunction, useLoaderData, useNavigate } from "react-router"; +import { getDatabaseClient } from "~/api/client.server"; +import { getResourceRevisionAPI } from "~/api/resource-revisions/methods"; +import { authMiddleware } from "~/middleware/auth"; +import type { ResourceRevision } from "~/types/ResourceRevision"; +import { + resourceTypeChipColor, + resourceTypeChipIcon, +} from "~/utils/chipColors"; + +export const middleware = [authMiddleware]; + +export const loader: LoaderFunction = async ({ request }) => { + const { client } = getDatabaseClient(request); + const revisionAPI = getResourceRevisionAPI(client); + const revisions = await revisionAPI.getList({ status: "PENDING" }); + + const resourceIds = [...new Set(revisions.map((r) => r.mapped_resources))]; + const resourceNames = new Map(); + + if (resourceIds.length > 0) { + const { data, error } = await client + .from("resources") + .select("id, name, address") + .in("id", resourceIds); + + if (error) { + throw error; + } + + for (const resource of data ?? []) { + resourceNames.set(resource.id, resource.name || resource.address || null); + } + } + + return { + revisions, + resourceNames: Object.fromEntries(resourceNames), + }; +}; + +type RowData = ResourceRevision & { resourceLabel: string }; + +const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Proposed name", + cell: ({ row }) => row.original.name || "—", + }, + { + accessorKey: "resource_type", + header: "Type", + cell: ({ row }) => ( + + ), + }, + { + accessorKey: "resourceLabel", + header: "Existing resource", + }, + { + accessorKey: "date_created", + header: "Submitted", + cell: ({ row }) => + new Date(row.original.date_created).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }), + }, +]; + +const ReviewsQueue = () => { + const { revisions, resourceNames } = useLoaderData<{ + revisions: ResourceRevision[]; + resourceNames: Record; + }>(); + const navigate = useNavigate(); + const [sorting, setSorting] = useState([ + { id: "date_created", desc: true }, + ]); + + const data: RowData[] = useMemo( + () => + revisions.map((revision) => ({ + ...revision, + resourceLabel: + resourceNames[revision.mapped_resources] || + `Resource #${revision.mapped_resources}`, + })), + [revisions, resourceNames], + ); + + const table = useReactTable({ + data, + columns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + return ( +
+ + Resource edit reviews + + + Proposed edits to existing PHLask resources, 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())} + + ))} + + ))} + +
+
+
+ ); +}; + +export default ReviewsQueue; diff --git a/app/routes/unauthenticated/_layout.tsx b/app/routes/unauthenticated/_layout.tsx index a855b88..0f74ef2 100644 --- a/app/routes/unauthenticated/_layout.tsx +++ b/app/routes/unauthenticated/_layout.tsx @@ -1,23 +1,133 @@ +import { Box } from "@mui/material"; import { Outlet } from "react-router"; import phlasklogo from "~/assets/PHLASK_v2.svg"; +import { ThemeToggle } from "~/components/ThemeToggle"; +import { WaveDivider } from "~/components/WaveDivider"; +import { brand, navy } from "~/theme/theme"; + +const BUBBLES = [ + { left: "8%", size: 14, delay: 0, duration: 9 }, + { left: "18%", size: 8, delay: 2.5, duration: 7 }, + { left: "32%", size: 20, delay: 1, duration: 11 }, + { left: "62%", size: 10, delay: 3.5, duration: 8 }, + { left: "78%", size: 16, delay: 0.5, duration: 10 }, + { left: "90%", size: 9, delay: 2, duration: 7.5 }, +]; export default function UnauthenticatedLayout() { return ( -
- + ({ + position: "relative", + display: "flex", + minHeight: "100vh", + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + px: 2, + py: 6, + backgroundImage: `linear-gradient(to bottom, ${brand[100]}, ${brand[300]}, ${brand[600]})`, + ...theme.applyStyles("dark", { + backgroundImage: `linear-gradient(to bottom, ${navy[950]}, ${navy[900]}, ${navy[800]})`, + }), + })} + > + + + + + {BUBBLES.map((bubble) => ( + ({ + pointerEvents: "none", + position: "absolute", + bottom: 0, + borderRadius: "9999px", + bgcolor: "rgba(255,255,255,0.4)", + ...theme.applyStyles("dark", { + bgcolor: `${brand[200]}33`, + }), + })} + style={{ + left: bubble.left, + width: bubble.size, + height: bubble.size, + animation: `bubble-rise ${bubble.duration}s ease-in infinite`, + animationDelay: `${bubble.delay}s`, + }} + /> + ))} + + ({ + bottom: 0, + ...theme.applyStyles("dark", { opacity: 0.2 }), + })} + /> + ({ + bottom: 0, + ...theme.applyStyles("dark", { opacity: 0.25 }), + })} + /> + ({ + bottom: 0, + ...theme.applyStyles("dark", { opacity: 0.3 }), + })} + /> -
-
+ + + ({ + display: "inline-flex", + borderRadius: 4, + bgcolor: "rgba(255,255,255,0.8)", + p: 1.5, + boxShadow: 2, + backdropFilter: "blur(4px)", + ...theme.applyStyles("dark", { bgcolor: `${navy[950]}99` }), + })} + > + PHLASK Logo + + + ({ + borderRadius: 4, + border: "1px solid rgba(255,255,255,0.5)", + bgcolor: "rgba(255,255,255,0.75)", + p: 4, + boxShadow: 8, + backdropFilter: "blur(8px)", + ...theme.applyStyles("dark", { + borderColor: `${navy[700]}99`, + bgcolor: `${navy[900]}bf`, + }), + })} + > -
-
-
+ + + ); } diff --git a/app/routes/unauthenticated/confirm.tsx b/app/routes/unauthenticated/confirm.tsx new file mode 100644 index 0000000..4893f05 --- /dev/null +++ b/app/routes/unauthenticated/confirm.tsx @@ -0,0 +1,20 @@ +import { type LoaderFunction, redirect } from "react-router"; +import { getDatabaseClient } from "~/api/client.server"; + +export const loader: LoaderFunction = async ({ request }) => { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + + if (!code) { + return redirect("/auth/forgot-password"); + } + + const { client, headers } = getDatabaseClient(request); + const { error } = await client.auth.exchangeCodeForSession(code); + + if (error) { + return redirect("/auth/forgot-password"); + } + + return redirect("/auth/reset-password", { headers }); +}; diff --git a/app/routes/unauthenticated/forgot-password.tsx b/app/routes/unauthenticated/forgot-password.tsx new file mode 100644 index 0000000..2a68578 --- /dev/null +++ b/app/routes/unauthenticated/forgot-password.tsx @@ -0,0 +1,132 @@ +import { + Button, + Link as MuiLink, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { applySchema } from "composable-functions"; +import { + type ActionFunction, + data, + Link, + type LoaderFunction, + redirect, + useActionData, +} from "react-router"; +import { performMutation, SchemaForm } from "remix-forms"; +import { getDatabaseClient } from "~/api/client.server"; +import { forgotPasswordSchema } from "~/schemas/forgot-password"; + +export const loader: LoaderFunction = async ({ request }) => { + const { client } = getDatabaseClient(request); + + const response = await client.auth.getUser(); + if (response.error) { + return null; + } + + return redirect("/"); +}; + +const mutation = applySchema(forgotPasswordSchema)(async (values) => values); + +export const action: ActionFunction = async ({ request }) => { + const result = await performMutation({ + request, + schema: forgotPasswordSchema, + mutation, + }); + + if (!result.success) { + return data(result, 400); + } + + const { client } = getDatabaseClient(request); + const origin = new URL(request.url).origin; + + await client.auth.resetPasswordForEmail(result.data.email, { + redirectTo: `${origin}/auth/confirm`, + }); + + return data({ sent: true }); +}; + +const ForgotPassword = () => { + const action = useActionData<{ sent?: boolean }>(); + + if (action?.sent) { + return ( + + + + Check your email + + + If an account exists for that email, we've sent a link to reset your + password. + + + + Back to sign in + + + ); + } + + return ( + + + + Reset your password + + + Enter the email associated with your account and we'll send you a link + to reset your password. + + + + + {({ Field, formState, register }) => ( + + + {({ name, errors, required }) => ( + + )} + + + + + + Back to sign in + + + )} + + + ); +}; + +export default ForgotPassword; diff --git a/app/routes/unauthenticated/login.tsx b/app/routes/unauthenticated/login.tsx index 9356b99..72cca10 100644 --- a/app/routes/unauthenticated/login.tsx +++ b/app/routes/unauthenticated/login.tsx @@ -1,8 +1,20 @@ -import { Button, Stack, TextField, Typography } from "@mui/material"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; +import { + Alert, + Button, + IconButton, + InputAdornment, + Link as MuiLink, + Stack, + TextField, + Typography, +} from "@mui/material"; import { applySchema } from "composable-functions"; +import { useState } from "react"; import { type ActionFunction, data, + Link, type LoaderFunction, redirect, useActionData, @@ -40,7 +52,10 @@ export const action: ActionFunction = async ({ context, request }) => { const response = await client.auth.signInWithPassword(result.data); if (response.error) { - return data(response.error, { status: response.error.status }); + return data( + { message: response.error.message }, + { status: response.error.status ?? 400 }, + ); } context.set(userContext, response.data.user); @@ -49,57 +64,96 @@ export const action: ActionFunction = async ({ context, request }) => { }; const Login = () => { - const action = useActionData<{ message: string; status: number }>(); + const action = useActionData<{ message?: string }>(); + const [showPassword, setShowPassword] = useState(false); return ( - - Login + + + + Sign in + + + Enter your credentials to access the PHLask admin dashboard. + + + {({ Field, formState, register }) => ( - + - {({ name, errors, required }) => { - return ( - - ); - }} - - {({ name, errors, required }) => ( )} - - {action?.message} - + + + + {({ name, errors, required }) => ( + + setShowPassword((value) => !value)} + edge="end" + tabIndex={-1} + > + {showPassword ? ( + + ) : ( + + )} + + + ), + }, + }} + /> + )} + + + + Forgot password? + + + + {action?.message && ( + {action.message} + )} diff --git a/app/routes/unauthenticated/reset-password.tsx b/app/routes/unauthenticated/reset-password.tsx new file mode 100644 index 0000000..12ff94d --- /dev/null +++ b/app/routes/unauthenticated/reset-password.tsx @@ -0,0 +1,154 @@ +import { Visibility, VisibilityOff } from "@mui/icons-material"; +import { + Alert, + Button, + IconButton, + InputAdornment, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { applySchema } from "composable-functions"; +import { useState } from "react"; +import { + type ActionFunction, + data, + type LoaderFunction, + redirect, + useActionData, +} from "react-router"; +import { performMutation, SchemaForm } from "remix-forms"; +import { getDatabaseClient } from "~/api/client.server"; +import { resetPasswordSchema } from "~/schemas/reset-password"; + +export const loader: LoaderFunction = async ({ request }) => { + const { client } = getDatabaseClient(request); + + const response = await client.auth.getUser(); + if (response.error) { + return redirect("/auth/forgot-password"); + } + + return null; +}; + +const mutation = applySchema(resetPasswordSchema)(async (values) => values); + +export const action: ActionFunction = async ({ request }) => { + const result = await performMutation({ + request, + schema: resetPasswordSchema, + mutation, + }); + + if (!result.success) { + return data(result, 400); + } + + const { client, headers } = getDatabaseClient(request); + const response = await client.auth.updateUser({ + password: result.data.password, + }); + + if (response.error) { + return data( + { message: response.error.message }, + { status: response.error.status ?? 400 }, + ); + } + + return redirect("/", { headers }); +}; + +const ResetPassword = () => { + const action = useActionData<{ message?: string }>(); + const [showPassword, setShowPassword] = useState(false); + + return ( + + + + Set a new password + + + Choose a new password for your account. + + + + + {({ Field, formState, register }) => ( + + + {({ name, errors, required }) => ( + + setShowPassword((value) => !value)} + edge="end" + tabIndex={-1} + > + {showPassword ? ( + + ) : ( + + )} + + + ), + }, + }} + /> + )} + + + + {({ name, errors, required }) => ( + + )} + + + {action?.message && ( + {action.message} + )} + + + + )} + + + ); +}; + +export default ResetPassword; diff --git a/app/schemas/forgot-password.ts b/app/schemas/forgot-password.ts new file mode 100644 index 0000000..531a179 --- /dev/null +++ b/app/schemas/forgot-password.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; + +export const forgotPasswordSchema = z.object({ + email: z.email().min(1), +}); diff --git a/app/schemas/reset-password.ts b/app/schemas/reset-password.ts new file mode 100644 index 0000000..4e0d3b5 --- /dev/null +++ b/app/schemas/reset-password.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const resetPasswordSchema = z + .object({ + password: z + .string() + .min(8, { error: "Password must be at least 8 characters" }), + confirmPassword: z + .string() + .min(1, { error: "Please confirm your password" }), + }) + .refine((values) => values.password === values.confirmPassword, { + error: "Passwords do not match", + path: ["confirmPassword"], + }); diff --git a/app/theme/ThemeModeProvider.tsx b/app/theme/ThemeModeProvider.tsx new file mode 100644 index 0000000..37b77b7 --- /dev/null +++ b/app/theme/ThemeModeProvider.tsx @@ -0,0 +1,62 @@ +import CssBaseline from "@mui/material/CssBaseline"; +import { ThemeProvider } from "@mui/material/styles"; +import { + createContext, + type ReactNode, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { THEME_COOKIE_NAME, type ThemeMode, theme } from "./theme"; + +type ThemeModeContextValue = { + mode: ThemeMode; + toggleMode: () => void; +}; + +const ThemeModeContext = createContext(null); + +export function ThemeModeProvider({ + children, + initialMode, +}: { + children: ReactNode; + initialMode: ThemeMode; +}) { + const [mode, setMode] = useState(initialMode); + + useEffect(() => { + document.documentElement.classList.toggle("dark", mode === "dark"); + // One year, readable by the root loader on the next request so SSR + // renders the right theme with no flash. Not httpOnly: it's a UI + // preference, not a secret, and needs to be writable from here. + // biome-ignore lint/suspicious/noDocumentCookie: Cookie Store API isn't supported in Safari/Firefox yet + document.cookie = `${THEME_COOKIE_NAME}=${mode}; path=/; max-age=31536000; samesite=lax`; + }, [mode]); + + const value = useMemo( + () => ({ + mode, + toggleMode: () => setMode((m) => (m === "dark" ? "light" : "dark")), + }), + [mode], + ); + + return ( + + + + {children} + + + ); +} + +export function useThemeMode(): ThemeModeContextValue { + const ctx = useContext(ThemeModeContext); + if (!ctx) { + throw new Error("useThemeMode must be used within a ThemeModeProvider"); + } + return ctx; +} diff --git a/app/theme/theme-cookie.server.ts b/app/theme/theme-cookie.server.ts new file mode 100644 index 0000000..bfa1122 --- /dev/null +++ b/app/theme/theme-cookie.server.ts @@ -0,0 +1,12 @@ +import { THEME_COOKIE_NAME, type ThemeMode } from "./theme"; + +// Plain, unsigned cookie: it only ever holds "light" | "dark", so there's +// nothing here worth signing/encoding, and keeping it plain lets the client +// write it with a bare `document.cookie =` assignment (see ThemeModeProvider). +export function getThemeMode(request: Request): ThemeMode { + const cookieHeader = request.headers.get("Cookie") ?? ""; + const match = cookieHeader.match( + new RegExp(`(?:^|;\\s*)${THEME_COOKIE_NAME}=(dark|light)`), + ); + return match?.[1] === "dark" ? "dark" : "light"; +} diff --git a/app/theme/theme.ts b/app/theme/theme.ts new file mode 100644 index 0000000..91efe20 --- /dev/null +++ b/app/theme/theme.ts @@ -0,0 +1,135 @@ +import { createTheme, type Theme } from "@mui/material/styles"; + +// Mirrors the brand-* / navy-* tokens in app/app.css (kept in sync manually — +// Tailwind's @theme values aren't importable into JS). +export const brand = { + 50: "#eafaff", + 100: "#d6f4ff", + 200: "#ade8ff", + 300: "#6fd6ff", + 400: "#38c1ff", + 500: "#10b6ff", + 600: "#0090de", + 700: "#0072b3", + 800: "#045c90", + 900: "#0a3a5c", +}; + +export const navy = { + 950: "#071522", + 900: "#0a1929", + 800: "#10263a", + 700: "#17344c", + 600: "#1f4560", +}; + +const teal = { + 300: "#5eead4", + 400: "#2dd4bf", + 500: "#14b8a6", + 600: "#0d9488", + 700: "#0f766e", +}; + +const fontFamily = [ + "Inter", + "ui-sans-serif", + "system-ui", + "sans-serif", + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji", +].join(","); + +const shape = { borderRadius: 14 }; + +const typography = { + fontFamily, + h4: { fontWeight: 700 }, + h5: { fontWeight: 700 }, + subtitle1: { fontWeight: 600 }, + button: { fontWeight: 600, textTransform: "none" as const }, +}; + +// Uses MUI's CSS-variables theming keyed off the same `.dark` class Tailwind +// toggles on (see ThemeModeProvider). Both light and dark rules are +// always present in the generated stylesheet — switching mode is a pure CSS +// selector match, not a JS-driven theme swap — so SSR and the first client +// render can never disagree about which theme object is "active" the way two +// separate light/dark Theme objects picked by JS state could. +export const theme: Theme = createTheme({ + cssVariables: { colorSchemeSelector: "class" }, + shape, + typography, + colorSchemes: { + light: { + palette: { + primary: { main: brand[500], light: brand[300], dark: brand[700] }, + secondary: { main: teal[600], light: teal[400], dark: teal[700] }, + background: { default: brand[50], paper: "#ffffff" }, + divider: brand[100], + text: { primary: navy[900], secondary: "#3d5a70" }, + }, + }, + dark: { + palette: { + primary: { main: brand[400], light: brand[200], dark: brand[600] }, + secondary: { main: teal[400], light: teal[300], dark: teal[600] }, + background: { default: navy[950], paper: navy[900] }, + divider: navy[700], + text: { primary: brand[50], secondary: "#9fc2d6" }, + }, + }, + }, + components: { + MuiPaper: { + styleOverrides: { + root: { backgroundImage: "none" }, + outlined: ({ theme }) => ({ + borderColor: brand[100], + boxShadow: + "0 1px 2px rgba(10,58,92,0.06), 0 8px 20px -10px rgba(10,58,92,0.25)", + ...theme.applyStyles("dark", { + borderColor: navy[700], + boxShadow: "none", + }), + }), + }, + }, + MuiButton: { + styleOverrides: { + root: { borderRadius: 999, paddingInline: 20 }, + contained: ({ theme }) => ({ + boxShadow: `0 6px 16px -4px ${brand[500]}66`, + "&:hover": { boxShadow: `0 8px 20px -4px ${brand[500]}88` }, + ...theme.applyStyles("dark", { + boxShadow: "0 6px 18px -4px #00000080", + "&:hover": { boxShadow: "0 8px 22px -4px #000000a0" }, + }), + }), + }, + }, + MuiChip: { styleOverrides: { root: { fontWeight: 600 } } }, + MuiTableHead: { + styleOverrides: { + root: ({ theme }) => ({ + "& .MuiTableCell-root": { + backgroundColor: brand[50], + ...theme.applyStyles("dark", { backgroundColor: navy[800] }), + }, + }), + }, + }, + MuiTextField: { + defaultProps: { variant: "outlined" }, + }, + MuiOutlinedInput: { + styleOverrides: { root: { borderRadius: 10 } }, + }, + }, +}); + +export type ThemeMode = "light" | "dark"; + +export const THEME_COOKIE_NAME = "phlask-theme-mode"; diff --git a/app/types/ResourceRevision.ts b/app/types/ResourceRevision.ts new file mode 100644 index 0000000..cd79fa6 --- /dev/null +++ b/app/types/ResourceRevision.ts @@ -0,0 +1,29 @@ +import type { ResourceEntry } from "~/types/ResourceEntry"; + +/** + * Review status of a `resource_revisions` row. Note this table reuses the + * `status` column for review state rather than the resource's operational + * status (`ResourceStatus`) — the two are unrelated despite the shared + * column name. + */ +export type RevisionStatus = "PENDING" | "APPROVED" | "REJECTED"; + +/** + * A proposed edit to an existing resource, staged in `resource_revisions`. + * + * Shape mirrors `ResourceEntry` (it's a full proposed snapshot of the + * resource) plus: + * - `mapped_resources`: FK to `resources.id` — the resource this revision + * proposes changes to. Every revision maps to an existing resource; this + * table has no concept of a brand-new, not-yet-existing resource. + * - `mapped_resource`: a second, unconstrained (no FK) int column that + * exists on the table but isn't used by this feature. + * - `status`: review state (`PENDING` / `APPROVED` / `REJECTED`), not the + * resource's operational status. + */ +export type ResourceRevision = Omit & { + id: number; + mapped_resource: number; + mapped_resources: number; + status: RevisionStatus; +}; diff --git a/app/utils/chipColors.tsx b/app/utils/chipColors.tsx new file mode 100644 index 0000000..9509c68 --- /dev/null +++ b/app/utils/chipColors.tsx @@ -0,0 +1,87 @@ +import { LocalDining, Park, WaterDrop, Wc } from "@mui/icons-material"; +import type { ChipProps } from "@mui/material"; +import type { ReactElement } from "react"; +import type { ResourceType } from "~/types/ResourceEntry"; +import type { RevisionStatus } from "~/types/ResourceRevision"; + +export const resourceTypeChipColor = ( + type: ResourceType | string, +): ChipProps["color"] => { + switch (type) { + case "WATER": + return "primary"; + case "FOOD": + return "warning"; + case "FORAGE": + return "success"; + case "BATHROOM": + return "secondary"; + default: + return "default"; + } +}; + +export const resourceTypeChipIcon = ( + type: ResourceType | string, +): ReactElement | undefined => { + switch (type) { + case "WATER": + return ( + + ); + case "FOOD": + return ( + + ); + case "FORAGE": + return ( + + ); + case "BATHROOM": + return ( + + ); + default: + return undefined; + } +}; + +export const statusChipColor = ( + status: RevisionStatus | string, +): ChipProps["color"] => { + switch (status) { + case "PENDING": + return "warning"; + case "APPROVED": + return "success"; + case "REJECTED": + return "error"; + default: + return "default"; + } +}; diff --git a/biome.json b/biome.json index 58f2367..5a9649f 100644 --- a/biome.json +++ b/biome.json @@ -25,7 +25,8 @@ "enabled": true, "rules": { "recommended": true - } + }, + "includes": ["**", "!app/assets/**"] }, "css": { "assist": { diff --git a/docs/implementation-progress.md b/docs/implementation-progress.md new file mode 100644 index 0000000..d73352f --- /dev/null +++ b/docs/implementation-progress.md @@ -0,0 +1,260 @@ +# Implementation Progress + +Running log of build work, piece by piece. Companion to +`resource-review-flow-scoping.md` (which scopes the review/moderation flow +itself) — this doc tracks whatever's actively being built, including +prerequisite work like auth that isn't part of that scoping doc. + +## 2026-07-07 — Login / auth pages redesign + +**Status:** Done. + +**What changed:** +- `app/routes/unauthenticated/_layout.tsx` — replaced the reused + dashboard-sidebar shell with a centered auth-card layout (logo above a + bordered `Paper`-style card), standard pattern for auth screens instead of + a half-populated dashboard sidebar. +- `app/routes/unauthenticated/login.tsx` — added a password visibility + toggle, an MUI `Alert` for Supabase sign-in errors (was a plain caption), + and a "Forgot password?" link. +- Added a full forgot-password flow, since Supabase auth makes it nearly free + and it's a standard expectation for a login page: + - `app/routes/unauthenticated/forgot-password.tsx` — request form, calls + `client.auth.resetPasswordForEmail`. Always shows a generic "check your + email" confirmation regardless of whether the address exists, to avoid + user enumeration (this is also just how Supabase's API behaves). + - `app/routes/unauthenticated/confirm.tsx` — loader-only route. Supabase's + SSR client uses the PKCE flow (`flowType: "pkce"`), so the recovery email + link lands here with a `?code=` param; this route exchanges it for a + session via `exchangeCodeForSession` and redirects to `reset-password`. + - `app/routes/unauthenticated/reset-password.tsx` — new-password form + (with confirm field + visibility toggle), calls `client.auth.updateUser`. + Requires an active (recovery) session — loader redirects to + forgot-password if there isn't one. +- New schemas: `app/schemas/forgot-password.ts`, `app/schemas/reset-password.ts`. +- `app/routes.ts` — registered `forgot-password`, `confirm`, `reset-password` + under the existing `/auth` layout. + +**Decisions made along the way:** +- After a successful password reset, the user lands signed in and is + redirected straight to `/` rather than being forced to log in again — + matches Supabase's own recommended pattern (the recovery session becomes a + normal session once `updateUser` succeeds). +- Kept the existing `remix-forms` / `composable-functions` / zod pattern used + by the original login page rather than introducing a different form + approach, for consistency. + +**Verified:** +- `pnpm typecheck` and `pnpm biome check` clean. +- Dev server smoke-tested via curl: `/auth`, `/auth/forgot-password` render + 200; `/auth/reset-password` and `/auth/confirm` correctly redirect to + `/auth/forgot-password` when there's no recovery session/code; submitting + bad credentials to the login action surfaces "Invalid login credentials" + through the new Alert; submitting the forgot-password form renders the + "Check your email" confirmation state. +- Not yet verified visually in an actual browser (no browser automation tool + available in this session) — worth a manual pass before merging. + +**Not done / explicitly out of scope for this pass:** +- Rate-limit/lockout-specific error copy (deferred per your answer). +- Any changes to the `VerificationButton` bypass or other items from + `resource-review-flow-scoping.md` §8 — unrelated to this piece. + +## 2026-07-07 — Resource edit review dashboard + +**Status:** Done (approve/reject only — no propagation to `resources` yet, by design). + +**Source-of-truth discovery:** the scoping doc's proposed schema +(`resource_submissions` etc.) doesn't exist yet — you pointed me at the +actual live table, `resource_revisions`, which already exists in Supabase. +Since there's no `information_schema` access via the publishable key, I +reverse-engineered its real shape via probing inserts/selects against the +REST API directly (and cleaned up the throwaway test rows after). Findings, +which differ from the scoping doc and are worth knowing for later phases: + +- `resource_revisions` is shaped exactly like `resources`/`ResourceEntry` + (same columns: `name`, `resource_type`, `address`, `water`/`food`/etc.) — + it's a full proposed snapshot, not a sparse diff. +- No `submission_type` (NEW vs EDIT) column exists. Every row has a + required, FK-enforced link to an existing resource (see below), so this + table currently only models **edits to existing resources**, not + brand-new resource submissions. +- The FK to `resources.id` is the confusingly-named `mapped_resources` + column (plural, has the FK constraint). There's *also* a `mapped_resource` + (singular) int column that's required (NOT NULL) but has no FK and isn't + used by this feature — looks like schema cruft, left untouched. +- `status` is free text (no DB-level CHECK constraint) — this build treats + it as `'PENDING' | 'APPROVED' | 'REJECTED'` by convention. Note this + column does double duty awkwardly: on `resources` the same column name + means operational status (OPERATIONAL/HIDDEN/etc.), but on + `resource_revisions` it means review status — there's no separate column + for "what operational status is being proposed." +- No `reviewed_by`, `reviewed_at`, or `rejection_reason` columns — reject + is a bare status flip, no reason is captured today. +- `resources.id` / `resource_revisions.id` are plain integers, not uuids + (despite `ResourceEntry.id` being typed `string` — pre-existing type/DB + mismatch, not something this change touches). +- The table is currently empty in the live DB (phlask-map doesn't write to + it yet, consistent with the scoping doc's §2 finding that "Suggest Edit" + is still a TODO there). + +**What changed:** +- `app/types/ResourceRevision.ts` — new type, `ResourceEntry` shape minus + `id`/`status`, plus `id: number`, `mapped_resource`/`mapped_resources: + number`, and `status: RevisionStatus`. +- `app/api/resource-revisions/methods.ts` — `getList` (optionally filtered + by status, newest first), `getById`, `updateStatus`. +- `app/routes/authenticated/reviews/index.tsx` — queue page. Lists + `PENDING` revisions in a sortable table (`@tanstack/react-table`, per the + scoping doc's suggestion — first real use of that dependency), joined + against `resources` for a human-readable "existing resource" label. + Row click routes to the detail page. Handles the empty-queue state. +- `app/routes/authenticated/reviews/detail.tsx` — detail/diff page. Loads + the revision plus its mapped `resources` row, renders a field-by-field + table (Current vs Proposed) highlighting changed fields. Approve/Reject + buttons post to the route's own action, which just flips + `resource_revisions.status` — per your instruction, does **not** touch + the live `resources` row or write any history yet. Handles the case + where the mapped resource no longer exists. +- `app/routes.ts` — registered `reviews` (index) and `reviews/:id` (detail) + under the authenticated layout. +- `app/routes/authenticated/_layout.tsx` — added a "Reviews" nav link. + +**Decisions made along the way (flagging per your "design questions" carve-out):** +- Approve/Reject currently only updates `resource_revisions.status`. It does + **not** write to `resources` or any history table — matches your explicit + "don't worry about sending approved reviews anywhere for now." This means + approving something here has no visible effect on the live map yet; that + wiring is future work once the target (`resources` update? new + `resource_history` table? something else?) is decided. +- Built one queue (edits to existing resources) rather than the doc's three + (New Resources / Resource Edits / Reports), since `resource_revisions` as + it actually exists can't represent "new resource" or "report" cases (no + submission-type column, always FK'd to an existing resource, no reports + table). If NEW-resource submissions or reports need a queue later, they'll + need their own table(s) or a schema change to this one. +- Diff view compares against the *live* `resources` row at request time + (not a snapshot), so if the underlying resource changes between + submission and review, the diff reflects the current state, not what the + submitter saw. + +**Verified:** +- `pnpm typecheck` and `pnpm biome check` clean. +- Confirmed via direct REST calls against the Supabase table (not just + reading code) that `resource_revisions`' real columns match what's coded + above, and that inserts/updates against it behave as assumed. +- Dev server smoke test: `/reviews` and `/reviews/:id` both correctly + 302-redirect to `/auth` when unauthenticated (confirms the route-level + `authMiddleware` is wired up). Did not verify the authenticated render + path against a real logged-in session in this pass (no test credentials + in hand) — worth a manual pass, same caveat as the login-page piece. + +**Not done / explicitly out of scope for this pass:** +- Approve/Reject propagating to `resources` or `resource_history` — you + said we'll figure that out later. +- New Resources queue, Reports queue, rollback/history timeline — blocked + on schema that doesn't exist yet (see discovery notes above). +- `reviewed_by`/`rejection_reason` capture — no columns for it today. + +## 2026-07-07 — Dashboard summary page + +**Status:** Done, with one stat explicitly blocked on schema. + +**What changed:** +- `app/routes/authenticated/dashboard.tsx` — replaced the placeholder with a + real loader that fetches all `resource_revisions` and aggregates in JS + (no DB-side aggregation available beyond REST filters): top 5 submitters + by revision count, outstanding (`PENDING`) count per resource type (all + four types always shown, defaulting to 0), plus pending/total stat cards. +- "Top approvers" is rendered but explicitly disabled with an explanatory + tooltip: `resource_revisions` has no `reviewed_by` column, so there's no + record of who approved/rejected a given revision. Can't add that column + myself — no service-role key or DB connection string in `.env`, only the + publishable key (REST-level CRUD, no DDL). Needs a schema change (add + `reviewed_by`, set it in the approve/reject action) before this is + buildable. + +## 2026-07-07 — Editable review detail page + +**Status:** Done. + +**What changed:** +- `app/api/resource-revisions/methods.ts` — added `updateFields(id, values)`, + a partial update against `resource_revisions` (separate from + `updateStatus`, which only flips `status`). +- `app/routes/authenticated/reviews/detail.tsx` — rewritten. The Proposed + column of the diff table is now editable (scalar fields as text/number/ + select inputs; water/food/forage/bathroom info as `Autocomplete` chip + pickers scoped to the current `resource_type`, switching live if you + change the type). Hours/images stay read-only — no editor UI for those + yet. A "Save changes" button submits via `useFetcher` as a JSON body + (`{ intent: "save", values }`); Approve/Reject remain plain `
` + FormData posts. The route's `action` dispatches on the request's + `Content-Type` header to tell the two apart. Editing is only enabled + while `status === "PENDING"` — approved/rejected revisions render + read-only. +- Verified via `pnpm typecheck` / `pnpm biome check` (clean) and a curl + smoke test against a locally-running dev server (temporarily disabling + `authMiddleware`, then reverting): `/reviews`, `/reviews/15`, + `/reviews/16`, `/reviews/17` all render 200 with no server-side errors, + and a JSON POST of `{"intent":"save",...}` against revision 15 round- + tripped through Supabase correctly ("Changes saved"). + +**Not done:** no diff/preview before saving, no undo — saving writes +directly to the revision row immediately. + +## Fixed: "page freezes and crashes" when opening a review + +**Status:** Done. + +Reported symptom: clicking a resource in the reviews queue did nothing — +no navigation, no error page, just silence (a truer description than the +original "crash" framing). Root-caused this pass by installing Playwright +for real browser-level testing (no browser access in earlier passes, which +is why it went unresolved for two prior sessions). + +**Root cause:** `app/middleware/auth.ts` exports `authMiddleware`, which +imports the genuinely server-only `~/api/client.server` (Supabase SSR +client + secrets). Every route using `export const middleware = +[authMiddleware]` (`reviews/index.tsx`, `reviews/detail.tsx`, +`dashboard.tsx`) therefore has a top-level `import { authMiddleware } from +"~/middleware/auth"` that's supposed to be stripped from the client bundle +(React Router auto-removes server-only route exports like `middleware` +from what ships to the browser). With `future.v8_splitRouteModules: true` +enabled in `react-router.config.ts`, that stripping silently failed in dev +mode: the browser's client-side navigation (clicking a row calls +`navigate()`, which lazy-loads the target route's module) ended up +requesting `/app/middleware/auth.ts` directly, which 500'd with "Server-only +module referenced by client" (Vite's guard against `.server.ts` code +reaching the client). The failed module load silently aborted the +navigation — URL never changed, UI appeared to do nothing, which read as a +freeze. + +**Fix:** disabled the `v8_splitRouteModules` future flag in +`react-router.config.ts` (kept `v8_middleware` and `v8_viteEnvironmentApi`, +both still needed). This is an opt-in, still-evolving code-splitting +optimization for RR 7.13.0 — the more basic "strip server-only route +exports from the client bundle" mechanism it layers on top of works +correctly without it. Verified via Playwright: after the fix, `/app` no +longer appears in the browser's request graph for `auth.ts` at all, and +clicking a review row in `/reviews` correctly navigates to `/reviews/:id` +with no console errors, no failed requests, no error overlay. Also +confirmed `/` (dashboard) still loads and renders correctly. + +**Not investigated further:** whether this is a known upstream bug in RR +7.13.0's `v8_splitRouteModules` + `v8_middleware` combination, or something +fixable by restructuring the import (e.g. not sharing a single +`authMiddleware` module across every leaf route file). Given +`v8_splitRouteModules` is purely a performance optimization (lazy-loads +`clientLoader`/`clientAction`/etc. into separate chunks) and not required +for anything this app currently does, turning it off was the lower-risk +fix over reverse-engineering the splitter's dead-code-elimination bug. +Worth revisiting if the app later depends on that optimization, or on an +RR upgrade past 7.13.0. + +## Next up + +Not yet started — pending your direction on which piece to tackle next: +propagating approved reviews to `resources` (and deciding what that even +means given the current schema — direct update? new `resource_history` +table?), or a different item from `resource-review-flow-scoping.md` §9. diff --git a/docs/resource-review-flow-scoping.md b/docs/resource-review-flow-scoping.md new file mode 100644 index 0000000..8e6f61c --- /dev/null +++ b/docs/resource-review-flow-scoping.md @@ -0,0 +1,191 @@ +# Scoping: Crowdsourced Resource Review Flow + +Status: Draft for discussion +Owner: TBD +Related: README's "Review and approve/reject suggested edits," "View and resolve reports," "View resource changelogs and roll back changes" + +## 1. Goal + +Let admins review, approve, or reject crowdsourced submissions (new resources, edits to +existing resources, and reports flagging problems with a resource) before they affect the +live PHLask map, and keep a rollback-able version history of every resource. + +## 2. Current state (verified against the codebase, 2026-07-07) + +**admin-dashboard** (this repo): Supabase client + auth are wired up. A `resources` table +and matching `ResourceEntry` type exist (`app/types/ResourceEntry.ts`). One CRUD API +(`app/api/resources/methods.ts`) against that table only. No review UI, no edits/reports/ +history tables, no roles. The dashboard route tree currently has a single placeholder +Dashboard page. + +**phlask-map** (sibling repo, the live community-facing app): "Add Resource" is fully built +and inserts **directly into the live `resources` table** — no staging step. "Suggest Edit" +is a menu item with an unimplemented handler (`// TODO`). "Report" has no handler at all. +There is no `edits`/`suggestions`/`reports` table anywhere, and no existing concept of +linking a proposed change to a `resource_id`. The only edit mechanism today is a +password-gated `VerificationButton` that upserts straight onto the live row, bypassing +any review step. + +**Conclusion:** everything below is greenfield. This doc scopes the admin-dashboard side +in full (schema, review UI, approve/reject, versioning) and defines the data contract +phlask-map must satisfy — it does not design phlask-map's Suggest Edit / Report form UX, +which is a separate scoping effort in that repo. + +## 3. Out of scope for this doc + +- phlask-map UI/UX for the Suggest Edit and Report forms (tracked as a dependency, §7). +- Notifying submitters of approval/rejection (no submitter accounts exist today). +- Reversing the `VerificationButton` bypass path in phlask-map (flagged as a risk, §8). + +## 4. Proposed data model (Supabase / Postgres) + +### 4.1 `resource_submissions` +Unified staging table for both new-resource and edit-to-existing submissions — matches +the diagram's single "Resources Table(s)" feeding two dashboard views via a discriminator. + +| column | type | notes | +|---|---|---| +| `id` | uuid pk | | +| `submission_type` | `'NEW' \| 'EDIT'` | set by auto-matching (§5.1) | +| `target_resource_id` | uuid, nullable, FK → resources.id | set for EDIT; null for NEW | +| `match_confidence` | numeric, nullable | populated when matched automatically rather than by explicit resource_id | +| `proposed_data` | jsonb | full `ResourceEntry`-shaped payload | +| `source` | jsonb | submitter contact/context, if any (phlask-map has no submitter accounts today) | +| `status` | `'PENDING' \| 'APPROVED' \| 'REJECTED'` | soft-delete on reject, not a hard delete | +| `rejection_reason` | text, nullable | | +| `reviewed_by` | uuid, nullable, FK → admin user | | +| `reviewed_at` | timestamptz, nullable | | +| `created_at` | timestamptz | | + +### 4.2 `resource_reports` +| column | type | notes | +|---|---|---| +| `id` | uuid pk | | +| `resource_id` | uuid, FK → resources.id | | +| `report_type` | `'CLOSED' \| 'INACCURATE' \| 'INAPPROPRIATE' \| 'OTHER'` | | +| `description` | text | | +| `source` | jsonb | submitter contact/context, optional | +| `status` | `'PENDING' \| 'RESOLVED' \| 'DISMISSED'` | | +| `resolution_notes` | text, nullable | | +| `resolved_by` | uuid, nullable, FK → admin user | | +| `resolved_at` | timestamptz, nullable | | +| `created_at` | timestamptz | | + +### 4.3 `resource_history` +Linear, append-only version log. A rollback writes a **new** version that copies an old +snapshot forward (like a revert commit) rather than mutating history — this is what the +diagram's "Git logic as instance saving" annotation is read as here; full branch/merge +semantics are out of scope per your answer. + +| column | type | notes | +|---|---|---| +| `id` | uuid pk | | +| `resource_id` | uuid, FK → resources.id | | +| `version` | int | matches `resources.version`, increments monotonically | +| `snapshot` | jsonb | full `ResourceEntry` at this version | +| `change_source` | `'SUBMISSION_APPROVAL' \| 'ADMIN_DIRECT_EDIT' \| 'ROLLBACK'` | | +| `changed_by` | uuid, FK → admin user | | +| `source_submission_id` | uuid, nullable, FK → resource_submissions.id | traceability back to the approved submission | +| `created_at` | timestamptz | | + +### 4.4 `admin_roles` +Per your answer, v1 needs more than one permission level. + +| column | type | notes | +|---|---|---| +| `user_id` | uuid, FK → auth.users | | +| `role` | `'REVIEWER' \| 'ADMIN'` | REVIEWER: approve/reject submissions & reports. ADMIN: also rollback history, manage roles | + +Open question: does this need a third tier, or is Reviewer/Admin sufficient for launch? + +## 5. Flow + +### 5.1 Intake & classification +1. phlask-map writes a row to `resource_submissions` (contract in §7) instead of directly + to `resources`. +2. A Postgres trigger or Supabase Edge Function runs automatic matching: if + `target_resource_id` wasn't supplied, match on proximity (lat/lng within a threshold) + + name similarity against existing `resources`. Strong match → `submission_type = EDIT`, + `target_resource_id` set, `match_confidence` recorded. No match → `submission_type = NEW`. + Reviewers can override this classification in the UI regardless of the auto-tag. +3. `resource_reports` rows land directly (no classification needed, already tied to a + `resource_id`). + +### 5.2 Review queues (admin-dashboard UI) +Three queues, matching the diagram's split dashboard views plus Reports: +- **New Resources** — `resource_submissions` where `submission_type = NEW`, `status = PENDING` +- **Resource Edits** — `resource_submissions` where `submission_type = EDIT`, `status = PENDING` +- **Reports** — `resource_reports` where `status = PENDING` + +### 5.3 Review detail / "In Review" mode +For an EDIT: side-by-side diff of existing `resources` row vs. `proposed_data` (the +diagram's "Existing site data" / "Suggested site data" panes), field-by-field. +For a NEW resource: single preview of `proposed_data` (nothing to diff against), plus a +flag if `match_confidence` was borderline so the reviewer can manually link it to an +existing resource instead of creating a duplicate. +For a Report: resource context + report description, with Resolve/Dismiss actions. + +Actions: +- **Approve** — NEW: insert into `resources`, create `resource_history` v1. + EDIT: update `resources` row, increment `resources.version`, append `resource_history` + entry. Either way, mark the submission `APPROVED`. +- **Reject** — mark `resource_submissions.status = REJECTED` with reason; no changes to + `resources`. Row is kept, not deleted (audit trail). +- **Resolve / Dismiss** (reports) — mark `resource_reports.status`; resolving a report does + not itself change the resource — a follow-up edit/verification does that separately. + +### 5.4 History & rollback +Per resource, a version timeline view backed by `resource_history`. Selecting an older +version and confirming "Roll back" writes a new `resource_history` row (and updates +`resources`) whose snapshot equals the selected old version — preserving full lineage +rather than deleting intervening history. + +## 6. Admin-dashboard scope (this repo) + +New routes (under the existing authenticated layout): +- `/reviews/new-resources`, `/reviews/edits`, `/reviews/reports` — queues +- `/reviews/submissions/:id` — diff/detail + approve/reject +- `/reviews/reports/:id` — report detail + resolve/dismiss +- `/resources/:id/history` — version timeline + rollback + +New Supabase tables: `resource_submissions`, `resource_reports`, `resource_history`, +`admin_roles` (§4), plus RLS policies scoping writes to authenticated admins and +role-gating rollback to `ADMIN`. +`@tanstack/react-table` is already installed and unused — natural fit for the three queues. + +## 7. Contract required from phlask-map (tracked as external dependency, not designed here) + +- "Add Resource" must stop inserting directly into `resources`; it inserts into + `resource_submissions` with `submission_type = 'NEW'`, `target_resource_id = null`. +- "Suggest Edit" must be implemented: inserts into `resource_submissions` with + `submission_type = 'EDIT'`, `target_resource_id` set to the resource being edited, + `proposed_data` containing only the changed fields merged over current values (exact + merge strategy TBD with phlask-map team). +- "Report" must be implemented: inserts into `resource_reports` with `resource_id` and + `report_type`. +- The `VerificationButton` direct-upsert bypass either needs to be retired in favor of + this flow, or explicitly kept as an admin-only fast path (decide in §8). + +## 8. Open questions / risks + +- **`VerificationButton` bypass**: phlask-map already has a password-gated direct-edit + path that writes straight to `resources`, skipping review and history entirely. Does + this get retired, or coexist? If it coexists, `resource_history` will have gaps. + **Needs a decision before this ships**, since it undermines the "every change has a + version + audit trail" guarantee. +- **Matching threshold**: what proximity/name-similarity threshold counts as a confident + auto-match for NEW vs EDIT classification? Needs tuning against real data, likely a + follow-up spike rather than something to nail down in this doc. +- **Admin role bootstrap**: who assigns the first `ADMIN` role, and how (manual SQL vs. a + seed script)? +- **Duplicate resources already in the wild**: since Add Resource has been inserting + directly with no dedup, is a one-time cleanup pass needed before this launches? + +## 9. Suggested phasing + +1. Schema + RLS (`resource_submissions`, `resource_reports`, `resource_history`, `admin_roles`) +2. Review queues + detail/diff view + approve/reject (New Resources, Resource Edits) +3. History timeline + rollback +4. Reports queue +5. Coordinate with phlask-map team on the intake contract (§7) — can happen in parallel + with 1–3 once the schema is settled diff --git a/package.json b/package.json index ca30df3..798bbe5 100644 --- a/package.json +++ b/package.json @@ -33,12 +33,10 @@ "devDependencies": { "@biomejs/biome": "2.3.14", "@react-router/dev": "^7.13.0", - "@tailwindcss/vite": "^4.1.18", "@types/node": "^22.19.11", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "lefthook": "^2.1.1", - "tailwindcss": "^4.1.18", "typescript": "^5.9.3", "vite": "^7.3.1", "vite-tsconfig-paths": "^5.1.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f33c4e3..9890669 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,9 +66,6 @@ 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)) - '@tailwindcss/vite': - specifier: ^4.1.18 - version: 4.1.18(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)) '@types/node': specifier: ^22.19.11 version: 22.19.11 @@ -81,9 +78,6 @@ importers: lefthook: specifier: ^2.1.1 version: 2.1.1 - tailwindcss: - specifier: ^4.1.18 - version: 4.1.18 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -831,100 +825,6 @@ packages: resolution: {integrity: sha512-KaIHsO6LU1cuqpYMsprQhfedRJFuTAf4dr8/k4/4Po6urRVXIWzmdY/lHPnf8Mzw2Fh0ueQVQYdaXzOvCBGnGQ==} engines: {node: '>=20.0.0'} - '@tailwindcss/node@4.1.18': - resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} - - '@tailwindcss/oxide-android-arm64@4.1.18': - resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [android] - - '@tailwindcss/oxide-darwin-arm64@4.1.18': - resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@tailwindcss/oxide-darwin-x64@4.1.18': - resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@tailwindcss/oxide-freebsd-x64@4.1.18': - resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [freebsd] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': - resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': - resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': - resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': - resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@tailwindcss/oxide-linux-x64-musl@4.1.18': - resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@tailwindcss/oxide-wasm32-wasi@4.1.18': - resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib - - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': - resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': - resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@tailwindcss/oxide@4.1.18': - resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} - engines: {node: '>= 10'} - - '@tailwindcss/vite@4.1.18': - resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==} - peerDependencies: - vite: ^5.2.0 || ^6 || ^7 - '@tanstack/react-table@8.21.3': resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} engines: {node: '>=12'} @@ -1135,10 +1035,6 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - enhanced-resolve@5.19.0: - resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} - engines: {node: '>=10.13.0'} - error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -1243,9 +1139,6 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -1452,9 +1345,6 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1748,13 +1638,6 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - tailwindcss@4.1.18: - resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} - - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} - engines: {node: '>=6'} - tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -2585,74 +2468,6 @@ snapshots: - bufferutil - utf-8-validate - '@tailwindcss/node@4.1.18': - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.19.0 - jiti: 2.6.1 - lightningcss: 1.30.2 - magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.1.18 - - '@tailwindcss/oxide-android-arm64@4.1.18': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.1.18': - optional: true - - '@tailwindcss/oxide-darwin-x64@4.1.18': - optional: true - - '@tailwindcss/oxide-freebsd-x64@4.1.18': - optional: true - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': - optional: true - - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': - optional: true - - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': - optional: true - - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': - optional: true - - '@tailwindcss/oxide-linux-x64-musl@4.1.18': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.1.18': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': - optional: true - - '@tailwindcss/oxide@4.1.18': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-x64': 4.1.18 - '@tailwindcss/oxide-freebsd-x64': 4.1.18 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-x64-musl': 4.1.18 - '@tailwindcss/oxide-wasm32-wasi': 4.1.18 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - - '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2))': - dependencies: - '@tailwindcss/node': 4.1.18 - '@tailwindcss/oxide': 4.1.18 - tailwindcss: 4.1.18 - vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2) - '@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/table-core': 8.21.3 @@ -2832,7 +2647,8 @@ snapshots: destroy@1.2.0: {} - detect-libc@2.1.2: {} + detect-libc@2.1.2: + optional: true dom-helpers@5.2.1: dependencies: @@ -2851,11 +2667,6 @@ snapshots: encodeurl@2.0.0: {} - enhanced-resolve@5.19.0: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.0 - error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -3000,8 +2811,6 @@ snapshots: gopd@1.2.0: {} - graceful-fs@4.2.11: {} - has-symbols@1.1.0: {} hasown@2.0.2: @@ -3043,7 +2852,8 @@ snapshots: isbot@5.1.35: {} - jiti@2.6.1: {} + jiti@2.6.1: + optional: true js-tokens@4.0.0: {} @@ -3144,6 +2954,7 @@ snapshots: lightningcss-linux-x64-musl: 1.30.2 lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 + optional: true lines-and-columns@1.2.4: {} @@ -3157,10 +2968,6 @@ snapshots: dependencies: yallist: 3.1.1 - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - math-intrinsics@1.1.0: {} media-typer@0.3.0: {} @@ -3453,10 +3260,6 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - tailwindcss@4.1.18: {} - - tapable@2.3.0: {} - tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) diff --git a/vite.config.ts b/vite.config.ts index 3f3f041..c4140c9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,9 @@ import { reactRouter } from "@react-router/dev/vite"; -import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ - plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], + plugins: [reactRouter(), tsconfigPaths()], server: { port: 5174, },