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