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.

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