● Zion Boggan
repos/Docket/components/laws-browser.tsx
zionboggan.com ↗
219 lines · tsx
History for this file →
1
"use client";
2
 
3
import { useEffect, useState } from "react";
4
import { useSearchParams } from "next/navigation";
5
import Link from "next/link";
6
import { SourceLink } from "@/components/source-link";
7
import { ArchiveLink } from "@/components/record-archive";
8
import { ordinal } from "@/lib/format";
9
import {
10
  SearchFilterBar,
11
  FilterField,
12
  FilterInput,
13
  FilterSelect,
14
  FilterSubmit,
15
} from "@/components/search-filter-bar";
16
import type { LawListItem } from "@/lib/feed";
17
 
18
function LawsSkeleton() {
19
  return (
20
    <div className="mt-8 space-y-4">
21
      <div className="h-14 w-full animate-pulse rounded-sm bg-graphite" />
22
      <div className="mt-8 mb-4 h-4 w-40 animate-pulse rounded-sm bg-graphite" />
23
      <div className="space-y-3">
24
        {Array.from({ length: 12 }).map((_, i) => (
25
          <div key={i} className="h-28 w-full animate-pulse rounded-sm bg-graphite" />
26
        ))}
27
      </div>
28
    </div>
29
  );
30
}
31
 
32
function snippet(s: string, max = 240): string {
33
  if (!s) return "";
34
  const clean = s.trim();
35
  return clean.length <= max ? clean : clean.slice(0, max - 1).trimEnd() + "…";
36
}
37
 
38
function LawCards({ laws }: { laws: LawListItem[] }) {
39
  return (
40
    <ul
41
      style={{ display: "flex", flexDirection: "column", gap: "12px", listStyle: "none", padding: 0, margin: 0 }}
42
      role="list"
43
    >
44
      {laws.map((law) => (
45
        <li key={law.id}>
46
          <div
47
            style={{
48
              borderRadius: "10px",
49
              border: "1px solid var(--dk-line)",
50
              background: "var(--dk-surface)",
51
              padding: "clamp(20px, 3vw, 28px)",
52
            }}
53
          >
54
            {}
55
            <div
56
              style={{
57
                display: "flex",
58
                flexWrap: "wrap",
59
                alignItems: "baseline",
60
                justifyContent: "space-between",
61
                gap: "12px",
62
                marginBottom: "14px",
63
              }}
64
            >
65
              <p
66
                className="font-mono text-paper"
67
                style={{ fontSize: "clamp(1.0625rem, 2.4vw, 1.25rem)", fontWeight: 700, letterSpacing: "0.01em" }}
68
              >
69
                Public Law {law.plNumber || "-"}
70
              </p>
71
              <span
72
                className="font-mono"
73
                style={{ fontSize: "12px", color: "var(--dk-ash-2)", letterSpacing: "0.06em" }}
74
              >
75
                {ordinal(law.congress)} Congress
76
              </span>
77
            </div>
78
 
79
            {}
80
            <p className="label" style={{ color: "var(--dk-ash-2)", marginBottom: "6px" }}>
81
              Enacted from{" "}
82
              <Link
83
                href={law.billHref}
84
                className="focus-ring font-mono hover:text-paper underline underline-offset-4"
85
                style={{ color: "var(--dk-signal)" }}
86
              >
87
                {law.billNumber}
88
              </Link>
89
            </p>
90
 
91
            {}
92
            <Link
93
              href={law.billHref}
94
              className="focus-ring font-prose text-paper"
95
              style={{
96
                display: "block",
97
                fontSize: "clamp(1rem, 1.8vw, 1.125rem)",
98
                lineHeight: 1.5,
99
                textDecoration: "underline",
100
                textDecorationColor: "var(--dk-line)",
101
                textUnderlineOffset: "3px",
102
                marginBottom: law.summary ? "12px" : "16px",
103
              }}
104
            >
105
              {law.title}
106
            </Link>
107
 
108
            {}
109
            {law.summary ? (
110
              <p
111
                className="font-prose"
112
                style={{ fontSize: "0.9375rem", lineHeight: 1.6, color: "var(--dk-ash)", marginBottom: "16px" }}
113
              >
114
                {snippet(law.summary)}
115
              </p>
116
            ) : null}
117
 
118
            {}
119
            <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "12px" }}>
120
              <SourceLink url={law.sourceUrl} label="Official record" />
121
              {law.archivePdfUrl ? (
122
                <ArchiveLink url={law.archivePdfUrl} label="Archived text - Docket" />
123
              ) : null}
124
            </div>
125
          </div>
126
        </li>
127
      ))}
128
    </ul>
129
  );
130
}
131
 
132
export function LawsBrowser() {
133
  const sp = useSearchParams();
134
  const q = sp.get("q") ?? "";
135
  const sort = sp.get("sort") ?? "";
136
 
137
  const [laws, setLaws] = useState<LawListItem[]>([]);
138
  const [total, setTotal] = useState(0);
139
  const [loading, setLoading] = useState(true);
140
 
141
  useEffect(() => {
142
    setLoading(true);
143
    const params = new URLSearchParams();
144
    if (q) params.set("q", q);
145
    if (sort) params.set("sort", sort);
146
    params.set("limit", "200");
147
 
148
    fetch(`/api/laws?${params.toString()}`)
149
      .then((r) => r.json())
150
      .then((data) => {
151
        setLaws(data.items ?? []);
152
        setTotal(data.total ?? 0);
153
      })
154
      .catch(() => {
155
        setLaws([]);
156
        setTotal(0);
157
      })
158
      .finally(() => setLoading(false));
159
  }, [q, sort]);
160
 
161
  const isFiltered = !!(q || sort);
162
 
163
  if (loading) {
164
    return <LawsSkeleton />;
165
  }
166
 
167
  return (
168
    <div>
169
      <div className="mt-8">
170
        <form method="get" action="/laws" className="contents">
171
          <SearchFilterBar
172
            count={total}
173
            countLabel="law"
174
            clearHref={isFiltered ? "/laws" : undefined}
175
          >
176
            <FilterField label="Search laws" span="2">
177
              <FilterInput
178
                name="q"
179
                defaultValue={q}
180
                placeholder="Law number, bill number, or title…"
181
                aria-label="Search public laws"
182
              />
183
            </FilterField>
184
            <FilterField label="Sort by">
185
              <FilterSelect
186
                name="sort"
187
                defaultValue={sort}
188
                aria-label="Sort laws"
189
                options={[
190
                  { value: "", label: "Most recent" },
191
                  { value: "oldest", label: "Oldest first" },
192
                  { value: "number", label: "By law number" },
193
                ]}
194
              />
195
            </FilterField>
196
            <FilterSubmit />
197
          </SearchFilterBar>
198
        </form>
199
      </div>
200
 
201
      <section style={{ marginTop: "32px" }} aria-label="Public laws list">
202
        <p className="label" style={{ color: "var(--dk-ash)", marginBottom: "24px" }}>
203
          {total.toLocaleString()} public law{total === 1 ? "" : "s"}
204
          {isFiltered ? " match" : ""} · showing {laws.length}
205
        </p>
206
 
207
        {laws.length === 0 ? (
208
          <div className="rounded-[6px] border border-line bg-surface p-8" role="status">
209
            <p className="font-prose" style={{ fontSize: "1.0625rem", color: "var(--dk-ash)" }}>
210
              No public laws match this search in the current record.
211
            </p>
212
          </div>
213
        ) : (
214
          <LawCards laws={laws} />
215
        )}
216
      </section>
217
    </div>
218
  );
219
}