Skip to content

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

150 linesCodeBlameRaw
1import { Bot, Network, Search, UserPlus } from "lucide-react";
2import { Link, data, useSearchParams, type ShouldRevalidateFunctionArgs } from "react-router";
3
4import type { Route } from "./+types/people";
5import { LocalTime, PersonFace } from "../../components/people";
6import { ButtonLink, notACredential } from "../../components/ui";
7import { Badge } from "../../components/ui/badge";
8import { Button } from "../../components/ui/button";
9import { Card } from "../../components/ui/card";
10import { page } from "../../lib/meta";
11import { type DirectoryEntry, directoryEntries, personName, personPath } from "../../lib/people";
12import { identity } from "../../lib/services.server";
13import { getViewer, roleIn, unwrap } from "../../lib/session.server";
14
15export function meta({ params, ...args }: Route.MetaArgs) {
16 return page(args, { title: `People · ${params.owner} · g1t` });
17}
18
19/**
20 * The workspace's people. Its agents are not here: they are listed under
21 * Agents, and a team's page lists the agents on it with its people.
22 */
23export async function loader({ params, context }: Route.LoaderArgs) {
24 const viewer = getViewer(context);
25 // Who is in a workspace is its members' business.
26 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
27 const directory = await identity.peopleDirectory(viewer, params.owner).then(unwrap);
28 return { slug: params.owner.toLowerCase(), directory };
29}
30
31/** Searching changes only the address: everyone is already here. */
32export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
33 if (!formMethod && currentUrl.pathname === nextUrl.pathname) return false;
34 return defaultShouldRevalidate;
35}
36
37export default function PeopleDirectoryPage({ loaderData }: Route.ComponentProps) {
38 const { slug, directory } = loaderData;
39 const [params, setParams] = useSearchParams();
40 const query = params.get("q") ?? "";
41 const entries = directoryEntries(directory, query);
42 const total = directory.people.length;
43 const change = (key: string, value: string) =>
44 setParams(
45 (current) => {
46 const next = new URLSearchParams(current);
47 if (value) next.set(key, value);
48 else next.delete(key);
49 return next;
50 },
51 { replace: true, preventScrollReset: true },
52 );
53
54 return (
55 <div className="space-y-6">
56 <div className="flex flex-col gap-3 md:flex-row md:items-center">
57 <label className="relative block grow">
58 <span className="sr-only">Search people</span>
59 <Search size={15} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
60 <input
61 type="search"
62 value={query}
63 onChange={(event) => change("q", event.target.value)}
64 placeholder="Name, title, team or what they own"
65 {...notACredential()}
66 className="w-full rounded-lg border border-line bg-bg py-2 pr-3 pl-9 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
67 />
68 </label>
69 <div className="flex flex-wrap items-center gap-2">
70 <span role="status" className="mr-1 text-sm text-muted tabular-nums">
71 {query ? `${entries.length} of ${total}` : total} {total === 1 && !query ? "person" : "people"}
72 </span>
73 <ButtonLink to={`/${slug}/-/org-chart`} variant="outline">
74 <Network size={15} />
75 Org chart
76 </ButtonLink>
77 {directory.can_manage && (
78 <ButtonLink to={`/${slug}/-/members`}>
79 <UserPlus size={15} />
80 Invite people
81 </ButtonLink>
82 )}
83 </div>
84 </div>
85
86 {entries.length === 0 ? (
87 <Card asChild tone="plain" className="border-dashed px-4 py-10 text-center text-sm text-muted">
88 <p>
89 {query ? (
90 <>
91 Nobody matches “{query}”. Try a team, like “sales”, or a job, like “refunds”.{" "}
92 <Button type="button" variant="link" size="inline" onClick={() => change("q", "")}>
93 Show everyone
94 </Button>
95 </>
96 ) : (
97 "No one here yet."
98 )}
99 </p>
100 </Card>
101 ) : (
102 <ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
103 {entries.map((entry) => (
104 <li key={entry.person.username}>
105 <PersonCard slug={slug} entry={entry} />
106 </li>
107 ))}
108 </ul>
109 )}
110
111 <p className="flex items-start gap-2 text-xs text-faint">
112 <Bot size={14} className="mt-px shrink-0" />
113 <span>
114 Agents aren&apos;t listed here. The workspace&apos;s agents are under{" "}
115 <Link to={`/${slug}/-/agents`} className="text-muted hover:text-fg">
116 Agents
117 </Link>
118 , and a <Link to={`/${slug}/-/teams`} className="text-muted hover:text-fg">team</Link> lists the agents on it with its people.
119 </span>
120 </p>
121 </div>
122 );
123}
124
125/** One person: who they are, their title, their teams and their local time. */
126function PersonCard({ slug, entry }: { slug: string; entry: DirectoryEntry }) {
127 const { person } = entry;
128 const teams = entry.teams.map((team) => team.name);
129 return (
130 <Link
131 to={personPath(slug, person.username)}
132 prefetch="intent"
133 className="flex h-full items-start gap-3 rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"
134 >
135 <PersonFace person={person} size={44} ring="var(--color-surface)" />
136 <span className="min-w-0 grow">
137 <span className="flex items-center gap-1.5">
138 <span className="truncate font-medium">{personName(person)}</span>
139 {person.role === "owner" && <Badge>Owner</Badge>}
140 </span>
141 <span className="block truncate text-sm text-muted">{person.title ?? `@${person.display_username ?? person.username}`}</span>
142 <span className="mt-1.5 block truncate text-xs text-faint">
143 {teams.length ? teams.join(", ") : "No team"}
144 <LocalTime zone={person.timezone} className="before:mx-1.5 before:content-['·']" />
145 </span>
146 {person.owns.length > 0 && <span className="mt-1 block truncate text-xs text-fg-soft">Owns {person.owns.join(", ")}</span>}
147 </span>
148 </Link>
149 );
150}