open-navigator / web_app /src /pages /LighthouseReportPage.tsx
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
11.7 kB
import { useQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
const DEFAULT_WEBSITE_URL = 'https://www.communityone.com/'
type LighthouseScores = {
performance: number | null
accessibility: number | null
best_practices: number | null
seo: number | null
}
export type LighthouseReportPayload = {
scan_key: string
batch_id: string
jurisdiction_id: string
website_url: string
final_url: string | null
scanned_at: string | null
status: string
lighthouse_version: string | null
requested_url: string | null
scores: LighthouseScores
run_warnings: string[]
screenshot_data_url: string | null
}
function scoreStroke(score: number | null): string {
if (score == null) return '#94a3b8'
if (score >= 90) return '#15803d'
if (score >= 50) return '#ca8a04'
return '#b91c1c'
}
function LighthouseGauge({
label,
score,
size = 'sm',
}: {
label: string
score: number | null
size?: 'sm' | 'lg'
}) {
const r = size === 'lg' ? 54 : 38
const c = 2 * Math.PI * r
const pct = score == null ? 0 : Math.min(100, Math.max(0, score)) / 100
const offset = c * (1 - pct)
const stroke = scoreStroke(score)
const dim = size === 'lg' ? 136 : 100
const view = dim
const cx = view / 2
const cy = view / 2
return (
<div className="flex flex-col items-center gap-2">
<div
className="relative"
style={{ width: dim, height: dim }}
aria-label={`${label} score ${score == null ? 'not available' : score}`}
>
<svg width={dim} height={dim} viewBox={`0 0 ${view} ${view}`} className="block -rotate-90">
<circle
cx={cx}
cy={cy}
r={r}
fill="none"
stroke="#e2e8f0"
strokeWidth={size === 'lg' ? 10 : 8}
/>
<circle
cx={cx}
cy={cy}
r={r}
fill="none"
stroke={stroke}
strokeWidth={size === 'lg' ? 10 : 8}
strokeLinecap="round"
strokeDasharray={`${c} ${c}`}
strokeDashoffset={offset}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span
className={
size === 'lg'
? 'text-3xl font-semibold tabular-nums text-slate-900'
: 'text-xl font-semibold tabular-nums text-slate-900'
}
>
{score == null ? '—' : score}
</span>
</div>
</div>
<span
className={
size === 'lg'
? 'text-center text-base font-medium text-slate-800'
: 'text-center text-sm font-medium text-slate-700'
}
>
{label}
</span>
</div>
)
}
function WarningBox({ items }: { items: string[] }) {
if (!items.length) return null
return (
<div className="rounded-md border border-amber-300/80 bg-amber-50/90 px-4 py-3 text-amber-950 shadow-sm">
<h2 className="text-sm font-semibold text-amber-900">There were issues affecting this run of Lighthouse:</h2>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-amber-950/90">
{items.map((w) => (
<li key={w}>{w}</li>
))}
</ul>
</div>
)
}
function formatErrorDetail(detail: unknown): string {
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
return detail
.map((x) => (typeof x === 'object' && x && 'msg' in x ? String((x as { msg: string }).msg) : String(x)))
.join('; ')
}
if (detail && typeof detail === 'object' && 'detail' in detail) {
return formatErrorDetail((detail as { detail: unknown }).detail)
}
return 'Request failed'
}
async function fetchReport(websiteUrl: string, batchId?: string): Promise<LighthouseReportPayload> {
const u = new URL('/api/lighthouse/report', window.location.origin)
u.searchParams.set('website_url', websiteUrl.trim())
if (batchId?.trim()) u.searchParams.set('batch_id', batchId.trim())
const res = await fetch(u.toString(), { credentials: 'include' })
if (!res.ok) {
let body: unknown = null
try {
body = await res.json()
} catch {
/* ignore */
}
const msg =
body && typeof body === 'object' && 'detail' in body
? formatErrorDetail((body as { detail: unknown }).detail)
: `HTTP ${res.status}`
throw new Error(msg)
}
return (await res.json()) as LighthouseReportPayload
}
export default function LighthouseReportPage() {
const [searchParams, setSearchParams] = useSearchParams()
const initialUrl = useMemo(() => {
const q = searchParams.get('url')?.trim()
return q && q.length >= 4 ? q : DEFAULT_WEBSITE_URL
}, [searchParams])
const [urlInput, setUrlInput] = useState(initialUrl)
const [batchInput, setBatchInput] = useState(() => searchParams.get('batch_id') ?? '')
const [submittedUrl, setSubmittedUrl] = useState(initialUrl)
const [submittedBatch, setSubmittedBatch] = useState(() => searchParams.get('batch_id') ?? '')
const syncQuery = useCallback(
(nextUrl: string, nextBatch: string) => {
const sp = new URLSearchParams(searchParams)
sp.set('url', nextUrl.trim())
if (nextBatch.trim()) sp.set('batch_id', nextBatch.trim())
else sp.delete('batch_id')
setSearchParams(sp, { replace: true })
},
[searchParams, setSearchParams],
)
const {
data,
isPending,
isError,
error,
isFetching,
} = useQuery({
queryKey: ['lighthouse-report', submittedUrl, submittedBatch || null],
queryFn: () => fetchReport(submittedUrl, submittedBatch),
staleTime: 60_000,
})
function onSubmit(e: React.FormEvent) {
e.preventDefault()
const u = urlInput.trim()
if (u.length < 4) return
setSubmittedUrl(u)
setSubmittedBatch(batchInput)
syncQuery(u, batchInput)
}
const scores = data?.scores
return (
<div className="min-h-0 min-w-0 overflow-auto rounded-lg border border-slate-300/80 bg-white p-4 shadow-sm sm:p-6">
<form onSubmit={onSubmit} className="mb-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
<label className="flex min-w-[14rem] flex-1 flex-col gap-1 text-xs font-medium text-slate-700">
Website URL
<input
type="url"
name="website_url"
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
className="rounded-md border border-slate-300 px-2 py-1.5 text-sm text-slate-900 shadow-sm outline-none focus:border-teal-600 focus:ring-1 focus:ring-teal-600"
placeholder="https://example.gov/"
autoComplete="url"
/>
</label>
<label className="flex w-full min-w-[10rem] flex-col gap-1 text-xs font-medium text-slate-700 sm:w-48">
Batch ID (optional)
<input
type="text"
name="batch_id"
value={batchInput}
onChange={(e) => setBatchInput(e.target.value)}
className="rounded-md border border-slate-300 px-2 py-1.5 text-sm text-slate-900 shadow-sm outline-none focus:border-teal-600 focus:ring-1 focus:ring-teal-600"
placeholder="from urls.meta.json"
/>
</label>
<button
type="submit"
className="rounded-md bg-teal-700 px-4 py-2 text-sm font-medium text-white shadow hover:bg-teal-800 disabled:opacity-50"
disabled={isFetching}
>
{isFetching ? 'Loading…' : 'Load report'}
</button>
</form>
{isPending && (
<p className="text-sm text-slate-600" role="status">
Loading Lighthouse data…
</p>
)}
{isError && (
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900" role="alert">
{(error as Error)?.message ?? 'Failed to load report'}
</div>
)}
{data && scores && (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-center gap-6 border-b border-slate-100 pb-6 sm:justify-between sm:gap-4">
<LighthouseGauge label="Performance" score={scores.performance} />
<LighthouseGauge label="Accessibility" score={scores.accessibility} />
<LighthouseGauge label="Best Practices" score={scores.best_practices} />
<LighthouseGauge label="SEO" score={scores.seo} />
</div>
<WarningBox items={data.run_warnings} />
<section className="grid gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] md:items-start">
<div>
<LighthouseGauge label="Performance" score={scores.performance} size="lg" />
<p className="mt-4 max-w-md text-xs leading-relaxed text-slate-600">
Values are estimated and may vary. The performance score is calculated directly from these metrics.{' '}
<a
href="https://googlechrome.github.io/lighthouse/scorecalc/"
className="text-teal-700 underline hover:text-teal-900"
target="_blank"
rel="noreferrer"
>
See calculator
</a>
.
</p>
<dl className="mt-4 space-y-1 text-xs text-slate-600">
<div>
<dt className="inline font-medium text-slate-700">Requested URL: </dt>
<dd className="inline break-all">{data.requested_url ?? data.website_url}</dd>
</div>
{data.final_url && data.final_url !== data.requested_url && (
<div>
<dt className="inline font-medium text-slate-700">Final URL: </dt>
<dd className="inline break-all">{data.final_url}</dd>
</div>
)}
<div>
<dt className="inline font-medium text-slate-700">Scanned at: </dt>
<dd className="inline">{data.scanned_at ?? '—'}</dd>
</div>
{data.lighthouse_version && (
<div>
<dt className="inline font-medium text-slate-700">Lighthouse: </dt>
<dd className="inline">{data.lighthouse_version}</dd>
</div>
)}
</dl>
</div>
<div className="flex flex-col items-center md:items-end">
{data.screenshot_data_url ? (
<figure className="overflow-hidden rounded-lg border border-slate-200 bg-slate-50 shadow-md">
<img
src={data.screenshot_data_url}
alt="Mobile-sized screenshot from the Lighthouse report"
className="mx-auto max-h-[420px] w-auto max-w-full object-contain"
/>
<figcaption className="border-t border-slate-200 px-2 py-1 text-center text-[10px] text-slate-500">
Final screenshot (from audit JSON)
</figcaption>
</figure>
) : (
<div className="flex min-h-[200px] w-full max-w-sm items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50 p-4 text-center text-sm text-slate-600">
No embedded screenshot in this run (run a full Lighthouse categories audit, not accessibility-only, to
capture <code className="text-xs">final-screenshot</code> in stored JSON).
</div>
)}
</div>
</section>
</div>
)}
</div>
)
}