g1t/apps/web/app/routes/user.tsx
| 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"; |
| 23 | import { Form, Link, redirect, useNavigate, useSearchParams } from "react-router"; |
| 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"; |
| 33 | import { notFound } from "../lib/not-found.server"; |
| 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); |
| 73 | if (!profile) throw notFound("person"); |
| 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 | } |