Skip to content
Source
Sign inSign up
Open History to see the last commit

OpenSourceLicenses.tsx

288 lines9.7 KB

1import { ChevronRightIcon, ExternalLinkIcon, SearchIcon } from "lucide-react";
2import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
3import * as Cause from "effect/Cause";
4import * as Schema from "effect/Schema";
5import * as Effect from "effect/Effect";
6import * as Option from "effect/Option";
7import { FetchHttpClient, HttpClient } from "effect/http";
8import { Atom } from "effect/reactivity";
9import { useState } from "react";
10​
11import {
12 decodeThirdPartyLicenseManifest,
13 filterThirdPartyLicenseEntries,
14 formatLicenseBundles,
15 thirdPartyLicenseEntryKey,
16 type ThirdPartyLicenseEntry,
17} from "@t3tools/shared/thirdPartyLicenses";
18​
19import { Button } from "../ui/button";
20import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
21import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
22import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
23import { SettingsPageContainer, SettingsSection } from "./settingsLayout";
24​
25class LicenseManifestLoadError extends Schema.TaggedError<LicenseManifestLoadError>()(
26 "LicenseManifestLoadError",
27 { message: Schema.String },
28) {}
29​
30// Not keepAlive: a failed load must not stick for the session, so each visit to the page loads
31// the manifest again (a static asset the HTTP cache serves).
32const licenseManifestAtom = Atom.make(
33 Effect.gen(function* () {
34 const response = yield* HttpClient.get(
35 `${import.meta.env.BASE_URL.replace(/\/$/, "")}/third-party-licenses.json`,
36 );
37 if (response.status < 200 || response.status >= 300) {
38 return yield* new LicenseManifestLoadError({
39 message: `The license manifest request failed with status ${String(response.status)}.`,
40 });
41 }
42 const body = yield* response.json;
43 return yield* Effect.try({
44 try: () => decodeThirdPartyLicenseManifest(body),
45 catch: (error) =>
46 new LicenseManifestLoadError({
47 message: error instanceof Error ? error.message : "The license manifest could not load.",
48 }),
49 });
50 }).pipe(
51 Effect.catchTags({
52 HttpClientError: (error) =>
53 Effect.fail(new LicenseManifestLoadError({ message: error.message })),
54 }),
55 Effect.timeoutOrElse({
56 duration: "30 seconds",
57 orElse: () =>
58 Effect.fail(
59 new LicenseManifestLoadError({ message: "The license manifest took too long to load." }),
60 ),
61 }),
62 Effect.provide(FetchHttpClient.layer),
63 ),
64).pipe(Atom.withLabel("settings:open-source-licenses"));
65​
66function LicenseNoticeRow({
67 entry,
68 open,
69 onOpenChange,
70}: {
71 readonly entry: ThirdPartyLicenseEntry;
72 readonly open: boolean;
73 readonly onOpenChange: (open: boolean) => void;
74}) {
75 return (
76 <Collapsible open={open} onOpenChange={onOpenChange}>
77 <article>
78 <div className="flex min-h-10 items-center hover:bg-muted/35 sm:min-h-9">
79 <CollapsibleTrigger className="group flex min-h-10 min-w-0 flex-1 items-center gap-2.5 px-3 text-left sm:min-h-9 sm:px-4">
80 <ChevronRightIcon
81 aria-hidden
82 className="size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 group-data-panel-open:rotate-90"
83 />
84 <span className="flex min-w-0 flex-1 items-baseline gap-2">
85 <span className="truncate font-medium text-foreground">{entry.name}</span>
86 {entry.version ? (
87 <code className="shrink-0 text-xs text-muted-foreground">{entry.version}</code>
88 ) : null}
89 </span>
90 <span className="max-w-[42%] shrink-0 truncate text-xs text-muted-foreground">
91 {entry.license} · {formatLicenseBundles(entry.bundles)}
92 </span>
93 </CollapsibleTrigger>
94 {entry.sourceUrl ? (
95 <Button
96 aria-label={`View project source for ${entry.name}`}
97 className="me-3 shrink-0 sm:me-4"
98 render={<a href={entry.sourceUrl} rel="noreferrer noopener" target="_blank" />}
99 size="icon-micro"
100 title="Project source"
101 variant="ghost-muted"
102 >
103 <ExternalLinkIcon aria-hidden className="size-3" />
104 </Button>
105 ) : null}
106 </div>
107 <CollapsiblePanel>
108 {open ? (
109 <div className="px-9 pt-1 pb-4 sm:px-10">
110 <pre className="max-w-[76ch] whitespace-pre-wrap break-words font-mono text-xs/5 text-foreground/80">
111 {entry.noticeText}
112 </pre>
113 </div>
114 ) : null}
115 </CollapsiblePanel>
116 </article>
117 </Collapsible>
118 );
120​
121function LicenseCount({
122 filteredCount,
123 totalCount,
124}: {
125 filteredCount: number;
126 totalCount: number;
127}) {
128 return (
129 <p className="whitespace-nowrap text-xs font-normal text-muted-foreground tabular-nums">
130 {filteredCount === totalCount
131 ? `${String(totalCount)} notices`
132 : `${String(filteredCount)} of ${String(totalCount)}`}
133 </p>
134 );
136​
137function LicenseHeaderAction({
138 query,
139 onQueryChange,
140 searchOpen,
141 onSearchOpenChange,
142 filteredCount,
143 totalCount,
144}: {
145 query: string;
146 onQueryChange: (value: string) => void;
147 searchOpen: boolean;
148 onSearchOpenChange: (open: boolean) => void;
149 filteredCount: number;
150 totalCount: number;
151}) {
152 if (!searchOpen) {
153 return (
154 <div className="flex items-center gap-1.5">
155 <LicenseCount filteredCount={filteredCount} totalCount={totalCount} />
156 <Tooltip>
157 <TooltipTrigger
158 render={
159 <Button
160 aria-label="Search open-source licenses"
161 onClick={() => onSearchOpenChange(true)}
162 size="icon-micro"
163 type="button"
164 variant="ghost-muted"
165 >
166 <SearchIcon className="size-3" />
167 </Button>
168 }
169 />
170 <TooltipPopup side="top">Search licenses</TooltipPopup>
171 </Tooltip>
172 </div>
173 );
174 }
175​
176 return (
177 <div className="flex items-center gap-2">
178 <div className="hidden sm:block">
179 <LicenseCount filteredCount={filteredCount} totalCount={totalCount} />
180 </div>
181 <InputGroup className="w-36 sm:w-44">
182 <InputGroupAddon>
183 <SearchIcon aria-hidden className="size-3" />
184 </InputGroupAddon>
185 <InputGroupInput
186 aria-label="Search open-source licenses"
187 autoFocus
188 onBlur={() => {
189 if (query.length === 0) onSearchOpenChange(false);
190 }}
191 onChange={(event) => onQueryChange(event.currentTarget.value)}
192 onKeyDown={(event) => {
193 if (event.key !== "Escape") return;
194 event.preventDefault();
195 onQueryChange("");
196 onSearchOpenChange(false);
197 }}
198 placeholder="Search licenses"
199 size="sm"
200 type="search"
201 value={query}
202 />
203 </InputGroup>
204 </div>
205 );
207​
208function LicenseManifestError({ message, onRetry }: { message: string; onRetry: () => void }) {
209 return (
210 <div className="flex flex-col items-start gap-3 px-3 py-5 sm:px-4">
211 <div className="flex flex-col gap-1">
212 <h3 className="text-sm font-medium text-foreground">Open-source notices are unavailable</h3>
213 <p className="max-w-[70ch] text-pretty text-xs leading-normal text-muted-foreground/80">
214 {message}
215 </p>
216 </div>
217 <Button type="button" size="xs" variant="outline" onClick={onRetry}>
218 Try again
219 </Button>
220 </div>
221 );
223​
224export function OpenSourceLicensesPanel() {
225 const result = useAtomValue(licenseManifestAtom);
226 const retry = useAtomRefresh(licenseManifestAtom);
227 const [query, setQuery] = useState("");
228 const [searchOpen, setSearchOpen] = useState(false);
229 const [openEntryKey, setOpenEntryKey] = useState<string | null>(null);
230 const manifest = result._tag === "Success" ? result.value : null;
231 const entries = manifest?.entries ?? [];
232 const filteredEntries = filterThirdPartyLicenseEntries(entries, query);
233 const errorMessage =
234 result._tag === "Failure" && !result.waiting
235 ? Option.match(Cause.findErrorOption(result.cause), {
236 onNone: () => "The license manifest could not load.",
237 onSome: (error) => error.message,
238 })
239 : null;
240​
241 return (
242 <SettingsPageContainer>
243 <SettingsSection
244 title="Third-party notices"
245 headerAction={
246 manifest ? (
247 <LicenseHeaderAction
248 query={query}
249 onQueryChange={setQuery}
250 searchOpen={searchOpen}
251 onSearchOpenChange={setSearchOpen}
252 filteredCount={filteredEntries.length}
253 totalCount={entries.length}
254 />
255 ) : null
256 }
257 >
258 {manifest ? (
259 <div className="text-base sm:text-sm">
260 {filteredEntries.length > 0 ? (
261 filteredEntries.map((entry) => {
262 const entryKey = thirdPartyLicenseEntryKey(entry);
263 return (
264 <LicenseNoticeRow
265 key={entryKey}
266 entry={entry}
267 open={openEntryKey === entryKey}
268 onOpenChange={(open) => setOpenEntryKey(open ? entryKey : null)}
269 />
270 );
271 })
272 ) : (
273 <p className="px-3 py-8 text-center text-sm/6 text-muted-foreground sm:px-4">
274 No licenses match that search.
275 </p>
276 )}
277 </div>
278 ) : errorMessage !== null ? (
279 <LicenseManifestError message={errorMessage} onRetry={retry} />
280 ) : (
281 <p className="px-3 py-5 text-sm/6 text-muted-foreground sm:px-4">
282 Loading open-source notices…
283 </p>
284 )}
285 </SettingsSection>
286 </SettingsPageContainer>
287 );