Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| import { useEffect, useMemo, useState } from 'react' | |
| import { useQuery, keepPreviousData } from '@tanstack/react-query' | |
| import { | |
| MagnifyingGlassIcon, | |
| XMarkIcon, | |
| ChevronDownIcon, | |
| ChevronRightIcon, | |
| MapPinIcon, | |
| } from '@heroicons/react/24/outline' | |
| import { fetchMeetings, type MeetingSort, type MeetingCard } from '../api/meetings' | |
| import DecisionCardList from './DecisionCardList' | |
| import MeetingThumbnail from './MeetingThumbnail' | |
| /** | |
| * MeetingCardList — a reusable, meeting-grain browser. | |
| * | |
| * Backed by `GET /api/meetings`, where meetings are linked to a topic / policy | |
| * question through their transcript. Each card shows the meeting's real decision | |
| * and question counts; a meeting with decisions expands inline into its own | |
| * decision cards (DecisionCardList scoped by meetingId). | |
| * | |
| * 100% live data — honest loading / error / empty states, never fabricated | |
| * cards or counts (CLAUDE.md: No Fabricated Data). | |
| */ | |
| const PAGE_SIZE = 24 | |
| const SORTS: { id: MeetingSort; label: string }[] = [ | |
| { id: 'recent', label: 'Most recent' }, | |
| { id: 'decisions', label: 'Most decisions' }, | |
| { id: 'interesting', label: 'Most interesting' }, | |
| ] | |
| interface MeetingCardListProps { | |
| topicId?: number | |
| theme?: string | |
| questionId?: string | |
| /** 2-letter state code or full state name. */ | |
| state?: string | |
| city?: string | |
| /** Heading shown above the list, e.g. "Meetings on Affordable housing". */ | |
| title?: string | |
| } | |
| // A clean place label from the card's jurisdiction + state. | |
| function placeLabel(m: MeetingCard): string { | |
| const place = m.city || m.jurisdiction || '' | |
| return [place, m.state_code].filter(Boolean).join(', ') | |
| } | |
| // Friendly date from an ISO yyyy-mm-dd string (the API already coerces). | |
| function fmtDate(iso: string | null): string { | |
| if (!iso) return '' | |
| const d = new Date(`${iso}T00:00:00`) | |
| if (Number.isNaN(d.getTime())) return '' | |
| return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) | |
| } | |
| function MeetingRow({ meeting }: { meeting: MeetingCard }) { | |
| const [open, setOpen] = useState(false) | |
| const canDrill = meeting.has_decisions && meeting.decision_count > 0 | |
| const date = fmtDate(meeting.date) | |
| return ( | |
| <div className="rounded-lg border border-gray-200 bg-white shadow-sm transition hover:border-indigo-200"> | |
| <button | |
| type="button" | |
| onClick={() => canDrill && setOpen((v) => !v)} | |
| aria-expanded={canDrill ? open : undefined} | |
| className={`flex w-full items-start gap-3 p-5 text-left ${ | |
| canDrill ? 'cursor-pointer' : 'cursor-default' | |
| }`} | |
| > | |
| {canDrill ? ( | |
| open ? ( | |
| <ChevronDownIcon className="mt-0.5 h-5 w-5 flex-shrink-0 text-indigo-500" /> | |
| ) : ( | |
| <ChevronRightIcon className="mt-0.5 h-5 w-5 flex-shrink-0 text-gray-400" /> | |
| ) | |
| ) : ( | |
| <span className="mt-0.5 h-5 w-5 flex-shrink-0" aria-hidden /> | |
| )} | |
| {/* YouTube meeting preview — addressed by video id off the ytimg CDN. | |
| Renders nothing when the meeting has no recording. */} | |
| {meeting.video_id && ( | |
| <div className="w-32 shrink-0 self-start sm:w-48"> | |
| <MeetingThumbnail | |
| videoId={meeting.video_id} | |
| alt={meeting.title ?? undefined} | |
| className="rounded-lg" | |
| /> | |
| </div> | |
| )} | |
| <div className="min-w-0 flex-1"> | |
| <h3 className="font-semibold text-gray-900">{meeting.title || 'Untitled meeting'}</h3> | |
| <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500"> | |
| {placeLabel(meeting) && ( | |
| <span className="inline-flex items-center gap-1"> | |
| <MapPinIcon className="h-3.5 w-3.5" /> | |
| {placeLabel(meeting)} | |
| </span> | |
| )} | |
| {date && <span>{date}</span>} | |
| </div> | |
| {/* Real counts — 0 is honest, not hidden. */} | |
| <div className="mt-2 flex flex-wrap gap-2"> | |
| <span | |
| className={`rounded-full px-2.5 py-1 text-xs font-medium ${ | |
| meeting.decision_count > 0 | |
| ? 'bg-indigo-50 text-indigo-700' | |
| : 'bg-gray-100 text-gray-500' | |
| }`} | |
| > | |
| {meeting.decision_count} {meeting.decision_count === 1 ? 'decision' : 'decisions'} | |
| </span> | |
| {meeting.question_count > 0 && ( | |
| <span className="rounded-full bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700"> | |
| {meeting.question_count} {meeting.question_count === 1 ? 'question' : 'questions'} | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| </button> | |
| {/* Inline drill-down: this meeting's decisions. */} | |
| {canDrill && open && ( | |
| <div className="border-t border-gray-100 bg-gray-50/60 p-5"> | |
| <DecisionCardList meetingId={meeting.meeting_id} /> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| export default function MeetingCardList({ | |
| topicId, | |
| theme, | |
| questionId, | |
| state, | |
| city, | |
| title, | |
| }: MeetingCardListProps) { | |
| const [rawQuery, setRawQuery] = useState('') | |
| const [debouncedQuery, setDebouncedQuery] = useState('') | |
| const [sort, setSort] = useState<MeetingSort>('recent') | |
| const [page, setPage] = useState(0) | |
| // Debounce the search box (~300ms) and reset to the first page on a new term. | |
| useEffect(() => { | |
| const t = setTimeout(() => { | |
| setDebouncedQuery(rawQuery.trim()) | |
| setPage(0) | |
| }, 300) | |
| return () => clearTimeout(t) | |
| }, [rawQuery]) | |
| // Reset to the first page when the scope or sort changes. | |
| useEffect(() => { | |
| setPage(0) | |
| }, [topicId, theme, questionId, state, city, sort]) | |
| const q = debouncedQuery || undefined | |
| const { data, isLoading, isError, isFetching } = useQuery({ | |
| queryKey: [ | |
| 'meetings', | |
| topicId ?? null, | |
| theme ?? null, | |
| questionId ?? null, | |
| state ?? null, | |
| city ?? null, | |
| q ?? null, | |
| sort, | |
| page, | |
| ], | |
| queryFn: () => | |
| fetchMeetings({ | |
| topicId, | |
| theme, | |
| questionId, | |
| state, | |
| city, | |
| q, | |
| sort, | |
| limit: PAGE_SIZE, | |
| offset: page * PAGE_SIZE, | |
| }), | |
| placeholderData: keepPreviousData, | |
| }) | |
| const meetings = useMemo(() => data?.items ?? [], [data]) | |
| const total = data?.pagination.total ?? 0 | |
| const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) | |
| return ( | |
| <div> | |
| {/* Heading + real total count */} | |
| <div className="mb-4 flex flex-wrap items-baseline justify-between gap-2"> | |
| {title ? <h2 className="text-xl font-bold text-gray-900">{title}</h2> : <span />} | |
| {!isLoading && !isError && ( | |
| <span className="text-sm text-gray-500"> | |
| {total.toLocaleString()} {total === 1 ? 'meeting' : 'meetings'} | |
| </span> | |
| )} | |
| </div> | |
| {/* Search bar + sort filters */} | |
| <div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center"> | |
| <div className="relative flex-1"> | |
| <MagnifyingGlassIcon className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400" /> | |
| <input | |
| type="text" | |
| value={rawQuery} | |
| onChange={(e) => setRawQuery(e.target.value)} | |
| placeholder="Search these meetings…" | |
| className="w-full rounded-full border border-gray-300 bg-white py-2.5 pl-10 pr-10 text-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-indigo-500" | |
| /> | |
| {rawQuery && ( | |
| <button | |
| type="button" | |
| onClick={() => setRawQuery('')} | |
| aria-label="Clear search" | |
| className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" | |
| > | |
| <XMarkIcon className="h-5 w-5" /> | |
| </button> | |
| )} | |
| </div> | |
| <div className="inline-flex shrink-0 rounded-full border border-gray-200 bg-white p-1"> | |
| {SORTS.map((s) => { | |
| const on = sort === s.id | |
| return ( | |
| <button | |
| key={s.id} | |
| type="button" | |
| onClick={() => setSort(s.id)} | |
| className={`rounded-full px-3 py-1.5 text-xs font-semibold transition-colors ${ | |
| on ? 'bg-indigo-600 text-white shadow-sm' : 'text-gray-600 hover:text-gray-900' | |
| }`} | |
| > | |
| {s.label} | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| {/* Body — honest loading / error / empty / list states */} | |
| {isLoading ? ( | |
| <div className="space-y-3"> | |
| {Array.from({ length: 6 }).map((_, i) => ( | |
| <div key={i} className="h-24 animate-pulse rounded-lg border border-gray-200 bg-gray-100" /> | |
| ))} | |
| </div> | |
| ) : isError ? ( | |
| <div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center text-sm text-red-700"> | |
| Couldn't load meetings. Please try again. | |
| </div> | |
| ) : meetings.length === 0 ? ( | |
| <div className="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center text-sm text-gray-500"> | |
| {debouncedQuery ? `No meetings match “${debouncedQuery}”.` : 'No meetings here yet.'} | |
| </div> | |
| ) : ( | |
| <> | |
| <div className={`space-y-3 transition-opacity ${isFetching ? 'opacity-60' : ''}`}> | |
| {meetings.map((m) => ( | |
| <MeetingRow key={m.meeting_id} meeting={m} /> | |
| ))} | |
| </div> | |
| {totalPages > 1 && ( | |
| <div className="mt-8 flex items-center justify-center gap-3"> | |
| <button | |
| type="button" | |
| onClick={() => setPage((p) => Math.max(0, p - 1))} | |
| disabled={page === 0} | |
| className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:text-gray-300" | |
| > | |
| Previous | |
| </button> | |
| <span className="text-sm text-gray-500"> | |
| Page {page + 1} of {totalPages.toLocaleString()} | |
| </span> | |
| <button | |
| type="button" | |
| onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} | |
| disabled={page >= totalPages - 1} | |
| className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:text-gray-300" | |
| > | |
| Next | |
| </button> | |
| </div> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| ) | |
| } | |