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.
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1 | import { Mail } from "lucide-react"; |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 2 | import { useState } from "react"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 3 | import { Form, Link } from "react-router"; |
| 4 | ||
| 5 | import type { Invite, InvitesOverview } from "@g1t/contracts"; | |
| 6 | ||
| Merge branch 'worktree-agent-a985c01f43538af01' | 7 | import { CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui"; |
| 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. | 8 | import { Badge } from "./ui/badge"; |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 9 | import { CheckboxOption } from "./ui/checkbox"; |
| 10 | import { Hint } from "./ui/hint"; | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 11 | import { SelectField } from "./ui/select"; |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 12 | import { G1T_INVITES, inviteFor, inviteKind, inviteLink, invitesPage, inviteState, moreInvitesMailto, remainingLine } from "../lib/invites"; |
| 13 | import type { BringIntoChoices } from "../lib/invites.server"; | |
| 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) | 14 | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 15 | const SELECT = "h-auto w-full py-2 sm:w-auto sm:min-w-40"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 16 | |
| 17 | const TONE: Record<"pending" | "done" | "dead", string> = { | |
| 18 | pending: "border-accent/40 text-accent", | |
| 19 | done: "border-merged/40 text-merged", | |
| 20 | dead: "border-line text-faint", | |
| 21 | }; | |
| 22 | ||
| 23 | function InviteRow({ invite, origin }: { invite: Invite; origin: string }) { | |
| 24 | const state = inviteState(invite); | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 25 | const kind = inviteKind(invite); |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 26 | return ( |
| 27 | <li className="space-y-2 px-4 py-3"> | |
| 28 | <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> | |
| 29 | <span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs ${TONE[state.tone]}`}>{state.label}</span> | |
| 30 | <span className="min-w-0 truncate text-sm">{inviteFor(invite)}</span> | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 31 | <Hint |
| 32 | label={ | |
| 33 | kind.kind === "g1t" | |
| 34 | ? "Lets them make an account. It adds them to no workspace." | |
| 35 | : "An invitation to join this workspace, which they accept or decline. Without an account, it lets them make one first." | |
| 36 | } | |
| 37 | > | |
| 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. | 38 | <Badge size="md">{kind.label}</Badge> |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 39 | </Hint> |
| 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) | 40 | {(invite.status === "pending" || invite.status === "awaiting_confirmation" || invite.status === "awaiting_answer") && ( |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 41 | <Form method="post" className="ml-auto"> |
| 42 | <input type="hidden" name="intent" value="revoke-invite" /> | |
| 43 | <input type="hidden" name="id" value={invite.id} /> | |
| 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. | 44 | <SubmitButton variant="outline" match={{ intent: "revoke-invite", id: invite.id }} pending="Revoking…"> |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 45 | Revoke |
| Merge branch 'worktree-agent-a985c01f43538af01' | 46 | </SubmitButton> |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 47 | </Form> |
| 48 | )} | |
| 49 | </div> | |
| 50 | <p className="text-xs text-faint"> | |
| 51 | <span className="font-mono">{invite.hint}…</span> · made <TimeAgo at={invite.createdAt} /> | |
| 52 | {invite.status === "pending" && ( | |
| 53 | <> | |
| 54 | {" "}· works until {new Date(invite.expiresAt).toISOString().slice(0, 10)} | |
| 55 | </> | |
| 56 | )} | |
| 57 | {invite.chargedTo === "workspace" && invite.workspace == null && " · from a workspace's invites"} | |
| 58 | </p> | |
| 59 | {invite.status === "pending" && invite.code && <CopyLine text={inviteLink(invite.code, origin)} />} | |
| 60 | </li> | |
| 61 | ); | |
| 62 | } | |
| 63 | ||
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 64 | /** "To bring someone into a workspace, invite them from that workspace's People page", with those pages. */ |
| 65 | function Elsewhere({ people }: { people: BringIntoChoices["people"] }) { | |
| 66 | return ( | |
| 67 | <p className="mt-4 text-sm text-muted"> | |
| 68 | {G1T_INVITES.elsewhere} | |
| 69 | {people.length > 0 ? ": " : "."} | |
| 70 | {people.map((workspace, index) => ( | |
| 71 | <span key={workspace.slug}> | |
| 72 | {index > 0 && ", "} | |
| 73 | <Link to={workspace.to} className="text-fg underline underline-offset-4 hover:text-accent"> | |
| 74 | {workspace.name} | |
| 75 | </Link> | |
| 76 | </span> | |
| 77 | ))} | |
| 78 | {people.length > 0 && "."} | |
| 79 | </p> | |
| 80 | ); | |
| 81 | } | |
| 82 | ||
| 83 | /** | |
| 84 | * Making an invite to g1t: an address (or anyone with the link), whose | |
| 85 | * invites it uses, and, only when its box is ticked, a workspace to invite | |
| 86 | * them to as well. Unticked, nothing about a workspace is sent. | |
| 87 | */ | |
| 88 | function InviteForm({ overview, bringInto }: { overview: InvitesOverview; bringInto: BringIntoChoices }) { | |
| 89 | const [alsoJoin, setAlsoJoin] = useState(false); | |
| 90 | const { workspaces } = overview; | |
| 91 | return ( | |
| 92 | <Form method="post" className="mt-4 space-y-4"> | |
| 93 | <input type="hidden" name="intent" value="create-invite" /> | |
| 94 | <div className="flex flex-col gap-3 sm:flex-row sm:items-end"> | |
| 95 | <div className="grow"> | |
| 96 | <Field label="Email (optional)"> | |
| 97 | <Input name="email" type="email" maxLength={254} placeholder="Anyone with the link" /> | |
| 98 | </Field> | |
| 99 | </div> | |
| 100 | {workspaces.length > 0 && ( | |
| 101 | <label className="block"> | |
| 102 | <span className="mb-1.5 block text-sm font-medium text-muted">Use</span> | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 103 | <SelectField |
| 104 | name="charge" | |
| 105 | defaultValue="mine" | |
| 106 | className={SELECT} | |
| 107 | options={[ | |
| 108 | { value: "mine", label: "Your invites" }, | |
| 109 | ...workspaces | |
| 110 | .filter((workspace) => workspace.allowance.remaining !== 0) | |
| 111 | .map((workspace) => ({ value: workspace.slug, label: `${workspace.slug}'s invites` })), | |
| 112 | ]} | |
| 113 | /> | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 114 | </label> |
| 115 | )} | |
| 116 | <SubmitButton match={{ intent: "create-invite" }} pending="Creating…"> | |
| 117 | Create invite | |
| 118 | </SubmitButton> | |
| 119 | </div> | |
| 120 | <p className="text-xs text-faint">With an email, the invite is sent there and only that address can use it.</p> | |
| 121 | ||
| 122 | {bringInto.options.length > 0 && ( | |
| 123 | <div className="space-y-3 rounded-md border border-line p-3"> | |
| 124 | <CheckboxOption | |
| 125 | name="also_join" | |
| 126 | checked={alsoJoin} | |
| 127 | onCheckedChange={(checked) => setAlsoJoin(checked === true)} | |
| 128 | label={G1T_INVITES.alsoJoin} | |
| 129 | description={G1T_INVITES.alsoJoinHint} | |
| 130 | /> | |
| 131 | {alsoJoin && ( | |
| 132 | <div className="flex flex-col gap-3 pl-6 sm:flex-row sm:items-end"> | |
| 133 | <label className="block"> | |
| 134 | <span className="mb-1.5 block text-sm font-medium text-muted">Workspace</span> | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 135 | <SelectField |
| 136 | name="join" | |
| 137 | required | |
| 138 | placeholder="Choose a workspace" | |
| 139 | className={SELECT} | |
| 140 | options={bringInto.options.map((workspace) => ({ | |
| 141 | value: workspace.slug, | |
| 142 | label: workspace.name === workspace.slug ? workspace.slug : `${workspace.name} (${workspace.slug})`, | |
| 143 | }))} | |
| 144 | /> | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 145 | </label> |
| 146 | <label className="block"> | |
| 147 | <span className="mb-1.5 block text-sm font-medium text-muted">Role</span> | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 148 | <SelectField |
| 149 | name="join_role" | |
| 150 | defaultValue="member" | |
| 151 | className={SELECT} | |
| 152 | options={[ | |
| 153 | { value: "member", label: "Member" }, | |
| 154 | { value: "owner", label: "Owner" }, | |
| 155 | ]} | |
| 156 | /> | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 157 | </label> |
| 158 | </div> | |
| 159 | )} | |
| 160 | {alsoJoin && bringInto.note && <p className="pl-6 text-xs text-faint">{bringInto.note}</p>} | |
| 161 | </div> | |
| 162 | )} | |
| 163 | </Form> | |
| 164 | ); | |
| 165 | } | |
| 166 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 167 | /** |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 168 | * Settings → Invites (`/settings/invites`): invites to g1t. Each lets one |
| 169 | * person make an account and adds them to no workspace, unless "Also | |
| 170 | * invite them to a workspace" is ticked. They exist only while sign-up | |
| 171 | * takes an invite; once anyone can sign up, the page keeps only the list | |
| 172 | * of invites already made. An invitation to a workspace is made on its | |
| 173 | * People page, which this page points to. | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 174 | */ |
| 175 | export function InvitesSection({ | |
| 176 | overview, | |
| 177 | created, | |
| 178 | error, | |
| 179 | origin = "https://g1t.sh", | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 180 | bringInto = { options: [], note: null, people: [] }, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 181 | }: { |
| 182 | overview: InvitesOverview | null; | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 183 | /** The workspaces an invite can also invite its person to, and the People pages (lib/invites.server.ts). */ |
| 184 | bringInto?: BringIntoChoices; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 185 | created?: Invite; |
| 186 | error?: string; | |
| 187 | origin?: string; | |
| 188 | }) { | |
| 189 | if (!overview) { | |
| 190 | return ( | |
| 191 | <section id="invites" className="scroll-mt-20"> | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 192 | <p className="text-sm text-muted">Confirm your email address to invite people to g1t.</p> |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 193 | </section> |
| 194 | ); | |
| 195 | } | |
| 196 | const { allowance, workspaces, invites } = overview; | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 197 | const shows = invitesPage(overview.mode, invites.length); |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 198 | const out = allowance.remaining === 0 && workspaces.every((workspace) => workspace.allowance.remaining === 0); |
| 199 | return ( | |
| 200 | <section id="invites" className="scroll-mt-20"> | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 201 | {shows.form ? ( |
| 202 | <> | |
| 203 | <p className="text-sm text-muted">A revoked or expired invite that was never used comes back to you.</p> | |
| 204 | <p className="mt-3 text-sm"> | |
| 205 | <span className="font-medium">{remainingLine(allowance)}</span> | |
| 206 | {workspaces.map((workspace) => ( | |
| 207 | <span key={workspace.slug} className="text-muted"> | |
| 208 | {" "}· {workspace.slug} has {workspace.allowance.remaining ?? "unlimited"} to share | |
| 209 | </span> | |
| 210 | ))} | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 211 | </p> |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 212 | |
| 213 | {created?.code && ( | |
| 214 | <div className="mt-4 rounded-md border border-accent/40 bg-surface p-4"> | |
| 215 | <p className="text-sm"> | |
| 216 | {created.email ? `Invite sent to ${created.email}. ` : ""}Share this link; it works once | |
| 217 | {created.email ? ", for that address only" : ""}. | |
| 218 | </p> | |
| 219 | <div className="mt-2"> | |
| 220 | <CopyLine text={inviteLink(created.code, origin)} /> | |
| 221 | </div> | |
| 222 | </div> | |
| 223 | )} | |
| 224 | ||
| 225 | {out ? ( | |
| 226 | <div className="mt-4 flex items-start gap-3 rounded-md border border-line bg-surface p-4 text-sm"> | |
| 227 | <Mail size={16} className="mt-0.5 shrink-0 text-muted" /> | |
| 228 | <p> | |
| 229 | Need more invites?{" "} | |
| 230 | <a href={moreInvitesMailto()} className="text-accent underline underline-offset-4"> | |
| 231 | Contact us | |
| 232 | </a>{" "} | |
| 233 | and say who you would like to bring, or ask on the{" "} | |
| 234 | <Link to="/support" className="underline underline-offset-4"> | |
| 235 | support page | |
| 236 | </Link> | |
| 237 | . | |
| 238 | </p> | |
| 239 | </div> | |
| 240 | ) : ( | |
| 241 | // Empty again once the invite is made; kept as filled in when it failed. | |
| 242 | <InviteForm key={created?.id ?? ""} overview={overview} bringInto={bringInto} /> | |
| 243 | )} | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 244 | <div className="mt-2"> |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 245 | <ErrorText>{error}</ErrorText> |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 246 | </div> |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 247 | <Elsewhere people={bringInto.people} /> |
| 248 | </> | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 249 | ) : ( |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 250 | <p className="text-sm text-muted">{G1T_INVITES.open}</p> |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 251 | )} |
| 252 | ||
| 253 | {invites.length > 0 && ( | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 254 | <> |
| 255 | {!shows.form && <h2 className="mt-6 text-sm font-medium">Invites you made</h2>} | |
| 256 | <ul className="mt-4 divide-y divide-line rounded-md border border-line"> | |
| 257 | {invites.map((invite) => ( | |
| 258 | <InviteRow key={invite.id} invite={invite} origin={origin} /> | |
| 259 | ))} | |
| 260 | </ul> | |
| 261 | </> | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 262 | )} |
| 263 | </section> | |
| 264 | ); | |
| 265 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.