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.

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