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