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