?state= was used verbatim and ends up interpolated into a fetch URL and into
the Hugging Face parquet shard path duckdb-wasm reads. Impact is low -- it is a
client-only fetch to a public HTTPS URL, and the wasm sandbox loads no httpfs
-- but validating at the boundary is cheap and correct. Deep links now require
/^[A-Z]{2}$/ (after trim + uppercase, so ?state=az still works) and fall back
to the state selector with a warning when they do not match.
123 lines
4.4 KiB
React
123 lines
4.4 KiB
React
import { useState, useEffect } from 'react'
|
|
import StateSelector from './components/StateSelector.jsx'
|
|
import DistrictSearch from './components/DistrictSearch.jsx'
|
|
import LoadingAnimation from './components/LoadingAnimation.jsx'
|
|
import ChartPanel from './components/ChartPanel.jsx'
|
|
import Footer from './components/Footer.jsx'
|
|
import { fetchDistrictEstimates } from './hooks/useApi.js'
|
|
|
|
// Two-letter USPS state code. The deep-link value flows into API request URLs
|
|
// and into the Hugging Face parquet shard path duckdb-wasm reads, so it gets
|
|
// validated here at the boundary rather than propagated verbatim.
|
|
const STATE_CODE_PATTERN = /^[A-Z]{2}$/
|
|
|
|
/**
|
|
* CRDC Arrests API Demo App — main router.
|
|
* Flow: state → district search (with interesting suggestions) → loading animation → charts
|
|
*/
|
|
export default function App() {
|
|
const [step, setStep] = useState('state') // 'state' | 'search' | 'loading' | 'results'
|
|
const [selectedState, setSelectedState] = useState(null)
|
|
const [district, setDistrict] = useState(null)
|
|
|
|
// On first load: check URL for ?leaid= and state to allow deep-linking
|
|
useEffect(() => {
|
|
const params = new URLSearchParams(window.location.search)
|
|
const leaid = params.get('leaid')
|
|
const rawState = params.get('state')
|
|
const stateParam = rawState ? rawState.trim().toUpperCase() : null
|
|
|
|
if (rawState && !STATE_CODE_PATTERN.test(stateParam)) {
|
|
// Malformed deep link — drop it and start at the state selector rather
|
|
// than passing an arbitrary string into fetch URLs and shard paths.
|
|
console.warn('Ignoring deep link: `state` is not a two-letter state code.')
|
|
return
|
|
}
|
|
|
|
if (leaid && stateParam) {
|
|
// Deep link (including browser back/forward landing on this URL): resolve
|
|
// the district name via a real estimates row, keyed by LEAID. The previous
|
|
// version called searchDistricts(leaid, ...), a name/text search — passing
|
|
// an LEAID as search text never matches, so it always fell back to "Unknown
|
|
// District".
|
|
setSelectedState(stateParam)
|
|
|
|
fetchDistrictEstimates(leaid, { model: 'unified_m2_mod', year: '21-22' }).then(rows => {
|
|
const row = rows?.[0]
|
|
setDistrict(row
|
|
? { leaid: row.leaid, lea_name: row.lea_name, state: row.state }
|
|
: { leaid, lea_name: 'Unknown District', state: stateParam })
|
|
setStep('loading')
|
|
}).catch(() => {
|
|
// Fallback on error
|
|
setDistrict({ leaid, lea_name: 'Unknown District', state: stateParam })
|
|
setStep('loading')
|
|
})
|
|
}
|
|
}, [])
|
|
|
|
const handleStateChange = (stateCode) => {
|
|
setSelectedState(stateCode)
|
|
setStep('search')
|
|
}
|
|
|
|
const handleDistrictSelect = (dist) => {
|
|
// Update URL for shareability
|
|
const params = new URLSearchParams({ leaid: dist.leaid, state: selectedState })
|
|
window.history.replaceState(null, '', `?${params}`)
|
|
setDistrict(dist)
|
|
setStep('loading')
|
|
}
|
|
|
|
return (
|
|
<div className="cv-app">
|
|
{/* Header */}
|
|
<header className="cv-header cv-wrap">
|
|
<a href="/crdc-demo" aria-label="Civilytics" style={{ display: 'block' }}>
|
|
<img
|
|
src={import.meta.env.BASE_URL + 'civilytics-wordmark.svg'}
|
|
alt="Civilytics — social science for the public good"
|
|
style={{ height: '2.5rem', width: 'auto' }}
|
|
/>
|
|
</a>
|
|
</header>
|
|
|
|
{/* Main content */}
|
|
<main style={{ maxWidth: '60rem', margin: '0 auto', padding: '0 var(--space-3)' }}>
|
|
{step === 'state' && (
|
|
<StateSelector onNext={handleStateChange} />
|
|
)}
|
|
|
|
{step === 'search' && selectedState && (
|
|
<DistrictSearch
|
|
state={selectedState}
|
|
onSelect={handleDistrictSelect}
|
|
onBack={() => setStep('state')}
|
|
/>
|
|
)}
|
|
|
|
{step === 'loading' && district && (
|
|
<LoadingAnimation district={district} state={selectedState} />
|
|
)}
|
|
|
|
{/* ChartPanel listens for data-ready event from LoadingAnimation */}
|
|
{step === 'results' && district && (
|
|
<ChartPanel district={district} state={selectedState} />
|
|
)}
|
|
|
|
{(step === 'loading' || step === 'results') && (
|
|
<button
|
|
className="btn-outline"
|
|
style={{ margin: 'var(--space-3) 0', display: 'block' }}
|
|
onClick={() => { window.location.href = '/crdc-demo/' }}
|
|
>
|
|
← Search another district
|
|
</button>
|
|
)}
|
|
</main>
|
|
|
|
<Footer />
|
|
</div>
|
|
)
|
|
}
|