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

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