flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/web/app/routes/user.tsx

618 lines23,498 bytesCodeBlame

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