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.

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