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