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.

146 linesCodeBlameRaw
1import { Bot, 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 {directory.can_manage && (
74 <ButtonLink to={`/${slug}/-/members`}>
75 <UserPlus size={15} />
76 Invite people
77 </ButtonLink>
78 )}
79 </div>
80 </div>
81
82 {entries.length === 0 ? (
83 <Card asChild tone="plain" className="border-dashed px-4 py-10 text-center text-sm text-muted">
84 <p>
85 {query ? (
86 <>
87 Nobody matches “{query}”. Try a team, like “sales”, or a job, like “refunds”.{" "}
88 <Button type="button" variant="link" size="inline" onClick={() => change("q", "")}>
89 Show everyone
90 </Button>
91 </>
92 ) : (
93 "No one here yet."
94 )}
95 </p>
96 </Card>
97 ) : (
98 <ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
99 {entries.map((entry) => (
100 <li key={entry.person.username}>
101 <PersonCard slug={slug} entry={entry} />
102 </li>
103 ))}
104 </ul>
105 )}
106
107 <p className="flex items-start gap-2 text-xs text-faint">
108 <Bot size={14} className="mt-px shrink-0" />
109 <span>
110 Agents aren&apos;t listed here. The workspace&apos;s agents are under{" "}
111 <Link to={`/${slug}/-/agents`} className="text-muted hover:text-fg">
112 Agents
113 </Link>
114 , and a <Link to={`/${slug}/-/teams`} className="text-muted hover:text-fg">team</Link> lists the agents on it with its people.
115 </span>
116 </p>
117 </div>
118 );
119}
120
121/** One person: who they are, their title, their teams and their local time. */
122function PersonCard({ slug, entry }: { slug: string; entry: DirectoryEntry }) {
123 const { person } = entry;
124 const teams = entry.teams.map((team) => team.name);
125 return (
126 <Link
127 to={personPath(slug, person.username)}
128 prefetch="intent"
129 className="flex h-full items-start gap-3 rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"
130 >
131 <PersonFace person={person} size={44} ring="var(--color-surface)" />
132 <span className="min-w-0 grow">
133 <span className="flex items-center gap-1.5">
134 <span className="truncate font-medium">{personName(person)}</span>
135 {person.role === "owner" && <Badge>Owner</Badge>}
136 </span>
137 <span className="block truncate text-sm text-muted">{person.title ?? `@${person.display_username ?? person.username}`}</span>
138 <span className="mt-1.5 block truncate text-xs text-faint">
139 {teams.length ? teams.join(", ") : "No team"}
140 <LocalTime zone={person.timezone} className="before:mx-1.5 before:content-['·']" />
141 </span>
142 {person.owns.length > 0 && <span className="mt-1 block truncate text-xs text-fg-soft">Owns {person.owns.join(", ")}</span>}
143 </span>
144 </Link>
145 );
146}