Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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