Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| 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'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 “{query.trim()}”. | |
| </p> | |
| )} | |
| </div> | |
| </Dialog.Panel> | |
| </Transition.Child> | |
| </div> | |
| </div> | |
| </Dialog> | |
| </Transition> | |
| </div> | |
| ) | |
| } | |