Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 1 | import { useEffect, useId, useRef, useState } from "react"; |
| 2 | ||
| 3 | import type { PersonMatch } from "@g1t/contracts"; | |
| 4 | ||
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 5 | import { Input } from "./ui"; |
| 6 | import { Avatar } from "./ui/avatar"; | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 7 | import { UserCard } from "./user-card"; |
| 8 | import { PEOPLE_SEARCH_PATH, peopleQuery } from "../lib/people-search"; | |
| 9 | ||
| 10 | /** | |
| 11 | * The invite form's "who" field on People: type a username, a name or an | |
| 12 | * email address. As someone types a name, people on g1t are offered by | |
| 13 | * username and name, with their avatars and the card over each name; an | |
| 14 | * email address is never looked up. Posts what is in the field as `name`. | |
| 15 | */ | |
| 16 | export function PeoplePicker({ name, placeholder }: { name: string; placeholder?: string }) { | |
| 17 | const [text, setText] = useState(""); | |
| 18 | const [people, setPeople] = useState<PersonMatch[]>([]); | |
| 19 | const [open, setOpen] = useState(false); | |
| 20 | const [active, setActive] = useState(0); | |
| 21 | const listId = useId(); | |
| 22 | const asked = useRef(0); | |
| 23 | ||
| 24 | useEffect(() => { | |
| 25 | const query = peopleQuery(text); | |
| 26 | if (!query) { | |
| 27 | setPeople([]); | |
| 28 | return; | |
| 29 | } | |
| 30 | const ask = ++asked.current; | |
| 31 | const timer = setTimeout(() => { | |
| 32 | fetch(`${PEOPLE_SEARCH_PATH}?q=${encodeURIComponent(query)}`, { headers: { accept: "application/json" } }) | |
| 33 | .then((response) => (response.ok ? (response.json() as Promise<{ people?: PersonMatch[] }>) : { people: [] })) | |
| 34 | .then((found) => { | |
| 35 | if (ask !== asked.current) return; | |
| 36 | setPeople(found.people ?? []); | |
| 37 | setActive(0); | |
| 38 | }) | |
| 39 | .catch(() => { | |
| 40 | if (ask === asked.current) setPeople([]); | |
| 41 | }); | |
| 42 | }, 150); | |
| 43 | return () => clearTimeout(timer); | |
| 44 | }, [text]); | |
| 45 | ||
| 46 | const choose = (person: PersonMatch) => { | |
| 47 | setText(person.username); | |
| 48 | setPeople([]); | |
| 49 | setOpen(false); | |
| 50 | }; | |
| 51 | const showing = open && people.length > 0; | |
| 52 | ||
| 53 | return ( | |
| 54 | <div className="relative"> | |
| 55 | <Input | |
| 56 | name={name} | |
| 57 | required | |
| 58 | maxLength={254} | |
| 59 | autoComplete="off" | |
| 60 | placeholder={placeholder} | |
| 61 | value={text} | |
| 62 | role="combobox" | |
| 63 | aria-expanded={showing} | |
| 64 | aria-controls={listId} | |
| 65 | aria-autocomplete="list" | |
| 66 | aria-activedescendant={showing ? `${listId}-${active}` : undefined} | |
| 67 | onChange={(event) => { | |
| 68 | setText(event.currentTarget.value); | |
| 69 | setOpen(true); | |
| 70 | }} | |
| 71 | onFocus={() => setOpen(true)} | |
| 72 | onBlur={() => setTimeout(() => setOpen(false), 150)} | |
| 73 | onKeyDown={(event) => { | |
| 74 | if (!showing) return; | |
| 75 | if (event.key === "ArrowDown") { | |
| 76 | event.preventDefault(); | |
| 77 | setActive((at) => (at + 1) % people.length); | |
| 78 | } else if (event.key === "ArrowUp") { | |
| 79 | event.preventDefault(); | |
| 80 | setActive((at) => (at - 1 + people.length) % people.length); | |
| 81 | } else if (event.key === "Enter" && people[active] && people[active].username !== text.trim().replace(/^@+/, "")) { | |
| 82 | event.preventDefault(); | |
| 83 | choose(people[active]); | |
| 84 | } else if (event.key === "Escape") { | |
| 85 | setOpen(false); | |
| 86 | } | |
| 87 | }} | |
| 88 | /> | |
| 89 | {showing && ( | |
| 90 | <ul | |
| 91 | id={listId} | |
| 92 | role="listbox" | |
| 93 | className="absolute top-full right-0 left-0 z-20 mt-1 max-h-72 overflow-y-auto rounded-md border border-line bg-surface p-1 shadow-lg" | |
| 94 | > | |
| 95 | {people.map((person, index) => ( | |
| 96 | <li | |
| 97 | key={person.username} | |
| 98 | id={`${listId}-${index}`} | |
| 99 | role="option" | |
| 100 | aria-selected={index === active} | |
| 101 | className={`flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm ${index === active ? "bg-line text-fg" : "text-fg/90"}`} | |
| 102 | onMouseEnter={() => setActive(index)} | |
| 103 | // Before the input's blur, so the choice is not lost. | |
| 104 | onMouseDown={(event) => { | |
| 105 | event.preventDefault(); | |
| 106 | choose(person); | |
| 107 | }} | |
| 108 | > | |
| 109 | <Avatar name={person.username} image={person.avatar} size={20} /> | |
| 110 | <UserCard username={person.username}> | |
| 111 | <span className="font-mono">{person.username}</span> | |
| 112 | </UserCard> | |
| 113 | {person.name && <span className="min-w-0 truncate text-muted">{person.name}</span>} | |
| 114 | </li> | |
| 115 | ))} | |
| 116 | </ul> | |
| 117 | )} | |
| 118 | </div> | |
| 119 | ); | |
| 120 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.