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.

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