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, | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 21 | Star, |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 22 | } from "lucide-react"; |
| 23 | 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 | 24 | import { Form, Link, redirect, useNavigate, useSearchParams } from "react-router"; |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 25 | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 26 | import type { Authored, AuthoredItem, AuthoredSort, AuthoredState, Profile, StarredRepo } from "@g1t/contracts"; |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 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"; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 33 | import { IssueIcon, PullIcon } from "../components/work-icons"; |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 34 | import { notFound } from "../lib/not-found.server"; |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 35 | import { identity, repos, work } from "../lib/services.server"; |
| 36 | import { getViewer } from "../lib/session.server"; | |
| 37 | ||
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 38 | type Tab = "overview" | "pulls" | "issues" | "stars"; |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 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); | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 74 | if (!profile) throw notFound("person"); |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 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; | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 81 | const tab: Tab = pick(query.get("tab"), ["pulls", "issues", "stars"] as const) ?? "overview"; |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 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. | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 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 | ]); | |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 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, | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 113 | starred, |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 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"> | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 128 | {tab === "overview" ? ( |
| 129 | <Overview {...loaderData} /> | |
| 130 | ) : tab === "stars" ? ( | |
| 131 | <StarredList starred={loaderData.starred} isSelf={isSelf} /> | |
| 132 | ) : ( | |
| 133 | <WorkList {...loaderData} /> | |
| 134 | )} | |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 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"> | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 190 | <ButtonLink to="/settings/profile" variant="quiet"> |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 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 | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 268 | className={`-mb-px flex items-center gap-2 border-b-2 px-3 pb-3 text-sm whitespace-nowrap transition-colors ${ |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 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 ( | |
| Tab bars on pull requests, search and profiles scroll without showing a scrollbar | 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"> |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 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)} | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 284 | {item("stars", "Stars", <Star size={15} />)} |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 285 | </nav> |
| 286 | ); | |
| 287 | } | |
| 288 | ||
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 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 | ||
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 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} | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 342 | icon={<GitPullRequest size={15} className="text-success" />} |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 343 | to={`/u/${profile.username}?tab=pulls&state=open`} |
| 344 | /> | |
| 345 | <Stat | |
| 346 | label="Issues opened" | |
| 347 | value={counts.issues} | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 348 | icon={<CircleDot size={15} className="text-success" />} |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.