open-navigator / web_app /src /pages /BrowseTopics.tsx
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
14.8 kB
import { Fragment, useMemo, useState } from 'react'
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Dialog, Transition } from '@headlessui/react'
import {
ArrowLeftIcon,
MagnifyingGlassIcon,
AdjustmentsHorizontalIcon,
XMarkIcon,
} from '@heroicons/react/24/outline'
import { fetchTopics, type TopicSummary } from '../api/topics'
import DecisionCardList from '../components/DecisionCardList'
// How many topic pills to surface inline before the rest move to the flyout.
// The API returns topics sorted by transcript_occurrences desc, so the first
// few are the most-discussed.
const TOP_PILL_COUNT = 5
export default function BrowseTopics() {
const [query, setQuery] = useState('')
// Picking a topic SCOPES the decision cards shown below — it no longer drills
// into a separate view. The cards stay at the top the whole time.
const [selectedTopic, setSelectedTopic] = useState<TopicSummary | null>(null)
// The full topic catalog + keyword search lives in a slide-over flyout so the
// main view stays focused on the top handful of topics.
const [flyoutOpen, setFlyoutOpen] = useState(false)
const navigate = useNavigate()
const routerLocation = useLocation()
// Place filter carried over from the homepage (e.g. ?state=GA&city=Atlanta).
// The topic catalog is state-grain, but the decision cards scope to the city
// when we have one — so browsing from Atlanta filters to Atlanta, not all GA.
const [searchParams] = useSearchParams()
const stateCode = (searchParams.get('state') || '').trim().toUpperCase() || undefined
const cityName = (searchParams.get('city') || '').trim() || undefined
// When a city is carried in, default the decision scope to that city (e.g.
// Atlanta, not all of GA) but let the user broaden to the state — some cities
// have little analyzed data yet, so we never dead-end on an empty page.
const [placeScope, setPlaceScope] = useState<'city' | 'state'>('city')
const scopedCity = cityName && placeScope === 'city' ? cityName : undefined
const placeLabel = scopedCity || stateCode
// Go back to wherever the user came from; fall back to the home page when
// this is the first in-app view (direct link / refresh).
const handleBack = () => {
if (routerLocation.key !== 'default') {
navigate(-1)
} else {
navigate('/')
}
}
const { data, isLoading, isError, error } = useQuery<TopicSummary[]>({
queryKey: ['topics', stateCode ?? null],
queryFn: () => fetchTopics(stateCode),
})
const topics = useMemo(() => data ?? [], [data])
// Full catalog filtered by the flyout keyword search.
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return topics
return topics.filter((t) => {
if (t.name.toLowerCase().includes(q)) return true
return t.keywords.some((k) => k.toLowerCase().includes(q))
})
}, [topics, query])
// Inline pills: the top N most-discussed topics, plus the active one if the
// user picked something further down the list via the flyout.
const topPills = useMemo(() => {
const top = topics.slice(0, TOP_PILL_COUNT)
if (selectedTopic && !top.some((t) => t.topic_id === selectedTopic.topic_id)) {
return [...top, selectedTopic]
}
return top
}, [topics, selectedTopic])
// Pill styling for the topic filter row — solid teal (theme) when active.
const chipClass = (on: boolean) =>
`inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
on
? 'border-primary-500 bg-primary-500 text-white'
: 'border-gray-200 bg-white text-gray-700 hover:border-primary-500 hover:text-primary-700'
}`
// Full-width row styling for the flyout catalog — long topic names made the
// old wrap-of-chips layout collapse into a ragged column, so the catalog is a
// proper vertical list: name left, count right, uniform hit targets.
const rowClass = (on: boolean) =>
`flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors ${
on
? 'bg-primary-50 font-semibold text-primary-700'
: 'text-gray-700 hover:bg-gray-50'
}`
// Small count badge shown after a topic name (transcript snippets tagged).
const countBadge = (n: number, on: boolean) => (
<span
className={`rounded-full px-1.5 py-0.5 text-xs font-semibold tabular-nums ${
on ? 'bg-white/20 text-white' : 'bg-gray-100 text-gray-500'
}`}
>
{n.toLocaleString()}
</span>
)
const pickTopic = (topic: TopicSummary | null) => {
setSelectedTopic(topic)
setFlyoutOpen(false)
}
const listTitle = selectedTopic
? `Decisions on ${selectedTopic.name}`
: `Most contested decisions${placeLabel ? ` · ${placeLabel}` : ''}`
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-7xl mx-auto px-4">
<button
type="button"
onClick={handleBack}
className="inline-flex items-center gap-2 mb-4 text-sm font-medium text-gray-600 hover:text-primary-600 transition-colors"
>
<ArrowLeftIcon className="h-4 w-4" />
Back
</button>
{/* Header card — title + place toggle on one row, then the topic pills. */}
<div className="bg-white rounded-lg shadow-sm p-4 mb-4">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
<h1 className="text-2xl font-bold text-gray-900">
Browse Topics{placeLabel ? ` · ${placeLabel}` : ''}
</h1>
{/* Place scope — default to the city we arrived with, with a one-click
broaden to the whole state (handy when a city is sparsely analyzed). */}
{cityName && stateCode && (
<div className="inline-flex rounded-full border border-gray-200 bg-gray-50 p-0.5 text-sm">
<button
type="button"
onClick={() => setPlaceScope('city')}
className={`rounded-full px-3 py-1 font-medium transition-colors ${
placeScope === 'city' ? 'bg-primary-500 text-white shadow-sm' : 'text-gray-600 hover:text-gray-900'
}`}
>
📍 {cityName}
</button>
<button
type="button"
onClick={() => setPlaceScope('state')}
className={`rounded-full px-3 py-1 font-medium transition-colors ${
placeScope === 'state' ? 'bg-primary-500 text-white shadow-sm' : 'text-gray-600 hover:text-gray-900'
}`}
>
All of {stateCode}
</button>
</div>
)}
</div>
{/* Top topics inline — pick one to scope the decision cards below. The
rest of the catalog + keyword search live in the "More topics" flyout. */}
{isError ? (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
Couldn&apos;t load topics.{' '}
{(error as { message?: string } | undefined)?.message ?? 'Please try again.'}
</div>
) : (
<div className="mt-3 flex flex-wrap items-center gap-2">
{isLoading ? (
<span className="px-2 py-1.5 text-sm text-gray-400">Loading topics…</span>
) : (
topPills.map((topic) => {
const on = selectedTopic?.topic_id === topic.topic_id
return (
<button
key={topic.topic_id}
type="button"
// Clicking the active pill clears it (back to all topics) —
// replaces the removed "All topics" reset button.
onClick={() => pickTopic(on ? null : topic)}
className={chipClass(on)}
>
{topic.name}
{countBadge(topic.transcript_occurrences, on)}
</button>
)
})
)}
{!isLoading && topics.length > 0 && (
<button
type="button"
onClick={() => setFlyoutOpen(true)}
className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border border-dashed border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:border-primary-500 hover:text-primary-700"
>
<AdjustmentsHorizontalIcon className="h-4 w-4" />
More topics
</button>
)}
</div>
)}
</div>
{/* Decision cards — the shared Contested StoryCard grid with YouTube
meeting previews, shown immediately. Picking a topic scopes this
list; the `key` remounts it so the new scope applies cleanly. */}
<DecisionCardList
key={`${selectedTopic?.topic_id ?? 'all-topics'}-${scopedCity ?? 'state'}`}
topicIds={selectedTopic ? [selectedTopic.topic_id] : undefined}
state={stateCode}
city={scopedCity}
title={listTitle}
showAdvancedFilters
scopeFilter={
<label className="flex flex-col gap-1 text-xs font-semibold text-gray-600">
Topic
<select
value={selectedTopic?.topic_id ?? ''}
onChange={(e) => {
const id = e.target.value
pickTopic(id ? topics.find((t) => String(t.topic_id) === id) ?? null : null)
}}
disabled={isLoading || topics.length === 0}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-normal text-gray-900 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-gray-50 disabled:text-gray-400"
>
<option value="">All topics</option>
{topics.map((t) => (
<option key={t.topic_id} value={t.topic_id}>
{t.name}
</option>
))}
</select>
</label>
}
/>
</div>
{/* Filter flyout — full topic catalog + keyword search, slid in from the
right. Drilldowns (selecting any topic) happen here too. */}
<Transition appear show={flyoutOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={() => setFlyoutOpen(false)}>
<Transition.Child
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/30" />
</Transition.Child>
<div className="fixed inset-0 overflow-hidden">
<div className="absolute inset-y-0 right-0 flex max-w-full pl-10">
<Transition.Child
as={Fragment}
enter="transform transition ease-out duration-200"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in duration-150"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<Dialog.Panel className="flex h-full w-screen max-w-md flex-col bg-white shadow-xl">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<Dialog.Title className="text-lg font-semibold text-gray-900">
All topics{placeLabel ? ` · ${placeLabel}` : ''}
</Dialog.Title>
<button
type="button"
onClick={() => setFlyoutOpen(false)}
className="rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<div className="border-b border-gray-200 px-6 py-4">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter topics or keywords (e.g. housing, transit)…"
className="w-full rounded-lg border-2 border-gray-300 px-10 py-2 text-sm text-gray-900 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-3 py-2">
<button
type="button"
onClick={() => pickTopic(null)}
className={rowClass(selectedTopic === null)}
>
<span className="truncate">All topics</span>
</button>
{filtered.map((topic) => {
const on = selectedTopic?.topic_id === topic.topic_id
return (
<button
key={topic.topic_id}
type="button"
onClick={() => pickTopic(topic)}
className={rowClass(on)}
>
<span className="truncate">{topic.name}</span>
<span className="shrink-0 tabular-nums text-xs font-medium text-gray-400">
{topic.transcript_occurrences.toLocaleString()}
</span>
</button>
)
})}
{filtered.length === 0 && (
<p className="px-3 py-3 text-sm text-gray-400">
No topics match &ldquo;{query.trim()}&rdquo;.
</p>
)}
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
</div>
)
}