| 1 | import { 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 { 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, directoryEntries, personName, personPath } from "../../lib/people"; |
| 12 | import { identity } 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 | /** The workspace's people. */ |
| 20 | export 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. */ |
| 29 | export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) { |
| 30 | if (!formMethod && currentUrl.pathname === nextUrl.pathname) return false; |
| 31 | return defaultShouldRevalidate; |
| 32 | } |
| 33 | |
| 34 | export 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. */ |
| 108 | function 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 | } |