● Zion Boggan
repos/Docket/components/members-browser.tsx
zionboggan.com ↗
267 lines · tsx
History for this file →
1
"use client";
2
 
3
import { useMemo, useState, useCallback } from "react";
4
import Link from "next/link";
5
import { Card } from "@/components/ui";
6
import { PartyTag } from "@/components/party-tag";
7
import { MemberAvatar } from "@/components/member-avatar";
8
import {
9
  SearchFilterBar,
10
  FilterField,
11
  FilterInput,
12
  FilterSelect,
13
} from "@/components/search-filter-bar";
14
import { clsx } from "@/lib/cx";
15
import type { MemberSummary } from "@/lib/feed";
16
 
17
 
18
function formatDistrict(chamber: string, district: number | null): string {
19
  if (chamber === "senate") return "";
20
  if (district === null || district <= 0) return " · At-Large";
21
  return ` · Dist. ${district}`;
22
}
23
 
24
 
25
export function MembersBrowser({
26
  seed,
27
  states,
28
  hiddenCount = 0,
29
}: {
30
  seed: MemberSummary[];
31
  states: string[];
32
  hiddenCount?: number;
33
}) {
34
  const [q, setQ] = useState("");
35
  const [state, setState] = useState("");
36
  const [chamber, setChamber] = useState("");
37
  const [party, setParty] = useState("");
38
  const [sort, setSort] = useState("");
39
  const [showAllHistorical, setShowAllHistorical] = useState(false);
40
  const [allMembers, setAllMembers] = useState<MemberSummary[] | null>(null);
41
  const [loadingAll, setLoadingAll] = useState(false);
42
 
43
  const handleShowAll = useCallback(async () => {
44
    if (!showAllHistorical) {
45
      if (!allMembers && hiddenCount > 0) {
46
        setLoadingAll(true);
47
        try {
48
          const res = await fetch("/api/members?limit=200");
49
          const first = await res.json();
50
          const items: MemberSummary[] = [...first.items];
51
          let offset = first.items.length;
52
          while (items.length < first.total) {
53
            const page = await fetch(`/api/members?limit=200&offset=${offset}`);
54
            const data = await page.json();
55
            items.push(...data.items);
56
            offset += data.items.length;
57
            if (!data.items.length) break;
58
          }
59
          setAllMembers(items);
60
        } catch {
61
        } finally {
62
          setLoadingAll(false);
63
        }
64
      }
65
      setShowAllHistorical(true);
66
    } else {
67
      setShowAllHistorical(false);
68
    }
69
  }, [showAllHistorical, allMembers, hiddenCount]);
70
 
71
  const pool = showAllHistorical && allMembers ? allMembers : seed;
72
 
73
  const filtered = useMemo(() => {
74
    const query = q.trim().toLowerCase();
75
    const anyFilterActive = !!(q || state || chamber || party || sort);
76
 
77
    return pool
78
      .filter((m) => {
79
        if (
80
          !anyFilterActive &&
81
          !showAllHistorical &&
82
          !m.isCurrent &&
83
          !m.photoUrl
84
        ) {
85
          return false;
86
        }
87
        if (
88
          query &&
89
          !m.name.toLowerCase().includes(query) &&
90
          !m.bioguide.toLowerCase().includes(query)
91
        ) {
92
          return false;
93
        }
94
        if (state && m.state !== state) return false;
95
        if (chamber && m.chamber !== chamber) return false;
96
        if (party && m.partyCode !== party) return false;
97
        return true;
98
      })
99
      .sort((a, b) => {
100
        if (sort === "name") return a.name.localeCompare(b.name);
101
        if (sort === "state") {
102
          const sc = a.state.localeCompare(b.state);
103
          return sc !== 0 ? sc : a.name.localeCompare(b.name);
104
        }
105
        if (sort === "chamber") {
106
          const cc = a.chamber.localeCompare(b.chamber);
107
          return cc !== 0 ? cc : a.name.localeCompare(b.name);
108
        }
109
        const aCurr = a.isCurrent ? 0 : 1;
110
        const bCurr = b.isCurrent ? 0 : 1;
111
        if (aCurr !== bCurr) return aCurr - bCurr;
112
        const aPhoto = a.photoUrl ? 0 : 1;
113
        const bPhoto = b.photoUrl ? 0 : 1;
114
        if (aPhoto !== bPhoto) return aPhoto - bPhoto;
115
        return a.name.localeCompare(b.name);
116
      });
117
  }, [pool, q, state, chamber, party, sort, showAllHistorical]);
118
 
119
  const anyFilterActive = !!(q || state || chamber || party || sort);
120
 
121
  return (
122
    <>
123
      {
124
}
125
      <SearchFilterBar
126
        count={filtered.length}
127
        countLabel="member"
128
        onClear={
129
          anyFilterActive
130
            ? () => {
131
                setQ("");
132
                setState("");
133
                setChamber("");
134
                setParty("");
135
                setSort("");
136
              }
137
            : undefined
138
        }
139
      >
140
        <FilterField label="Search by name" span="2">
141
          <FilterInput
142
            value={q}
143
            onChange={setQ}
144
            placeholder="Name or bioguide ID…"
145
            aria-label="Search members by name or bioguide ID"
146
          />
147
        </FilterField>
148
        <FilterField label="State">
149
          <FilterSelect
150
            value={state}
151
            onChange={setState}
152
            aria-label="Filter by state"
153
            options={[
154
              { value: "", label: "All states" },
155
              ...states.map((s) => ({ value: s, label: s })),
156
            ]}
157
          />
158
        </FilterField>
159
        <FilterField label="Chamber">
160
          <FilterSelect
161
            value={chamber}
162
            onChange={setChamber}
163
            aria-label="Filter by chamber"
164
            options={[
165
              { value: "", label: "Both chambers" },
166
              { value: "senate", label: "Senate" },
167
              { value: "house", label: "House" },
168
            ]}
169
          />
170
        </FilterField>
171
        <FilterField label="Party">
172
          <FilterSelect
173
            value={party}
174
            onChange={setParty}
175
            aria-label="Filter by party"
176
            options={[
177
              { value: "", label: "All parties" },
178
              { value: "D", label: "Democrat" },
179
              { value: "R", label: "Republican" },
180
              { value: "I", label: "Independent" },
181
            ]}
182
          />
183
        </FilterField>
184
        <FilterField label="Sort by">
185
          <FilterSelect
186
            value={sort}
187
            onChange={setSort}
188
            aria-label="Sort members"
189
            options={[
190
              { value: "", label: "Default" },
191
              { value: "name", label: "Name (A-Z)" },
192
              { value: "state", label: "State (A-Z)" },
193
              { value: "chamber", label: "Chamber" },
194
            ]}
195
          />
196
        </FilterField>
197
      </SearchFilterBar>
198
 
199
      {}
200
      {!anyFilterActive && hiddenCount > 0 && (
201
        <div className="mt-3 flex justify-end">
202
          <button
203
            type="button"
204
            onClick={handleShowAll}
205
            disabled={loadingAll}
206
            className="focus-ring label rounded-sm px-2 py-1 text-ash underline underline-offset-4 hover:text-paper disabled:opacity-50"
207
          >
208
            {loadingAll
209
              ? "Loading…"
210
              : showAllHistorical
211
              ? "Hide historical members without a photo"
212
              : `Show all ${hiddenCount.toLocaleString()} historical members`}
213
          </button>
214
        </div>
215
      )}
216
      {!anyFilterActive && !showAllHistorical && hiddenCount > 0 && (
217
        <p className="label mt-1.5 text-right text-ash-2">
218
          Showing current Congress and members with a recorded photo
219
        </p>
220
      )}
221
 
222
      {}
223
      <div className="mt-6">
224
        {filtered.length === 0 ? (
225
          <Card className="p-8 text-center">
226
            <p className="display text-2xl text-paper">No members match.</p>
227
            <p className="prose-lead mx-auto mt-3 max-w-md text-ash">
228
              Adjust the filters above. The roster covers the sitting House and
229
              Senate plus historical members back to the 1st Congress.
230
            </p>
231
          </Card>
232
        ) : (
233
          <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
234
            {filtered.map((m) => (
235
              <Link
236
                key={m.id}
237
                href={m.href}
238
                className="focus-ring group rounded-[3px] border border-line bg-surface p-4 transition-colors hover:bg-graphite"
239
              >
240
                <div className="flex items-start gap-3">
241
                  <MemberAvatar name={m.name} photoUrl={m.photoUrl} size="md" />
242
                  <div className="flex min-w-0 flex-1 items-start justify-between gap-2">
243
                    <div className="min-w-0">
244
                      <p className="font-mono text-[13px] text-paper group-hover:underline">
245
                        {m.name}
246
                      </p>
247
                      <p className="label mt-1 text-ash-2">
248
                        {m.chamber === "senate" ? "Senate" : "House"} ·{" "}
249
                        {m.state}
250
                        {formatDistrict(m.chamber, m.district)}
251
                      </p>
252
                    </div>
253
                    <PartyTag party={m.partyCode} />
254
                  </div>
255
                </div>
256
                <div className="mt-3 flex items-center justify-between">
257
                  <span className="label text-ash-2">{m.bioguide}</span>
258
                  <span className="label text-ash">Record →</span>
259
                </div>
260
              </Link>
261
            ))}
262
          </div>
263
        )}
264
      </div>
265
    </>
266
  );
267
}