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