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.

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