Skip to content

g1t/apps/web/app/routes/user.tsx

667 lines25,523 bytesCodeBlame
1/**
2 * A person's profile at `/u/<username>`, apart from workspaces at
3 * `/<workspace>` (as Docker Hub keeps people under `/u/`).
4 *
5 * The left column is who they are; the main column is their work, as
6 * tabs: an overview, then their pull requests and issues with a filter
7 * aside. Everything is filtered on the server by `work.byAuthor`, which
8 * only ever returns work on repositories the viewer may read, and the
9 * workspaces shown are only those the viewer could know about anyway (see
10 * `profile_workspaces` in services/identity/src/profiles.rs).
11 */
12import {
13 CalendarDays,
14 CircleDot,
15 GitMerge,
16 GitPullRequest,
17 LayoutGrid,
18 Link2,
19 MapPin,
20 Pencil,
21 Star,
22} from "lucide-react";
23import type { ReactNode } from "react";
24import { Form, Link, redirect, useNavigate, useSearchParams } from "react-router";
25
26import type { Authored, AuthoredItem, AuthoredSort, AuthoredState, Profile, StarredRepo } from "@g1t/contracts";
27
28import type { Route } from "./+types/user";
29import { page } from "../lib/meta";
30import { Avatar, Button, ButtonLink, EmptyState, TimeAgo } from "../components/ui";
31import { RadioGroup, RadioOption } from "../components/ui/radio-group";
32import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
33import { IssueIcon, PullIcon } from "../components/work-icons";
34import { notFound } from "../lib/not-found.server";
35import { identity, repos, work } from "../lib/services.server";
36import { getViewer } from "../lib/session.server";
37
38type Tab = "overview" | "pulls" | "issues" | "stars";
39
40const EMPTY: Authored = {
41 items: [],
42 next: null,
43 counts: { pullsMerged: 0, pullsOpen: 0, pulls: 0, issues: 0, issuesOpen: 0 },
44 repos: [],
45};
46
47/** How many recent items the overview shows. */
48const RECENT = 8;
49
50function displayName(profile: Profile): string {
51 return profile.name ? `${profile.name} (@${profile.username})` : profile.username;
52}
53
54export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
55 const profile = loaderData?.profile;
56 if (!profile) return page(args, { title: `${params.username} · g1t` });
57 return page(args, {
58 title: `${displayName(profile)} · g1t`,
59 description: profile.bio || `${profile.name ?? profile.username} (@${profile.username}) on g1t: their pull requests, issues and workspaces.`,
60 // What the card shows, so it is drawn again when any of it changes.
61 version: [profile.name, profile.bio, profile.avatar, loaderData.publicCounts],
62 });
63}
64
65function pick<T extends string>(value: string | null, allowed: readonly T[]): T | undefined {
66 return allowed.find((option) => option === value);
67}
68
69export async function loader({ params, request, context }: Route.LoaderArgs) {
70 const viewer = getViewer(context);
71 const url = new URL(request.url);
72 const username = params.username.toLowerCase();
73 const profile = await identity.profile(username);
74 if (!profile) throw notFound("person");
75 // One address per person: `/u/Ada` is `/u/ada`.
76 if (params.username !== profile.username) {
77 throw redirect(`/u/${profile.username}${url.search}`);
78 }
79
80 const query = url.searchParams;
81 const tab: Tab = pick(query.get("tab"), ["pulls", "issues", "stars"] as const) ?? "overview";
82 const state = pick(query.get("state"), (tab === "pulls" ? ["open", "closed", "merged"] : ["open", "closed"]) as AuthoredState[]);
83 const sort = pick(query.get("sort"), ["updated", "oldest"] as const satisfies AuthoredSort[]);
84 const repo = query.get("repo")?.trim() || undefined;
85 const before = query.get("before") || undefined;
86 const filter =
87 tab === "overview"
88 ? { limit: RECENT }
89 : { kind: tab === "pulls" ? ("pull" as const) : ("issue" as const), state, repo, sort, before };
90
91 // Only real memberships, and only those the viewer could see anyway.
92 const workspaces = identity
93 .userByUsername(username)
94 .then((person) => (person ? repos.publicNamespaces(person.id) : []))
95 .catch(() => [] as string[])
96 .then((publicIn) => identity.profileWorkspaces(username, viewer, publicIn));
97 // The card is drawn for no one in particular, so its version uses what
98 // everyone sees; for a signed-out viewer that is these counts already.
99 const [authored, shown, starred] = await Promise.all([
100 work.byAuthor(username, viewer, filter),
101 workspaces,
102 // What they starred that the viewer can see, on its own tab.
103 tab === "stars" ? repos.starred(username, viewer).catch(() => [] as StarredRepo[]) : ([] as StarredRepo[]),
104 ]);
105 const activity = authored.ok ? authored.value : EMPTY;
106 return {
107 profile,
108 tab,
109 filter: { state: state ?? null, repo: repo ?? null, sort: sort ?? "created", before: before ?? null },
110 activity,
111 failed: !authored.ok,
112 workspaces: shown,
113 starred,
114 isSelf: viewer?.username === profile.username,
115 publicCounts: viewer ? null : activity.counts,
116 };
117}
118
119export default function UserProfile({ loaderData }: Route.ComponentProps) {
120 const { profile, tab, activity, workspaces, isSelf } = loaderData;
121 return (
122 <main className="mx-auto max-w-6xl px-4 py-8 sm:px-8 md:py-10">
123 <div className="grid gap-8 md:grid-cols-[minmax(0,240px)_minmax(0,1fr)] lg:grid-cols-[minmax(0,280px)_minmax(0,1fr)] lg:gap-12">
124 <PersonColumn profile={profile} workspaces={workspaces} isSelf={isSelf} />
125 <section className="min-w-0">
126 <ProfileTabs username={profile.username} tab={tab} counts={activity.counts} />
127 <div className="mt-6">
128 {tab === "overview" ? (
129 <Overview {...loaderData} />
130 ) : tab === "stars" ? (
131 <StarredList starred={loaderData.starred} isSelf={isSelf} />
132 ) : (
133 <WorkList {...loaderData} />
134 )}
135 </div>
136 </section>
137 </div>
138 </main>
139 );
140}
141
142// --- Who they are -------------------------------------------------------------
143
144function joined(at: string): string {
145 // In UTC, so the server and the browser agree.
146 return new Date(at).toLocaleDateString("en-US", { month: "long", year: "numeric", timeZone: "UTC" });
147}
148
149function PersonColumn({
150 profile,
151 workspaces,
152 isSelf,
153}: {
154 profile: Profile;
155 workspaces: { slug: string; name: string; avatar: string | null }[];
156 isSelf: boolean;
157}) {
158 return (
159 <aside className="min-w-0">
160 <div className="flex items-center gap-4 md:block">
161 <span className="md:hidden">
162 <Avatar name={profile.username} image={profile.avatar} size={80} />
163 </span>
164 <span className="hidden md:block">
165 <span className="block w-fit rounded-full ring-1 ring-line">
166 <Avatar name={profile.username} image={profile.avatar} size={240} />
167 </span>
168 </span>
169 <div className="min-w-0 md:mt-5">
170 {profile.name ? (
171 <>
172 <h1 className="text-2xl leading-tight font-semibold tracking-tight wrap-anywhere">
173 {profile.name}
174 </h1>
175 <p className="mt-0.5 font-mono text-base text-muted">@{profile.username}</p>
176 </>
177 ) : (
178 <h1 className="font-mono text-2xl leading-tight font-semibold tracking-tight wrap-anywhere">
179 {profile.username}
180 </h1>
181 )}
182 {profile.pronouns && <p className="mt-1 text-sm text-faint">{profile.pronouns}</p>}
183 </div>
184 </div>
185
186 {profile.bio && <p className="mt-4 text-[0.9375rem] leading-relaxed text-fg/90">{profile.bio}</p>}
187
188 {isSelf && (
189 <div className="mt-4">
190 <ButtonLink to="/settings/profile" variant="quiet">
191 <Pencil size={14} />
192 Edit profile
193 </ButtonLink>
194 </div>
195 )}
196
197 <ul className="mt-4 space-y-1.5 text-sm text-muted">
198 {profile.location && (
199 <Detail icon={<MapPin size={15} />}>
200 <span className="wrap-anywhere">{profile.location}</span>
201 </Detail>
202 )}
203 {profile.website && (
204 <Detail icon={<Link2 size={15} />}>
205 <a
206 href={profile.website}
207 rel="nofollow ugc noopener noreferrer me"
208 target="_blank"
209 className="text-fg wrap-anywhere hover:underline"
210 >
211 {profile.website.replace(/^https:\/\//, "").replace(/\/$/, "")}
212 </a>
213 </Detail>
214 )}
215 <Detail icon={<CalendarDays size={15} />}>Joined {joined(profile.createdAt)}</Detail>
216 </ul>
217
218 {workspaces.length > 0 && (
219 <div className="mt-6 border-t border-line pt-5">
220 <h2 className="text-sm font-medium">Workspaces</h2>
221 <ul className="mt-3 space-y-1">
222 {workspaces.map((workspace) => (
223 <li key={workspace.slug}>
224 <Link
225 to={`/${workspace.slug}`}
226 className="-mx-2 flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-surface"
227 >
228 <Avatar name={workspace.slug} image={workspace.avatar} size={22} square />
229 <span className="min-w-0 truncate">{workspace.name}</span>
230 {workspace.name !== workspace.slug && (
231 <span className="ml-auto shrink-0 truncate font-mono text-xs text-faint">{workspace.slug}</span>
232 )}
233 </Link>
234 </li>
235 ))}
236 </ul>
237 </div>
238 )}
239 </aside>
240 );
241}
242
243function Detail({ icon, children }: { icon: ReactNode; children: ReactNode }) {
244 return (
245 <li className="flex items-start gap-2">
246 <span className="mt-0.5 shrink-0 text-faint">{icon}</span>
247 <span className="min-w-0">{children}</span>
248 </li>
249 );
250}
251
252// --- Tabs ------------------------------------------------------------------------
253
254function ProfileTabs({
255 username,
256 tab,
257 counts,
258}: {
259 username: string;
260 tab: Tab;
261 counts: Authored["counts"];
262}) {
263 const item = (value: Tab, label: string, icon: ReactNode, count?: number) => (
264 <Link
265 to={value === "overview" ? `/u/${username}` : `/u/${username}?tab=${value}`}
266 aria-current={tab === value ? "page" : undefined}
267 preventScrollReset
268 className={`-mb-px flex items-center gap-2 border-b-2 px-3 pb-3 text-sm whitespace-nowrap transition-colors ${
269 tab === value ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg"
270 }`}
271 >
272 {icon}
273 {label}
274 {count != null && count > 0 && (
275 <span className="rounded-full bg-raised px-1.5 py-px text-xs text-muted">{count}</span>
276 )}
277 </Link>
278 );
279 return (
280 <nav aria-label="Profile" className="flex gap-1 overflow-x-auto overflow-y-hidden border-b border-line [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
281 {item("overview", "Overview", <LayoutGrid size={15} />)}
282 {item("pulls", "Pull requests", <GitPullRequest size={15} />, counts.pulls)}
283 {item("issues", "Issues", <CircleDot size={15} />, counts.issues)}
284 {item("stars", "Stars", <Star size={15} />)}
285 </nav>
286 );
287}
288
289// --- Stars -------------------------------------------------------------------------
290
291function StarredList({ starred, isSelf }: { starred: StarredRepo[]; isSelf: boolean }) {
292 if (starred.length === 0) {
293 return (
294 <EmptyState title={isSelf ? "You have not starred anything yet" : "No stars yet"}>
295 {isSelf ? "Star a repository from the button beside Watch to keep it here." : null}
296 </EmptyState>
297 );
298 }
299 return (
300 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
301 {starred.map(({ repo, starredAt, stars }) => (
302 <li key={repo.id} className="px-4 py-3">
303 <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
304 <Link to={`/${repo.namespace}/${repo.name}`} className="min-w-0 truncate font-medium hover:text-accent">
305 <span className="text-muted">{repo.namespace}/</span>
306 {repo.name}
307 </Link>
308 <span className="ml-auto inline-flex items-center gap-1 text-xs text-muted tabular-nums">
309 <Star size={13} className="text-faint" />
310 {stars.toLocaleString("en-US")}
311 </span>
312 </div>
313 {repo.description && <p className="mt-1 text-sm text-muted">{repo.description}</p>}
314 <p className="mt-1 text-xs text-faint">
315 Starred <TimeAgo at={starredAt} />
316 </p>
317 </li>
318 ))}
319 </ul>
320 );
321}
322
323// --- Overview ----------------------------------------------------------------------
324
325type Data = Route.ComponentProps["loaderData"];
326
327function Overview({ profile, activity, failed }: Data) {
328 const { counts, items } = activity;
329 const who = profile.name ?? profile.username;
330 return (
331 <div className="space-y-8">
332 <dl className="grid grid-cols-1 gap-3 sm:grid-cols-3">
333 <Stat
334 label="Pull requests merged"
335 value={counts.pullsMerged}
336 icon={<GitMerge size={15} className="text-merged" />}
337 to={`/u/${profile.username}?tab=pulls&state=merged`}
338 />
339 <Stat
340 label="Open pull requests"
341 value={counts.pullsOpen}
342 icon={<GitPullRequest size={15} className="text-success" />}
343 to={`/u/${profile.username}?tab=pulls&state=open`}
344 />
345 <Stat
346 label="Issues opened"
347 value={counts.issues}
348 icon={<CircleDot size={15} className="text-success" />}
349 to={`/u/${profile.username}?tab=issues`}
350 />
351 </dl>
352
353 <section>
354 <h2 className="text-sm font-medium">Recent activity</h2>
355 <div className="mt-3">
356 {failed ? (
357 <EmptyState title="Activity could not be loaded">Try again in a moment.</EmptyState>
358 ) : items.length === 0 ? (
359 <EmptyState title={`No activity from ${who} yet`}>
360 Pull requests and issues @{profile.username} opens on repositories you can see show up here.
361 </EmptyState>
362 ) : (
363 <>
364 <ItemList items={items} />
365 {(counts.pulls > 0 || counts.issues > 0) && (
366 <p className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm">
367 {counts.pulls > 0 && (
368 <Link to={`/u/${profile.username}?tab=pulls`} className="text-muted hover:text-fg">
369 All {counts.pulls} pull {counts.pulls === 1 ? "request" : "requests"} →
370 </Link>
371 )}
372 {counts.issues > 0 && (
373 <Link to={`/u/${profile.username}?tab=issues`} className="text-muted hover:text-fg">
374 All {counts.issues} {counts.issues === 1 ? "issue" : "issues"} →
375 </Link>
376 )}
377 </p>
378 )}
379 </>
380 )}
381 </div>
382 </section>
383 </div>
384 );
385}
386
387function Stat({ label, value, icon, to }: { label: string; value: number; icon: ReactNode; to: string }) {
388 return (
389 <Link
390 to={to}
391 className="block rounded-xl border border-line bg-surface/50 px-4 py-3.5 transition-colors hover:border-line-strong hover:bg-surface"
392 >
393 <dt className="flex items-center gap-2 text-xs text-muted">
394 {icon}
395 {label}
396 </dt>
397 <dd className="mt-1.5 text-2xl font-semibold tabular-nums tracking-tight">{value.toLocaleString("en-US")}</dd>
398 </Link>
399 );
400}
401
402// --- Lists ---------------------------------------------------------------------------
403
404function itemUrl(item: AuthoredItem): string {
405 const base = `/${item.repo.namespace}/${item.repo.name}`;
406 return item.kind === "pull" ? `${base}/pull/${item.number}` : `${base}/issues/${item.number}`;
407}
408
409function ItemList({ items }: { items: AuthoredItem[] }) {
410 return (
411 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
412 {items.map((item) => (
413 <li key={`${item.kind}:${item.repo.namespace}/${item.repo.name}#${item.number}`}>
414 <Link
415 prefetch="intent"
416 to={itemUrl(item)}
417 className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-surface"
418 >
419 <span className="mt-0.5">
420 {item.kind === "pull" ? (
421 <PullIcon status={item.status ?? (item.state === "open" ? "open" : "closed")} />
422 ) : (
423 <IssueIcon issue={{ state: item.state, reason: item.reason }} />
424 )}
425 </span>
426 <span className="min-w-0 grow">
427 <span className="flex items-center gap-2">
428 <span className="truncate font-medium">{item.title}</span>
429 {item.draft && (
430 <span className="shrink-0 rounded-full border border-line px-1.5 py-px text-[0.6875rem] text-faint">
431 draft
432 </span>
433 )}
434 </span>
435 <span className="mt-0.5 block truncate text-xs text-faint">
436 <span className="font-mono text-muted">
437 {item.repo.namespace}/{item.repo.name}
438 </span>{" "}
439 #{item.number} · opened <TimeAgo at={item.createdAt} />
440 {item.merged && item.mergedAt ? (
441 <>
442 {" "}
443 · merged <TimeAgo at={item.mergedAt} />
444 </>
445 ) : item.updatedAt !== item.createdAt ? (
446 <>
447 {" "}
448 · updated <TimeAgo at={item.updatedAt} />
449 </>
450 ) : null}
451 </span>
452 </span>
453 </Link>
454 </li>
455 ))}
456 </ul>
457 );
458}
459
460const SORTS: { value: AuthoredSort; label: string }[] = [
461 { value: "created", label: "Newest" },
462 { value: "updated", label: "Recently updated" },
463 { value: "oldest", label: "Oldest" },
464];
465
466function WorkList({ profile, tab, filter, activity, failed }: Data) {
467 const pulls = tab === "pulls";
468 const noun = pulls ? "pull requests" : "issues";
469 const filtered = filter.state != null || filter.repo != null;
470 const base = `/u/${profile.username}?tab=${tab}`;
471 const [params] = useSearchParams();
472 const next = new URLSearchParams(params);
473 if (activity.next) next.set("before", activity.next);
474 const first = new URLSearchParams(params);
475 first.delete("before");
476 return (
477 <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_220px]">
478 <div className="min-w-0 lg:order-1">
479 {failed ? (
480 <EmptyState title={`${pulls ? "Pull requests" : "Issues"} could not be loaded`}>
481 Try again in a moment.
482 </EmptyState>
483 ) : activity.items.length === 0 ? (
484 filtered || filter.before ? (
485 <EmptyState title={`No ${noun} match these filters`}>
486 <Link to={base} className="text-fg underline underline-offset-4">
487 Clear filters
488 </Link>{" "}
489 to see all of them.
490 </EmptyState>
491 ) : (
492 <EmptyState title={`No ${noun} yet`}>
493 {pulls
494 ? `Pull requests @${profile.username} opens on repositories you can see show up here.`
495 : `Issues @${profile.username} opens on repositories you can see show up here.`}
496 </EmptyState>
497 )
498 ) : (
499 <>
500 <ItemList items={activity.items} />
501 {(activity.next || filter.before) && (
502 <nav aria-label="Pages" className="mt-4 flex items-center justify-between gap-3">
503 {filter.before ? (
504 <ButtonLink to={`?${first}`} variant="quiet" preventScrollReset>
505 ← {filter.sort === "oldest" ? "Oldest" : "Newest"}
506 </ButtonLink>
507 ) : (
508 <span />
509 )}
510 {activity.next && (
511 <ButtonLink to={`?${next}`} variant="quiet" preventScrollReset>
512 Next page →
513 </ButtonLink>
514 )}
515 </nav>
516 )}
517 </>
518 )}
519 </div>
520 <FilterAside tab={tab} filter={filter} activity={activity} username={profile.username} />
521 </div>
522 );
523}
524
525/**
526 * The filters beside a list, as on GitHub's search: each change reloads
527 * the list from the server, from the first page. Without script, the
528 * form's Apply button does the same.
529 */
530function FilterAside({
531 tab,
532 filter,
533 activity,
534 username,
535}: {
536 tab: Tab;
537 filter: Data["filter"];
538 activity: Authored;
539 username: string;
540}) {
541 const [params] = useSearchParams();
542 const navigate = useNavigate();
543 const set = (key: string, value: string | null) => {
544 const next = new URLSearchParams(params);
545 if (value === null) next.delete(key);
546 else next.set(key, value);
547 next.delete("before");
548 void navigate(`?${next}`, { preventScrollReset: true });
549 };
550 const pulls = tab === "pulls";
551 const { counts } = activity;
552 const states: { value: string; label: string; count?: number }[] = pulls
553 ? [
554 { value: "all", label: "All", count: counts.pulls },
555 { value: "open", label: "Open", count: counts.pullsOpen },
556 { value: "merged", label: "Merged", count: counts.pullsMerged },
557 { value: "closed", label: "Closed" },
558 ]
559 : [
560 { value: "all", label: "All", count: counts.issues },
561 { value: "open", label: "Open", count: counts.issuesOpen },
562 { value: "closed", label: "Closed", count: counts.issues - counts.issuesOpen },
563 ];
564 const filtered = filter.state != null || filter.repo != null || filter.sort !== "created";
565 return (
566 <aside aria-label="Filters" className="min-w-0 lg:order-2">
567 <Form method="get" className="space-y-5 rounded-xl border border-line p-4 lg:sticky lg:top-20">
568 <input type="hidden" name="tab" value={tab} />
569 <FilterGroup title="Type">
570 <RadioGroup
571 value={tab}
572 onValueChange={(value) => void navigate(`/u/${username}?tab=${value}`, { preventScrollReset: true })}
573 aria-label="Type"
574 >
575 <RadioOption value="pulls" label={<Counted label="Pull requests" count={counts.pulls} />} />
576 <RadioOption value="issues" label={<Counted label="Issues" count={counts.issues} />} />
577 </RadioGroup>
578 </FilterGroup>
579 <FilterGroup title="State">
580 <RadioGroup
581 name="state"
582 value={filter.state ?? "all"}
583 onValueChange={(value) => set("state", value === "all" ? null : value)}
584 aria-label="State"
585 >
586 {states.map((state) => (
587 <RadioOption
588 key={state.value}
589 value={state.value}
590 label={<Counted label={state.label} count={state.count} />}
591 />
592 ))}
593 </RadioGroup>
594 </FilterGroup>
595 <FilterGroup title="Repository">
596 <Select
597 name="repo"
598 value={filter.repo ?? "all"}
599 onValueChange={(value) => set("repo", value === "all" ? null : value)}
600 >
601 <SelectTrigger size="sm" aria-label="Repository">
602 <SelectValue />
603 </SelectTrigger>
604 <SelectContent>
605 <SelectItem value="all">All repositories</SelectItem>
606 {activity.repos.map(({ repo, count }) => {
607 const path = `${repo.namespace}/${repo.name}`;
608 return (
609 <SelectItem key={path} value={path} description={`${count} by @${username}`}>
610 {path}
611 </SelectItem>
612 );
613 })}
614 {filter.repo && !activity.repos.some(({ repo }) => `${repo.namespace}/${repo.name}` === filter.repo) && (
615 <SelectItem value={filter.repo}>{filter.repo}</SelectItem>
616 )}
617 </SelectContent>
618 </Select>
619 </FilterGroup>
620 <FilterGroup title="Sort">
621 <Select name="sort" value={filter.sort} onValueChange={(value) => set("sort", value === "created" ? null : value)}>
622 <SelectTrigger size="sm" aria-label="Sort">
623 <SelectValue />
624 </SelectTrigger>
625 <SelectContent>
626 {SORTS.map((sort) => (
627 <SelectItem key={sort.value} value={sort.value}>
628 {sort.label}
629 </SelectItem>
630 ))}
631 </SelectContent>
632 </Select>
633 </FilterGroup>
634 <div className="flex items-center justify-between gap-2">
635 <noscript>
636 <Button type="submit" variant="quiet">
637 Apply
638 </Button>
639 </noscript>
640 {filtered && (
641 <Link to={`/u/${username}?tab=${tab}`} preventScrollReset className="text-xs text-muted hover:text-fg">
642 Clear filters
643 </Link>
644 )}
645 </div>
646 </Form>
647 </aside>
648 );
649}
650
651function FilterGroup({ title, children }: { title: string; children: ReactNode }) {
652 return (
653 <fieldset className="min-w-0">
654 <legend className="mb-2 text-[0.6875rem] font-medium tracking-wide text-faint uppercase">{title}</legend>
655 {children}
656 </fieldset>
657 );
658}
659
660function Counted({ label, count }: { label: string; count?: number }) {
661 return (
662 <>
663 {label}
664 {count != null && <span className="text-xs text-faint tabular-nums">{count}</span>}
665 </>
666 );
667}