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