| 1 | import { Network, Search, UserPlus } from "lucide-react"; |
| 2 | import { Link, data, useSearchParams, type ShouldRevalidateFunctionArgs } from "react-router"; |
| 3 | |
| 4 | import type { Route } from "./+types/people"; |
| 5 | import { AgentPersonFace, AgentTag, LocalTime, PersonFace } from "../../components/people"; |
| 6 | import { ButtonLink, notACredential } from "../../components/ui"; |
| 7 | import { Badge } from "../../components/ui/badge"; |
| 8 | import { page } from "../../lib/meta"; |
| 9 | import { type DirectoryEntry, type DirectoryKind, agentPath, directoryEntries, directoryKind, peopleAgent, personName, personPath } from "../../lib/people"; |
| 10 | import { identity, workspaceAgents } from "../../lib/services.server"; |
| 11 | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; |
| 12 | |
| 13 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 14 | return page(args, { title: `People · ${params.owner} · g1t` }); |
| 15 | } |
| 16 | |
| 17 | export 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. */ |
| 33 | export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) { |
| 34 | if (!formMethod && currentUrl.pathname === nextUrl.pathname) return false; |
| 35 | return defaultShouldRevalidate; |
| 36 | } |
| 37 | |
| 38 | const KINDS: { value: DirectoryKind; label: string }[] = [ |
| 39 | { value: "everyone", label: "Everyone" }, |
| 40 | { value: "people", label: "People" }, |
| 41 | { value: "agents", label: "Agents" }, |
| 42 | ]; |
| 43 | |
| 44 | export 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. */ |
| 133 | function 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 | } |