Unified · Split
| @@ -5,14 +5,16 @@ import { · 4 unmodified lines | |||
| 5 | decodeThirdPartyLicenseManifest, | 5 | decodeThirdPartyLicenseManifest, |
| 6 | filterThirdPartyLicenseEntries, | 6 | filterThirdPartyLicenseEntries, |
| 7 | formatLicenseBundles, | 7 | formatLicenseBundles, |
| 8 | thirdPartyLicenseEntryKey, | ||
| 8 | type ThirdPartyLicenseEntry, | 9 | type ThirdPartyLicenseEntry, |
| 9 | type ThirdPartyLicenseManifest, | 10 | type ThirdPartyLicenseManifest, |
| 10 | } from "@t3tools/shared/thirdPartyLicenses"; | 11 | } from "@t3tools/shared/thirdPartyLicenses"; |
| 11 | 12 | ||
| 12 | import { cn } from "../../lib/utils"; | ||
| 13 | import { Button } from "../ui/button"; | 13 | import { Button } from "../ui/button"; |
| 14 | import { Input } from "../ui/input"; | 14 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; |
| 15 | import { SettingsPageContainer } from "./settingsLayout"; | 15 | import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; |
| 16 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 17 | import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; | ||
| 16 | 18 | ||
| 17 | type LicenseManifestState = | 19 | type LicenseManifestState = |
| 18 | | { readonly status: "loading" } | 20 | | { readonly status: "loading" } |
| @@ -30,80 +32,154 @@ async function loadLicenseManifest(signal: AbortSignal): Promise<ThirdPartyLicen · 11 unmodified lines | |||
| 30 | return decodeThirdPartyLicenseManifest((await response.json()) as unknown); | 32 | return decodeThirdPartyLicenseManifest((await response.json()) as unknown); |
| 31 | } | 33 | } |
| 32 | 34 | ||
| 33 | function LicenseNoticeRow({ entry }: { readonly entry: ThirdPartyLicenseEntry }) { | 35 | function LicenseNoticeRow({ |
| 34 | const [open, setOpen] = useState(false); | 36 | entry, |
| 35 | const panelId = `license-notice-${encodeURIComponent(`${entry.kind}-${entry.name}-${entry.version ?? "custom"}`)}`; | 37 | open, |
| 36 | const versionLabel = entry.version ? ` ${entry.version}` : ""; | 38 | onOpenChange, |
| 37 | 39 | }: { | |
| 40 | readonly entry: ThirdPartyLicenseEntry; | ||
| 41 | readonly open: boolean; | ||
| 42 | readonly onOpenChange: (open: boolean) => void; | ||
| 43 | }) { | ||
| 38 | return ( | 44 | return ( |
| 39 | <article> | 45 | <Collapsible open={open} onOpenChange={onOpenChange}> |
| 40 | <button | 46 | <article> |
| 41 | type="button" | 47 | <div className="flex min-h-10 items-center hover:bg-muted/35 sm:min-h-9"> |
| 42 | aria-controls={panelId} | 48 | <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"> |
| 43 | aria-expanded={open} | 49 | <ChevronRightIcon |
| 44 | className="cursor-pointer flex min-h-14 w-full items-start gap-3 px-3 py-3 text-left hover:bg-foreground/4 sm:px-4" | 50 | aria-hidden |
| 45 | onClick={() => setOpen((value) => !value)} | 51 | className="size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 group-data-panel-open:rotate-90" |
| 46 | > | 52 | /> |
| 47 | <ChevronRightIcon | 53 | <span className="flex min-w-0 flex-1 items-baseline gap-2"> |
| 48 | aria-hidden | 54 | <span className="truncate font-medium text-foreground">{entry.name}</span> |
| 49 | className={cn( | 55 | {entry.version ? ( |
| 50 | "mt-0.5 size-4 shrink-0 text-muted-foreground transition-transform duration-150", | 56 | <code className="shrink-0 text-xs text-muted-foreground">{entry.version}</code> |
| 51 | open && "rotate-90", | ||
| 52 | )} | ||
| 53 | /> | ||
| 54 | <span className="min-w-0 flex-1"> | ||
| 55 | <span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1"> | ||
| 56 | <span className="min-w-0 break-words font-medium text-foreground">{entry.name}</span> | ||
| 57 | {entry.version ? ( | ||
| 58 | <code className="font-medium text-muted-foreground">{entry.version}</code> | ||
| 59 | ) : null} | ||
| 60 | </span> | ||
| 61 | <span className="text-muted-foreground"> | ||
| 62 | {entry.license} · {formatLicenseBundles(entry.bundles)} | ||
| 63 | </span> | ||
| 64 | </span> | ||
| 65 | </button> | ||
| 66 | {open ? ( | ||
| 67 | <div | ||
| 68 | id={panelId} | ||
| 69 | className="border-t border-foreground/10 bg-foreground/2 px-3 py-4 sm:px-4" | ||
| 70 | > | ||
| 71 | <div className="flex flex-col gap-3"> | ||
| 72 | <div className="flex flex-wrap items-center justify-between gap-3"> | ||
| 73 | <h2 className="text-base font-medium text-foreground sm:text-sm"> | ||
| 74 | {entry.name} | ||
| 75 | {versionLabel} | ||
| 76 | </h2> | ||
| 77 | {entry.sourceUrl ? ( | ||
| 78 | <a | ||
| 79 | className="inline-flex min-h-8 items-center gap-1.5 rounded-md px-2 text-base font-medium text-muted-foreground hover:bg-foreground/5 hover:text-foreground sm:text-sm" | ||
| 80 | href={entry.sourceUrl} | ||
| 81 | rel="noreferrer noopener" | ||
| 82 | target="_blank" | ||
| 83 | > | ||
| 84 | Project source | ||
| 85 | <ExternalLinkIcon aria-hidden className="size-4 shrink-0" /> | ||
| 86 | </a> | ||
| 87 | ) : null} | 57 | ) : null} |
| 88 | </div> | 58 | </span> |
| 89 | <pre className="max-w-full whitespace-pre-wrap break-words font-mono text-base/7 text-foreground/85 sm:text-sm/6"> | 59 | <span className="max-w-[42%] shrink-0 truncate text-xs text-muted-foreground"> |
| 90 | {entry.noticeText} | 60 | {entry.license} · {formatLicenseBundles(entry.bundles)} |
| 91 | </pre> | 61 | </span> |
| 92 | </div> | 62 | </CollapsibleTrigger> |
| 63 | {entry.sourceUrl ? ( | ||
| 64 | <Button | ||
| 65 | aria-label={`View project source for ${entry.name}`} | ||
| 66 | className="me-3 shrink-0 sm:me-4" | ||
| 67 | render={<a href={entry.sourceUrl} rel="noreferrer noopener" target="_blank" />} | ||
| 68 | size="icon-micro" | ||
| 69 | title="Project source" | ||
| 70 | variant="ghost-muted" | ||
| 71 | > | ||
| 72 | <ExternalLinkIcon aria-hidden className="size-3" /> | ||
| 73 | </Button> | ||
| 74 | ) : null} | ||
| 93 | </div> | 75 | </div> |
| 94 | ) : null} | 76 | <CollapsiblePanel> |
| 95 | </article> | 77 | {open ? ( |
| 78 | <div className="px-9 pt-1 pb-4 sm:px-10"> | ||
| 79 | <pre className="max-w-[76ch] whitespace-pre-wrap break-words font-mono text-xs/5 text-foreground/80"> | ||
| 80 | {entry.noticeText} | ||
| 81 | </pre> | ||
| 82 | </div> | ||
| 83 | ) : null} | ||
| 84 | </CollapsiblePanel> | ||
| 85 | </article> | ||
| 86 | </Collapsible> | ||
| 87 | ); | ||
| 88 | } | ||
| 89 | |||
| 90 | function LicenseCount({ | ||
| 91 | filteredCount, | ||
| 92 | totalCount, | ||
| 93 | }: { | ||
| 94 | filteredCount: number; | ||
| 95 | totalCount: number; | ||
| 96 | }) { | ||
| 97 | return ( | ||
| 98 | <p className="whitespace-nowrap text-xs font-normal text-muted-foreground tabular-nums"> | ||
| 99 | {filteredCount === totalCount | ||
| 100 | ? `${String(totalCount)} notices` | ||
| 101 | : `${String(filteredCount)} of ${String(totalCount)}`} | ||
| 102 | </p> | ||
| 103 | ); | ||
| 104 | } | ||
| 105 | |||
| 106 | function LicenseHeaderAction({ | ||
| 107 | query, | ||
| 108 | onQueryChange, | ||
| 109 | searchOpen, | ||
| 110 | onSearchOpenChange, | ||
| 111 | filteredCount, | ||
| 112 | totalCount, | ||
| 113 | }: { | ||
| 114 | query: string; | ||
| 115 | onQueryChange: (value: string) => void; | ||
| 116 | searchOpen: boolean; | ||
| 117 | onSearchOpenChange: (open: boolean) => void; | ||
| 118 | filteredCount: number; | ||
| 119 | totalCount: number; | ||
| 120 | }) { | ||
| 121 | if (!searchOpen) { | ||
| 122 | return ( | ||
| 123 | <div className="flex items-center gap-1.5"> | ||
| 124 | <LicenseCount filteredCount={filteredCount} totalCount={totalCount} /> | ||
| 125 | <Tooltip> | ||
| 126 | <TooltipTrigger | ||
| 127 | render={ | ||
| 128 | <Button | ||
| 129 | aria-label="Search open-source licenses" | ||
| 130 | onClick={() => onSearchOpenChange(true)} | ||
| 131 | size="icon-micro" | ||
| 132 | type="button" | ||
| 133 | variant="ghost-muted" | ||
| 134 | > | ||
| 135 | <SearchIcon className="size-3" /> | ||
| 136 | </Button> | ||
| 137 | } | ||
| 138 | /> | ||
| 139 | <TooltipPopup side="top">Search licenses</TooltipPopup> | ||
| 140 | </Tooltip> | ||
| 141 | </div> | ||
| 142 | ); | ||
| 143 | } | ||
| 144 | |||
| 145 | return ( | ||
| 146 | <div className="flex items-center gap-2"> | ||
| 147 | <div className="hidden sm:block"> | ||
| 148 | <LicenseCount filteredCount={filteredCount} totalCount={totalCount} /> | ||
| 149 | </div> | ||
| 150 | <InputGroup className="w-36 sm:w-44"> | ||
| 151 | <InputGroupAddon> | ||
| 152 | <SearchIcon aria-hidden className="size-3" /> | ||
| 153 | </InputGroupAddon> | ||
| 154 | <InputGroupInput | ||
| 155 | aria-label="Search open-source licenses" | ||
| 156 | autoFocus | ||
| 157 | onBlur={() => { | ||
| 158 | if (query.length === 0) onSearchOpenChange(false); | ||
| 159 | }} | ||
| 160 | onChange={(event) => onQueryChange(event.currentTarget.value)} | ||
| 161 | onKeyDown={(event) => { | ||
| 162 | if (event.key !== "Escape") return; | ||
| 163 | event.preventDefault(); | ||
| 164 | onQueryChange(""); | ||
| 165 | onSearchOpenChange(false); | ||
| 166 | }} | ||
| 167 | placeholder="Search licenses" | ||
| 168 | size="sm" | ||
| 169 | type="search" | ||
| 170 | value={query} | ||
| 171 | /> | ||
| 172 | </InputGroup> | ||
| 173 | </div> | ||
| 96 | ); | 174 | ); |
| 97 | } | 175 | } |
| 98 | 176 | ||
| 99 | function LicenseManifestError({ message, onRetry }: { message: string; onRetry: () => void }) { | 177 | function LicenseManifestError({ message, onRetry }: { message: string; onRetry: () => void }) { |
| 100 | return ( | 178 | return ( |
| 101 | <div className="flex flex-col items-start gap-3 border-y border-foreground/10 px-3 py-5 sm:px-4"> | 179 | <div className="flex flex-col items-start gap-3 px-3 py-5 sm:px-4"> |
| 102 | <div className="flex flex-col gap-1"> | 180 | <div className="flex flex-col gap-1"> |
| 103 | <h2 className="text-base font-medium text-foreground sm:text-sm"> | 181 | <h3 className="text-sm font-medium text-foreground">Open-source notices are unavailable</h3> |
| 104 | Open-source notices are unavailable | 182 | <p className="max-w-[70ch] text-pretty text-[13px] leading-[1.45] text-muted-foreground/80"> |
| 105 | </h2> | ||
| 106 | <p className="max-w-[70ch] text-pretty text-base/7 text-muted-foreground sm:text-sm/6"> | ||
| 107 | {message} | 183 | {message} |
| 108 | </p> | 184 | </p> |
| 109 | </div> | 185 | </div> |
| @@ -117,6 +193,8 @@ function LicenseManifestError({ message, onRetry }: { message: string; onRetry: · 7 unmodified lines | |||
| 117 | export function OpenSourceLicensesPanel() { | 193 | export function OpenSourceLicensesPanel() { |
| 118 | const [state, setState] = useState<LicenseManifestState>({ status: "loading" }); | 194 | const [state, setState] = useState<LicenseManifestState>({ status: "loading" }); |
| 119 | const [query, setQuery] = useState(""); | 195 | const [query, setQuery] = useState(""); |
| 196 | const [searchOpen, setSearchOpen] = useState(false); | ||
| 197 | const [openEntryKey, setOpenEntryKey] = useState<string | null>(null); | ||
| 120 | const [requestVersion, setRequestVersion] = useState(0); | 198 | const [requestVersion, setRequestVersion] = useState(0); |
| 121 | 199 | ||
| 122 | useEffect(() => { | 200 | useEffect(() => { |
| @@ -143,63 +221,50 @@ export function OpenSourceLicensesPanel() { · 20 unmodified lines | |||
| 143 | const retry = useCallback(() => setRequestVersion((value) => value + 1), []); | 221 | const retry = useCallback(() => setRequestVersion((value) => value + 1), []); |
| 144 | 222 | ||
| 145 | return ( | 223 | return ( |
| 146 | <SettingsPageContainer width="wide" className="gap-6"> | 224 | <SettingsPageContainer> |
| 147 | <header className="flex flex-col gap-2 px-3 sm:px-4"> | 225 | <SettingsSection |
| 148 | <h1 className="text-balance text-2xl font-semibold tracking-tight text-foreground"> | 226 | title="Third-party notices" |
| 149 | Open source licenses | 227 | headerAction={ |
| 150 | </h1> | 228 | state.status === "ready" ? ( |
| 151 | <p className="max-w-[70ch] text-pretty text-base/7 text-muted-foreground sm:text-sm/6"> | 229 | <LicenseHeaderAction |
| 152 | Third-party notices for dependencies and assets included in T3 Code. | 230 | query={query} |
| 153 | </p> | 231 | onQueryChange={setQuery} |
| 154 | </header> | 232 | searchOpen={searchOpen} |
| 155 | 233 | onSearchOpenChange={setSearchOpen} | |
| 156 | {state.status === "ready" ? ( | 234 | filteredCount={filteredEntries.length} |
| 157 | <div className="flex flex-col gap-3"> | 235 | totalCount={entries.length} |
| 158 | <div className="flex flex-col gap-2 px-3 sm:flex-row sm:items-center sm:justify-between sm:px-4"> | 236 | /> |
| 159 | <label className="relative min-w-0 flex-1 sm:max-w-sm"> | 237 | ) : null |
| 160 | <SearchIcon | 238 | } |
| 161 | aria-hidden | 239 | > |
| 162 | className="pointer-events-none absolute top-1/2 left-2.5 z-1 size-4 shrink-0 -translate-y-1/2 text-muted-foreground" | 240 | {state.status === "ready" ? ( |
| 163 | /> | 241 | <div className="text-base sm:text-sm"> |
| 164 | <Input | ||
| 165 | nativeInput | ||
| 166 | type="search" | ||
| 167 | value={query} | ||
| 168 | onChange={(event) => setQuery(event.currentTarget.value)} | ||
| 169 | placeholder="Search packages" | ||
| 170 | aria-label="Search open-source licenses" | ||
| 171 | className="[&_[data-slot=input]]:pl-8" | ||
| 172 | /> | ||
| 173 | </label> | ||
| 174 | <p className="text-base text-muted-foreground tabular-nums sm:text-sm"> | ||
| 175 | {filteredEntries.length === entries.length | ||
| 176 | ? `${String(entries.length)} notices` | ||
| 177 | : `${String(filteredEntries.length)} of ${String(entries.length)} notices`} | ||
| 178 | </p> | ||
| 179 | </div> | ||
| 180 | |||
| 181 | <div className="overflow-hidden rounded-xl border border-foreground/10 divide-y divide-foreground/10 text-base sm:text-sm"> | ||
| 182 | {filteredEntries.length > 0 ? ( | 242 | {filteredEntries.length > 0 ? ( |
| 183 | filteredEntries.map((entry) => ( | 243 | filteredEntries.map((entry) => { |
| 184 | <LicenseNoticeRow | 244 | const entryKey = thirdPartyLicenseEntryKey(entry); |
| 185 | key={`${entry.kind}:${entry.name}:${entry.version ?? "custom"}`} | 245 | return ( |
| 186 | entry={entry} | 246 | <LicenseNoticeRow |
| 187 | /> | 247 | key={entryKey} |
| 188 | )) | 248 | entry={entry} |
| 249 | open={openEntryKey === entryKey} | ||
| 250 | onOpenChange={(open) => setOpenEntryKey(open ? entryKey : null)} | ||
| 251 | /> | ||
| 252 | ); | ||
| 253 | }) | ||
| 189 | ) : ( | 254 | ) : ( |
| 190 | <p className="px-3 py-8 text-center text-base/7 text-muted-foreground sm:px-4 sm:text-sm/6"> | 255 | <p className="px-3 py-8 text-center text-sm/6 text-muted-foreground sm:px-4"> |
| 191 | No licenses match that search. | 256 | No licenses match that search. |
| 192 | </p> | 257 | </p> |
| 193 | )} | 258 | )} |
| 194 | </div> | 259 | </div> |
| 195 | </div> | 260 | ) : state.status === "error" ? ( |
| 196 | ) : state.status === "error" ? ( | 261 | <LicenseManifestError message={state.message} onRetry={retry} /> |
| 197 | <LicenseManifestError message={state.message} onRetry={retry} /> | 262 | ) : ( |
| 198 | ) : ( | 263 | <p className="px-3 py-5 text-sm/6 text-muted-foreground sm:px-4"> |
| 199 | <p className="border-y border-foreground/10 px-3 py-5 text-base/7 text-muted-foreground sm:px-4 sm:text-sm/6"> | 264 | Loading open-source notices… |
| 200 | Loading open-source notices… | 265 | </p> |
| 201 | </p> | 266 | )} |
| 202 | )} | 267 | </SettingsSection> |
| 203 | </SettingsPageContainer> | 268 | </SettingsPageContainer> |
| 204 | ); | 269 | ); |
| 205 | } | 270 | } |