Skip to content
Closed
2 changes: 1 addition & 1 deletion NOTICE
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
civfix — the operator dashboard
civfix: the operator dashboard
Copyright (c) 2026 Reach Out Los Angeles Inc. and contributors

This program is free software: you can redistribute it and/or modify it under
Expand Down
21 changes: 6 additions & 15 deletions apps/admin/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,15 @@ import { tokens } from "@civfix/shared/tokens"

import { Providers } from "@/components/providers"
import "./globals.css"
// The ported design system CSS (warm-paper tokens + every shell/component class from the PinIt Admin
// handoff). Imported AFTER globals.css so the design's component classes win over Tailwind's base
// reset wherever they overlap on the shell. admin.css @imports colors-and-type.css itself.
// After globals.css so the design system's component classes win over Tailwind's base reset where
// they overlap on the shell.
import "@/styles/admin.css"
// Dashboard app additions the prototype lacked: the Cloudflare Access sign-in gate, boot screen, inline
// spinner, and loading / error / empty states. Built on the same design tokens; imported last so it can
// layer on top of the ported design CSS.
// Last, so the app's own additions layer on top of the design system CSS.
import "@/styles/app.css"

/**
* Fonts are loaded via next/font/google and exposed DIRECTLY as the CSS variables the ported design CSS
* consumes (--font-display-next / --font-body-next / --font-mono-next, see colors-and-type.css). Those
* design vars then build the final --font-display / --font-body / --font-mono stacks with literal
* fallbacks. The next/font variable names MUST differ from the design's own --font-display/body/mono, or
* the alias becomes a circular var() reference (which CSS invalidates -> serif fallback). The families
* match tokens.font (Bricolage Grotesque / Manrope / JetBrains Mono), identical to community-web.
*/
// colors-and-type.css builds the --font-display/body/mono stacks from these *-next variables. The names
// must differ from the stacks' own, or the alias becomes a circular var() that CSS invalidates, falling
// back to serif.
const display = Bricolage_Grotesque({
subsets: ["latin"],
variable: "--font-display-next",
Expand Down Expand Up @@ -49,7 +41,6 @@ export const metadata: Metadata = {
}

export const viewport: Viewport = {
// Sourced from the shared token (neutral.paper) so browser chrome matches the app background.
themeColor: tokens.color.neutral.paper,
width: "device-width",
initialScale: 1,
Expand Down
8 changes: 2 additions & 6 deletions apps/admin/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import { AppShell } from "@/components/shell/app-shell"

/**
* The dashboard is a single client-rooted SPA shell. The static export emits just the HTML shell + JS;
* the login gate (providers.tsx) and AppShell render entirely on the client, and all data is fetched
* at runtime. There is exactly one route - section navigation is client-side page state (no Next
* routes), so the export produces a single out/index.html plus the SPA fallback in public/_redirects.
*/
// The only route: sections are client-side page state, so the static export emits a single
// out/index.html, served for every path by the SPA fallback in public/_redirects.
export default function HomePage() {
return <AppShell />
}
11 changes: 0 additions & 11 deletions apps/admin/src/components/auth/auth-hydrator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,6 @@ import * as React from "react"

import { useOperatorBootstrap } from "@/hooks/use-admin-auth"

/**
* Bootstraps the operator session via Cloudflare Access exactly once on mount (doc 16, same-origin).
*
* The SPA and the `/admin/*` API are served behind the same Access app on the same origin, so by the time
* this runs the browser already holds the Access cookie. The bootstrap (useOperatorBootstrap) first reuses
* a still-valid operator session (GET /admin/auth/session), and otherwise exchanges the edge-injected
* Access JWT (POST /admin/auth/access/exchange) for one. On success the auth store flips to authenticated
* and the gate (providers.tsx) renders the dashboard; a 403 (authenticated but not allowlisted) lands on
* the not-authorized state; any other failure lands on the retryable login screen. No cross-origin cookie
* bootstrap / redirect is needed in this same-origin deployment.
*/
export function AuthHydrator() {
const bootstrap = useOperatorBootstrap()

Expand Down
17 changes: 3 additions & 14 deletions apps/admin/src/components/icons.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,14 @@
/**
* Icons (Lucide-style inline SVG), ported from the design prototype (icons.jsx) to a typed TSX module.
* Stroke 1.75, round caps/joins, currentColor. Every name the design's `Icons.*` set used is present,
* so ported components reference `Icons.Pin`, `Icons.ChevronRight`, etc. exactly as in the prototype.
*/

export interface IconProps {
/** Pixel size for width + height (viewBox stays 24). Defaults to 16. */
size?: number
fill?: string
stroke?: string
/** Stroke width. Defaults to 1.75. */
/** Stroke width. */
sw?: number
className?: string
}

interface BaseIconProps extends IconProps {
/** A single path string, OR... */
d?: string
/** ...several path strings. */
paths?: string[]
}

Expand Down Expand Up @@ -48,12 +39,10 @@ function Icon({
)
}

/** A single icon component: takes IconProps (size/fill/stroke/sw/className). */
export type IconComponent = (props: IconProps) => React.ReactElement

// NOTE: declared as a plain object literal (not `Record<string, IconComponent>`) so that each known
// key (Icons.Pin, Icons.ChevronRight, ...) is non-optional under `noUncheckedIndexedAccess`. The
// `satisfies` clause still enforces that every value is a valid IconComponent.
// A plain literal with `satisfies`, not `Record<string, IconComponent>`, so every known key stays
// non-optional under `noUncheckedIndexedAccess`.
export const Icons = {
Pin: (p) => (
<Icon
Expand Down
21 changes: 5 additions & 16 deletions apps/admin/src/components/map/boundary-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,10 @@ import L from "leaflet"

import { withCartoKey } from "@/lib/carto"

/** The GeoJSON object type L.geoJSON accepts, derived from Leaflet's own signature (avoids a direct @types/geojson import). */
// Derived from Leaflet's own signature, so no direct @types/geojson dependency is needed.
type LeafletGeoJson = Parameters<typeof L.geoJSON>[0]

/**
* A small Leaflet map that draws ONE jurisdiction's boundary polygon and fits to it — the directory's
* "is this in the right place?" verification view. CARTO Voyager raster basemap (same tiles as the
* marker map.tsx); the polygon is the server-simplified GeoJSON from GET /admin/jurisdictions/:geoid/
* geometry. Like leaflet-map.tsx this imports Leaflet at the top level, so it MUST be loaded client-only
* via next/dynamic (ssr:false). Leaflet's CSS is imported globally in globals.css.
*/
// Leaflet touches window at import, so this module must be loaded through next/dynamic with ssr:false.

const TILE = {
url: withCartoKey("https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"),
Expand All @@ -23,7 +17,6 @@ const TILE = {
subdomains: "abcd",
}

/** Boundary fill/stroke by jurisdiction layer, so a city reads differently from a county/federal land. */
const LAYER_COLOR: Record<string, string> = {
place: "#5B8C6E",
county: "#3F7CAC",
Expand All @@ -33,11 +26,9 @@ const LAYER_COLOR: Record<string, string> = {
}

export interface BoundaryMapProps {
/** GeoJSON Polygon/MultiPolygon geometry. */
geometry: { type: string; coordinates: unknown }
/** [west, south, east, north] for fit-bounds. */
/** [west, south, east, north] */
bbox: [number, number, number, number]
/** Jurisdiction layer, drives the boundary color. */
layer?: string
}

Expand All @@ -46,13 +37,13 @@ export function BoundaryMap({ geometry, bbox, layer = "place" }: BoundaryMapProp
const mapRef = React.useRef<L.Map | null>(null)
const shapeRef = React.useRef<L.GeoJSON | null>(null)

// Create the map once.
React.useEffect(() => {
if (!elRef.current || mapRef.current) return
const map = L.map(elRef.current, {
zoomControl: true,
attributionControl: true,
scrollWheelZoom: false, // require an explicit zoom (it sits inside a scrollable detail panel)
// The map sits inside a scrollable detail panel, so the wheel must scroll the panel.
scrollWheelZoom: false,
})
mapRef.current = map
L.tileLayer(TILE.url, {
Expand All @@ -73,10 +64,8 @@ export function BoundaryMap({ geometry, bbox, layer = "place" }: BoundaryMapProp
mapRef.current = null
shapeRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

// Draw / redraw the boundary and fit to its bbox whenever the geometry changes.
React.useEffect(() => {
const map = mapRef.current
if (!map) return
Expand Down
50 changes: 13 additions & 37 deletions apps/admin/src/components/map/leaflet-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,16 @@ import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation
import { withCartoKey } from "@/lib/carto"
import { CATEGORY_GLYPHS } from "@/lib/category"

/**
* The civfix universal map (ported from the design's map.jsx PinItMap). Real OpenStreetMap data via
* Leaflet + CARTO raster tiles - a deliberate design-fidelity choice for this internal tool (it matches
* the prototype exactly and does NOT use MapLibre/pmtiles like community-web).
*
* This module imports Leaflet at the top level, so it MUST only ever be loaded on the client. It is
* imported via next/dynamic with `ssr:false` from live-map.tsx (and any future minimap). Leaflet's CSS
* is imported globally in src/app/globals.css.
*/
// Leaflet rather than community-web's MapLibre seam: a deliberate choice for this internal tool.
// Leaflet touches window at import, so this module must be loaded through next/dynamic with ssr:false.

export interface MapPin {
/** Stable marker id. */
id: string
lat: number
lng: number
/** Report category for the glyph; null/omitted falls back to a generic pin. */
/** Null or omitted falls back to the generic glyph. */
category?: string | null
/** When true, renders the red "needs attention" pin. */
/** Renders the red "needs attention" pin. */
draft?: boolean
/** "event" renders the yellow cleanup pin; "event-volunteer" the moss other-volunteer pin. */
kind?: string | null
Expand Down Expand Up @@ -53,16 +45,15 @@ export type MapTint = keyof typeof MAP_TILES

export const MAP_HOME = { center: [39.5, -98.35] as [number, number], zoom: 4 }

// Brand pin as a Leaflet divIcon. Routed jurisdictions render gray with a glyph; jurisdictions that
// still need a routing contact render red; events render yellow. (Ported from map.jsx.)
// Report pins are gray once routed and red while they still need a routing contact; events take the
// fill of their kind.
const TEARDROP =
"M32 4 C46 4 58 16 58 30 C58 46 40 60 34 68 C33 69 31 69 30 68 C24 60 6 46 6 30 C6 16 18 4 32 4 Z"
const GLYPHS: Record<string, string> = {
...CATEGORY_GLYPHS,
cleanup:
"M3 6 L21 6 M19 6 V20 a2 2 0 0 1 -2 2 H7 a2 2 0 0 1 -2 -2 V6 M9 6 V4 a1 1 0 0 1 1 -1 h4 a1 1 0 0 1 1 1 V6 M9 11 V17 M12 11 V17 M15 11 V17",
event: "M4 7 a1 1 0 0 1 1 -1 h14 a1 1 0 0 1 1 1 v12 a1 1 0 0 1 -1 1 H5 a1 1 0 0 1 -1 -1 Z M16 4 v4 M8 4 v4 M4 11 h16",
// "Other Volunteer" events: a cupped-hands-with-heart glyph, distinct from the cleanup calendar.
"event-volunteer":
"M12 9 a2 2 0 0 1 3 -1.3 a2 2 0 0 1 0.5 3 L12 14 L8.5 10.7 a2 2 0 0 1 0.5 -3 A2 2 0 0 1 12 9 Z M4 13 v5 a1 1 0 0 0 1 1 h2 v-6 Z M20 13 v5 a1 1 0 0 1 -1 1 h-2 v-6 Z",
}
Expand All @@ -72,15 +63,13 @@ const PIN_FILL: Record<string, string> = {
event: "#E5AE1C",
"event-volunteer": "#5B8C6E",
}
// Marker kinds that render with the "event" family of treatments (no draft/needs override).
// Event kinds own their fill and glyph; `draft` never overrides them.
const EVENT_KINDS = new Set(["event", "event-volunteer"])

function pinIcon(
category: string | null | undefined,
{ active = false, draft = false, kind = null }: { active?: boolean; draft?: boolean; kind?: string | null },
): L.DivIcon {
// Event markers (cleanup / other-volunteer) own their own fill + glyph by kind; everything else is a
// report pin (red when it needs attention, gray when handled).
const isEvent = kind != null && EVENT_KINDS.has(kind)
const state = isEvent ? kind : draft ? "needs" : "routed"
const glyphKey = isEvent ? kind : category || "other"
Expand Down Expand Up @@ -146,10 +135,6 @@ export interface LeafletMapProps {
onPinTap?: (pin: MapPin) => void
}

/**
* Imperative Leaflet wrapper. Creates the map once, swaps the tile layer on tint change, and
* reconciles markers when `pins` / `activeId` change. Mirrors map.jsx PinItMap behavior.
*/
export function LeafletMap({
pins = [],
center = MAP_HOME.center,
Expand All @@ -163,11 +148,9 @@ export function LeafletMap({
const mapRef = React.useRef<L.Map | null>(null)
const tileRef = React.useRef<L.TileLayer | null>(null)
const markersRef = React.useRef<Record<string, L.Marker>>({})
// Per-id memo of the last-rendered visual descriptor (category|draft|kind|active), tooltip text and
// position, so reconcile can skip the expensive DivIcon rebuild + DOM teardown (setIcon), the tooltip
// rebind and the setLatLng call when nothing visible actually changed for that marker. Without this, a
// single activeId change re-icons and DOM-replaces ALL N markers; with it, only the de-activated +
// newly-active markers do.
// What each marker last rendered, so reconcile skips the DivIcon rebuild and DOM teardown, the tooltip
// rebind and setLatLng when nothing visible changed. Without it one activeId change re-icons every
// marker instead of just the two whose active state flipped.
const renderRef = React.useRef<
Record<string, { key: string; text: string | null; lat: number; lng: number }>
>({})
Expand All @@ -177,7 +160,6 @@ export function LeafletMap({
const onPinTapRef = React.useRef(onPinTap)
onPinTapRef.current = onPinTap

// Create the map once.
React.useEffect(() => {
if (!elRef.current || mapRef.current) return
const map = L.map(elRef.current, {
Expand All @@ -191,8 +173,7 @@ export function LeafletMap({
touchZoom: interactive,
boxZoom: false,
keyboard: false,
// Note: Leaflet's legacy `tap` option was dropped from @types/leaflet (and is a no-op in modern
// Leaflet), so it is intentionally omitted here; touch tap is handled natively.
// No `tap`: the legacy option is a no-op in modern Leaflet and gone from @types/leaflet.
})
mapRef.current = map

Expand Down Expand Up @@ -221,7 +202,7 @@ export function LeafletMap({
renderRef.current = {}
pinsRef.current = {}
}
// Intentionally run once on mount.
// Created once; the effects below apply later prop changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

Expand All @@ -230,7 +211,6 @@ export function LeafletMap({
mapRef.current?.setView([centerLat, centerLng], zoom)
}, [centerLat, centerLng, zoom])

// Swap tiles when tint changes.
React.useEffect(() => {
const map = mapRef.current
if (!map) return
Expand All @@ -244,7 +224,6 @@ export function LeafletMap({
}).addTo(map)
}, [tint])

// Reconcile markers.
React.useEffect(() => {
const map = mapRef.current
if (!map) return
Expand All @@ -266,21 +245,18 @@ export function LeafletMap({
pinsRef.current[p.id] = p
const existing = markersRef.current[p.id]
const active = String(p.id) === String(activeId)
// One cheap string capturing everything pinIcon() depends on. Equal key => identical DivIcon, so
// we can skip rebuilding the HTML/SVG and the setIcon DOM teardown entirely.
// Everything pinIcon() depends on: an equal key means an identical DivIcon.
const key = `${p.category}|${p.draft}|${p.kind}|${active}`
const text = tooltipText(p)
if (existing) {
const prev = renderRef.current[p.id]
// Re-icon only when the visual descriptor changed (e.g. this pin just gained/lost active).
if (!prev || prev.key !== key) {
existing.setIcon(pinIcon(p.category, { active, draft: p.draft, kind: p.kind }))
}
if (!prev || prev.text !== text) {
syncTooltip(existing, text)
if (interactive) labelMarker(existing, text)
}
// Re-position only when the coordinates actually moved.
if (!prev || prev.lat !== p.lat || prev.lng !== p.lng) {
existing.setLatLng([p.lat, p.lng])
}
Expand Down
16 changes: 0 additions & 16 deletions apps/admin/src/components/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,6 @@ import { ErrorBoundary } from "@/components/shell/error-boundary"
import { OperatorLogin } from "@/features/auth/operator-login"
import { useOperatorSession } from "@/hooks/use-admin-auth"

/**
* App-wide client providers. Mounted once in the root layout.
*
* The QueryClient is created lazily and held in a ref so it survives re-renders but is unique per
* browser tab. AuthHydrator runs the Cloudflare Access exchange/bootstrap on mount; AuthGate then
* decides whether to render the dashboard, the loading screen, or the operator (Access) gate.
*/
export function Providers({ children }: { children: React.ReactNode }) {
const clientRef = React.useRef<QueryClient | null>(null)
if (!clientRef.current) {
Expand All @@ -32,14 +25,6 @@ export function Providers({ children }: { children: React.ReactNode }) {
)
}

/**
* The operator gate. Only an authenticated operator session renders the dashboard:
* - idle / loading -> a minimal loading screen (Access exchange in flight, or pre-hydration).
* - signing-out -> the same screen, saying so, until the Access logout navigation lands.
* - not an operator -> the full-page Cloudflare Access gate (anonymous: authenticating + manual
* continue; forbidden: not-authorized message).
* - operator -> the dashboard shell (children).
*/
function AuthGate({ children }: { children: React.ReactNode }) {
const { isOperator, status } = useOperatorSession()

Expand All @@ -58,7 +43,6 @@ function AuthGate({ children }: { children: React.ReactNode }) {
return <>{children}</>
}

/** Minimal centered screen shown while the session is being established or ended. */
function BootScreen({ label }: { label: string }) {
return (
<div className="op-boot" role="status" aria-live="polite">
Expand Down
5 changes: 2 additions & 3 deletions apps/admin/src/components/shared/backdrop-dismiss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,8 @@ export function useBackdropDismiss<T extends HTMLElement = HTMLDivElement>(

/**
* Escape and the backdrop dismiss a modal only while it holds nothing the operator would lose; once
* there is a draft, its close button and Cancel are the deliberate ways to discard it. Returns the
* backdrop props. The shell yields Escape to any open modal (escape-owner.ts), so Escape here closes
* only the modal and never navigates.
* there is a draft, its close button and Cancel are the deliberate ways to discard it. The shell yields
* Escape to any open modal (escape-owner.ts), so Escape here closes only the modal and never navigates.
*/
export function usePristineDismiss<T extends HTMLElement = HTMLDivElement>(
onClose: () => void,
Expand Down
Loading