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(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({ 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) => ( {n.toLocaleString()} ) const pickTopic = (topic: TopicSummary | null) => { setSelectedTopic(topic) setFlyoutOpen(false) } const listTitle = selectedTopic ? `Decisions on ${selectedTopic.name}` : `Most contested decisions${placeLabel ? ` · ${placeLabel}` : ''}` return (
{/* Header card — title + place toggle on one row, then the topic pills. */}

Browse Topics{placeLabel ? ` · ${placeLabel}` : ''}

{/* 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 && (
)}
{/* 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 ? (
Couldn't load topics.{' '} {(error as { message?: string } | undefined)?.message ?? 'Please try again.'}
) : (
{isLoading ? ( Loading topics… ) : ( topPills.map((topic) => { const on = selectedTopic?.topic_id === topic.topic_id return ( ) }) )} {!isLoading && topics.length > 0 && ( )}
)}
{/* 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. */} Topic } />
{/* Filter flyout — full topic catalog + keyword search, slid in from the right. Drilldowns (selecting any topic) happen here too. */} setFlyoutOpen(false)}>
All topics{placeLabel ? ` · ${placeLabel}` : ''}
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" />
{filtered.map((topic) => { const on = selectedTopic?.topic_id === topic.topic_id return ( ) })} {filtered.length === 0 && (

No topics match “{query.trim()}”.

)}
) }