flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Workspace names and icons, and a component kit for every control

Workspaces show their display name, not their slug, wherever people see them: the sidebar switcher (which no longer loses it to a role badge), mission control, the command list and the workspace page; addresses keep the slug. Owners upload an icon on workspace settings, and anyone a picture on their own settings: PNG, JPEG, WebP or GIF up to 1 MB, checked by its bytes (never SVG), owner-only on the server, stored by content hash and served with a strict policy. Icons show in the switcher, menus, mission control and social cards. Kept in KV until R2 is turned on. A shadcn-style kit themed to g1t (Select, Combobox, radio cards, Checkbox, Switch, Popover, Tooltip, Dialog, AlertDialog, Field, Input groups) replaces every native select, radio and checkbox, with the same form fields posted. New project gets a workspace picker with icons and names joined to the name, and the source and visibility as cards. fflate is held at 0.7.5 or later, for the moderate advisory reached through the social-card renderer.

syntaqxcommitted Parentcfe1663Browse files
74 files+2242−4630/74 viewed
+32−2
11 ---
22 title: Workspaces
3−description: Workspaces, members and roles, and access tokens that belong to a workspace.
3+description: Workspaces, their names and icons, members and roles, and access tokens that belong to a workspace.
44 ---
55
66 A workspace owns repositories and is the first part of their address:
3737 create a workspace with that name, and nobody can register a username that
3838 is already a workspace.
3939
40+## Display name, slug and icon
41+
42+A workspace has two names:
43+
44+| | Example | Where it appears | Changes |
45+| --- | --- | --- | --- |
46+| **Display name** | `Flagon Industries` | The sidebar, the top of its page, mission control and link previews | Any time, up to 80 characters; spaces and capitals are fine |
47+| **Slug** | `flagon` | Every address: `g1t.sh/flagon/<repo>` and clone URLs | Never, so links and clones keep working |
48+
49+Without a display name, the slug is shown. Where an address is shown, the
50+slug is in monospace beside the name. Owners change the display name and
51+description on **Settings → General**; from the API, `update_workspace`.
52+
53+A workspace also has an icon, as an organization does on GitHub. Without
54+one, g1t draws its first letter in a colour of its own. To upload one, an
55+owner opens **Settings → General** and picks an image:
56+
57+- PNG, JPEG, WebP or GIF, at most 1 MB. Square images look best.
58+- An image is checked by its contents, not its name. SVG is refused,
59+ because it can carry script.
60+- **Remove** goes back to the letter.
61+
62+The icon then shows wherever the workspace does, and on its link previews
63+(PNG and JPEG icons only). Each image is served from
64+`g1t.sh/avatars/<sha256>`, an address named after its contents, so an icon
65+that changes gets a new address and nothing shows the old one.
66+
67+You can upload a picture of yourself the same way, under
68+[Settings → Picture](https://g1t.sh/settings#picture).
69+
4070 ## Members and roles
4171
4272 | Role | Can |
5888
5989 | Settings | Who | |
6090 | --- | --- | --- |
61−| **General** | Owners | The display name and a one-line description. |
91+| **General** | Owners | The icon, the display name and a one-line description. The slug is shown, and cannot be changed. |
6292 | **Members** | Members | Who belongs, and their roles. Owners add and remove people. |
6393 | **Access tokens** | Members | The workspace's own tokens. Owners create and delete them. |
6494 | **Billing and plans** | Members | [Plans](/guides/usage-and-billing/#plans), the balance and the statement. Owners turn plans on and add credit. |
+48−0
2727
2828 --animate-fade-up: fade-up 0.6s ease-out both;
2929 --animate-dash: dash 1.2s linear infinite;
30+
31+ /* Menus, popovers and dialogs (components/ui): in quickly, out quicker. */
32+ --animate-pop-in: pop-in 0.16s cubic-bezier(0.16, 1, 0.3, 1);
33+ --animate-pop-out: pop-out 0.1s ease-in forwards;
34+ --animate-fade-in: fade-in 0.16s ease-out;
35+ --animate-fade-out: fade-out 0.12s ease-in forwards;
36+ --animate-dialog-in: dialog-in 0.2s cubic-bezier(0.16, 1, 0.3, 1);
37+ --animate-dialog-out: dialog-out 0.12s ease-in forwards;
38+}
39+
40+@keyframes pop-in {
41+ from {
42+ opacity: 0;
43+ transform: scale(0.96) translateY(-2px);
44+ }
45+}
46+
47+@keyframes pop-out {
48+ to {
49+ opacity: 0;
50+ transform: scale(0.96);
51+ }
52+}
53+
54+@keyframes fade-in {
55+ from {
56+ opacity: 0;
57+ }
58+}
59+
60+@keyframes fade-out {
61+ to {
62+ opacity: 0;
63+ }
64+}
65+
66+@keyframes dialog-in {
67+ from {
68+ opacity: 0;
69+ transform: translateY(6px) scale(0.97);
70+ }
71+}
72+
73+@keyframes dialog-out {
74+ to {
75+ opacity: 0;
76+ transform: translateY(6px) scale(0.97);
77+ }
3078 }
3179
3280 @keyframes fade-up {
+73−0
1+import { ImageUp, Trash2 } from "lucide-react";
2+import { useRef } from "react";
3+import { Form, useNavigation } from "react-router";
4+
5+import { AVATAR_ACCEPT } from "../lib/avatar-upload";
6+import { Avatar, Button, ErrorText } from "./ui";
7+
8+/**
9+ * Uploading an avatar, as GitHub's settings do: the one shown now, a
10+ * button to pick a new image, which is sent as soon as it is picked, and
11+ * a way back to the letter. Posts `intent=avatar` with the file in
12+ * `avatar`, or `intent=remove-avatar`.
13+ */
14+export function AvatarField({
15+ name,
16+ image,
17+ square,
18+ error,
19+ about,
20+}: {
21+ /** What the letter avatar is drawn from. */
22+ name: string;
23+ image?: string | null;
24+ square?: boolean;
25+ error?: string;
26+ about: string;
27+}) {
28+ const navigation = useNavigation();
29+ const form = useRef<HTMLFormElement>(null);
30+ const intent = navigation.formData?.get("intent");
31+ const busy = navigation.state === "submitting" && (intent === "avatar" || intent === "remove-avatar");
32+ return (
33+ <div className="flex items-start gap-4">
34+ <Avatar name={name} image={image} size={64} square={square} />
35+ <div className="min-w-0 space-y-2">
36+ <p className="text-sm text-muted">{about}</p>
37+ <div className="flex flex-wrap items-center gap-2">
38+ <Form method="post" encType="multipart/form-data" ref={form}>
39+ <input type="hidden" name="intent" value="avatar" />
40+ <label
41+ className={`inline-flex cursor-pointer items-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium ring-1 ring-line transition-colors hover:ring-line-strong ${
42+ busy ? "pointer-events-none opacity-50" : ""
43+ }`}
44+ >
45+ <ImageUp size={15} />
46+ {busy && intent === "avatar" ? "Uploading…" : image ? "Upload a new image" : "Upload an image"}
47+ <input
48+ type="file"
49+ name="avatar"
50+ accept={AVATAR_ACCEPT}
51+ className="sr-only"
52+ onChange={(event) => {
53+ if (event.currentTarget.files?.length) form.current?.requestSubmit();
54+ }}
55+ />
56+ </label>
57+ </Form>
58+ {image && (
59+ <Form method="post">
60+ <input type="hidden" name="intent" value="remove-avatar" />
61+ <Button type="submit" variant="quiet" disabled={busy}>
62+ <Trash2 size={15} />
63+ Remove
64+ </Button>
65+ </Form>
66+ )}
67+ </div>
68+ <p className="text-xs text-faint">PNG, JPEG, WebP or GIF, at most 1 MB. Square images look best.</p>
69+ <ErrorText>{error}</ErrorText>
70+ </div>
71+ </div>
72+ );
73+}
+4−4
2222 } from "../lib/diff";
2323 import { Markdown } from "./markdown";
2424 import { Avatar, Button, EmptyState, Textarea, TimeAgo } from "./ui";
25+import { Checkbox } from "./ui/checkbox";
2526
2627 const ROW_STYLES: Record<DiffLine["kind"], string> = {
2728 context: "",
440441 viewed ? "border-accent/40 bg-accent/10 text-accent" : "border-line text-muted hover:text-fg"
441442 }`}
442443 >
443− <input
444− type="checkbox"
444+ <Checkbox
445445 checked={viewed}
446− onChange={(event) => onViewed(event.target.checked)}
447− className="accent-accent"
446+ onCheckedChange={(checked) => onViewed(checked === true)}
447+ className="size-3.5 rounded-[4px] [&_svg]:size-2.5"
448448 />
449449 Viewed
450450 </label>
+7−1
1515 import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
1616 import remarkGfm from "remark-gfm";
1717
18+import { Checkbox } from "./ui/checkbox";
1819 import { type AlertKind, type MarkdownRepo, rehypeAlerts, rehypeReferences } from "../lib/markdown-plugins";
1920
2021 /** The text inside a React tree, for anchors and copying. */
225226 input({ type, checked, disabled }) {
226227 // Task list boxes: shown, not editable.
227228 return type === "checkbox" ? (
228− <input type="checkbox" checked={checked} disabled={disabled} readOnly className="mr-1.5 translate-y-0.5 accent-accent" />
229+ <Checkbox
230+ checked={checked === true}
231+ disabled={disabled !== false}
232+ aria-label={checked ? "Done" : "Not done"}
233+ className="mr-1.5 inline-flex translate-y-0.5 disabled:cursor-default disabled:opacity-100"
234+ />
229235 ) : null;
230236 },
231237 img({ src, alt }) {
+53−20
33 * fields each one asks for, and the routing of each kind of work to a
44 * provider and model.
55 */
6−import { Bot, ChevronRight, Webhook } from "lucide-react";
6+import { Bot, ChevronRight, Sparkles, Webhook } from "lucide-react";
77 import { type ReactNode, useState } from "react";
88 import { Form, Link } from "react-router";
99
1010 import { type Connection, MODEL_TASKS, type ModelRoute, type ModelTask, type Provider, PROVIDERS } from "@g1t/contracts";
1111
1212 import { Button, Field, Input } from "./ui";
13+import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select";
1314
1415 type Group = "labs" | "platforms" | "any";
1516
244245 );
245246 }
246247
248+/** Radix Select items cannot be "": the routing value for "same as everything". */
249+const SAME = "same";
250+
247251 const SELECT =
248252 "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim disabled:opacity-60";
249253
289293 <Input name="secret" type="password" />
290294 </Field>
291295 <Field label="Send the key as">
292− <select name="authHeader" className={SELECT} defaultValue={anthropic ? "x-api-key" : "authorization"}>
293− <option value="x-api-key">x-api-key</option>
294− <option value="authorization">Authorization: Bearer</option>
295− </select>
296+ <Select name="authHeader" defaultValue={anthropic ? "x-api-key" : "authorization"}>
297+ <SelectTrigger aria-label="Send the key as" className="font-mono text-[0.8125rem]">
298+ <SelectValue />
299+ </SelectTrigger>
300+ <SelectContent>
301+ <SelectItem value="x-api-key" description="Anthropic's header" className="font-mono text-[0.8125rem]">
302+ x-api-key
303+ </SelectItem>
304+ <SelectItem value="authorization" description="OpenAI's header, and most others" className="font-mono text-[0.8125rem]">
305+ Authorization: Bearer
306+ </SelectItem>
307+ </SelectContent>
308+ </Select>
296309 </Field>
297310 {GATEWAY_TOKEN}
298311 <Field
399412 <p className="text-xs text-faint">{TASK_LABELS[task].hint}</p>
400413 </div>
401414 <input type="hidden" name={`route-${task}`} value={value} />
402− <select
403− aria-label={`${TASK_LABELS[task].label}: provider`}
415+ <Select
404416 disabled={!owner}
405− value={choice.target}
406− onChange={(event) => set(task, { target: event.target.value, model: "" })}
407− className={SELECT}
417+ value={choice.target || SAME}
418+ onValueChange={(target) => set(task, { target: target === SAME ? "" : target, model: "" })}
408419 >
409− {task !== "default" && <option value="">Same as everything</option>}
410− <option value="g1t" disabled={!hostedOpen}>
411− g1t's models{hostedOpen ? ` · credit at cost + ${marginPercent}%` : " · not open to this workspace yet"}
412− </option>
413− {connections.map((c) => (
414− <option key={c.id} value={c.id}>
415− {c.name}
416− </option>
417− ))}
418− </select>
420+ <SelectTrigger aria-label={`${TASK_LABELS[task].label}: provider`}>
421+ <SelectValue />
422+ </SelectTrigger>
423+ <SelectContent>
424+ {task !== "default" && (
425+ <>
426+ <SelectItem value={SAME} description="Follows the choice for everything">
427+ Same as everything
428+ </SelectItem>
429+ <SelectSeparator />
430+ </>
431+ )}
432+ <SelectItem
433+ value="g1t"
434+ disabled={!hostedOpen}
435+ icon={<Sparkles />}
436+ description={hostedOpen ? `Credit at cost + ${marginPercent}%` : "Not open to this workspace yet"}
437+ >
438+ g1t's models
439+ </SelectItem>
440+ {connections.length > 0 && (
441+ <SelectGroup>
442+ <SelectLabel>Your providers</SelectLabel>
443+ {connections.map((c) => (
444+ <SelectItem key={c.id} value={c.id} icon={<Bot />} description={PROVIDERS[c.provider]?.label}>
445+ {c.name}
446+ </SelectItem>
447+ ))}
448+ </SelectGroup>
449+ )}
450+ </SelectContent>
451+ </Select>
419452 <div>
420453 <input
421454 aria-label={`${TASK_LABELS[task].label}: model`}
+81−92
1313
1414 import type { SecretsAction, SecretsData } from "../lib/secrets.server";
1515 import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "./ui";
16+import { CheckboxOption } from "./ui/checkbox";
17+import { RadioCard, RadioGroup, RadioOption } from "./ui/radio-group";
18+import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select";
1619
1720 /** The environments every deployment knows; workflow jobs may name others. */
1821 const KNOWN_ENVIRONMENTS = ["production", "preview"];
100103 className={`${SELECT} w-full pl-9`}
101104 />
102105 </label>
103− <select value={type} onChange={(e) => setType(e.target.value)} aria-label="Type" className={SELECT}>
104− <option value="all">All types</option>
105− <option value="secret">Secret</option>
106− <option value="variable">Config</option>
107− </select>
108− <select value={environment} onChange={(e) => setEnvironment(e.target.value)} aria-label="Environment" className={SELECT}>
109− <option value="all">All environments</option>
110− {environments.map((env) => (
111− <option key={env} value={env}>
112− {environmentsLabel([env])}
113− </option>
114− ))}
115− </select>
106+ <Select value={type} onValueChange={setType}>
107+ <SelectTrigger aria-label="Type" className="h-auto w-auto py-2">
108+ <SelectValue />
109+ </SelectTrigger>
110+ <SelectContent align="end">
111+ <SelectItem value="all">All types</SelectItem>
112+ <SelectSeparator />
113+ <SelectItem value="secret" icon={<Lock />}>Secret</SelectItem>
114+ <SelectItem value="variable" icon={<SlidersHorizontal />}>Config</SelectItem>
115+ </SelectContent>
116+ </Select>
117+ <Select value={environment} onValueChange={setEnvironment}>
118+ <SelectTrigger aria-label="Environment" className="h-auto w-auto py-2">
119+ <SelectValue />
120+ </SelectTrigger>
121+ <SelectContent align="end">
122+ <SelectItem value="all">All environments</SelectItem>
123+ {environments.length > 0 && <SelectSeparator />}
124+ {environments.map((env) => (
125+ <SelectItem key={env} value={env}>
126+ {environmentsLabel([env])}
127+ </SelectItem>
128+ ))}
129+ </SelectContent>
130+ </Select>
116131 </div>
117132
118133 <ErrorText>{data.error}</ErrorText>
238253
239254 <fieldset>
240255 <legend className="mb-2 text-sm font-medium text-muted">Type</legend>
241− <div className="grid gap-3 sm:grid-cols-2">
256+ <RadioGroup
257+ name="type"
258+ value={type}
259+ onValueChange={(value) => setType(value as typeof type)}
260+ aria-label="Type"
261+ className="gap-3 sm:grid-cols-2"
262+ >
242263 {(
243264 [
244− ["secret", "Secret", "You can't read it again after saving. For passwords, API keys and tokens."],
245− ["config", "Config", "Readable by members after saving. For values that are not sensitive."],
265+ ["secret", "Secret", "You can't read it again after saving. For passwords, API keys and tokens.", <Lock key="i" />],
266+ ["config", "Config", "Readable by members after saving. For values that are not sensitive.", <SlidersHorizontal key="i" />],
246267 ] as const
247− ).map(([value, title, text]) => {
268+ ).map(([value, title, text, icon]) => (
248269 // A secret's value is sealed: it can never become config.
249− const locked = value === "config" && row?.kind === "secret";
250− return (
251− <label
252− key={value}
253− className={`rounded-xl border p-3.5 transition-colors ${
254− type === value ? "border-accent bg-accent/5" : "border-line hover:border-line-strong"
255− } ${locked ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}
256− >
257− <span className="flex items-center justify-between">
258− <span className="text-sm font-medium">{title}</span>
259− <input
260− type="radio"
261− name="type"
262− value={value}
263− checked={type === value}
264− disabled={locked}
265− onChange={() => setType(value)}
266− className="accent-accent"
267− />
268− </span>
269− <span className="mt-1 block text-xs text-muted">{text}</span>
270− </label>
271− );
272− })}
273− </div>
270+ <RadioCard
271+ key={value}
272+ value={value}
273+ title={title}
274+ description={text}
275+ icon={icon}
276+ disabled={value === "config" && row?.kind === "secret"}
277+ />
278+ ))}
279+ </RadioGroup>
274280 {row?.kind === "variable" && (
275281 <p className="mt-2 text-xs text-faint">Config can become a secret; a secret cannot become config.</p>
276282 )}
316322 <fieldset>
317323 <legend className="mb-2 text-sm font-medium text-muted">Environments</legend>
318324 <div className="space-y-2 text-sm">
319− <label className="flex items-center gap-2">
320− <input type="radio" name="scope" value="all" checked={!some} onChange={() => setSome(false)} className="accent-accent" />
321− All environments
322− </label>
323− <label className="flex items-center gap-2">
324− <input type="radio" name="scope" value="some" checked={some} onChange={() => setSome(true)} className="accent-accent" />
325− Only some
326− </label>
325+ <RadioGroup name="scope" value={some ? "some" : "all"} onValueChange={(value) => setSome(value === "some")} aria-label="Environments">
326+ <RadioOption value="all" label="All environments" />
327+ <RadioOption value="some" label="Only some" />
328+ </RadioGroup>
327329 {some && (
328330 <div className="ml-6 space-y-2">
329331 {KNOWN_ENVIRONMENTS.map((env) => (
330− <label key={env} className="flex items-center gap-2">
331− <input
332− type="checkbox"
333− name="env"
334− value={env}
335− defaultChecked={row?.environments.includes(env)}
336− className="accent-accent"
337− />
338− {environmentsLabel([env])}
339− </label>
332+ <CheckboxOption
333+ key={env}
334+ name="env"
335+ value={env}
336+ defaultChecked={row?.environments.includes(env)}
337+ label={environmentsLabel([env])}
338+ />
340339 ))}
341340 <input
342341 name="envCustom"
362361 ["deployments", "Deployments", "The build's environment, and env.KEY in the running app"],
363362 ] as const
364363 ).map(([value, title, text]) => (
365− <label key={value} className="flex items-start gap-2">
366− <input
367− type="checkbox"
368− name="availableTo"
369− value={value}
370− defaultChecked={row ? row.availableTo.includes(value) : true}
371− className="mt-1 accent-accent"
372− />
373− <span>
374− {title}
375− <span className="block text-xs text-faint">{text}</span>
376− </span>
377− </label>
364+ <CheckboxOption
365+ key={value}
366+ name="availableTo"
367+ value={value}
368+ defaultChecked={row ? row.availableTo.includes(value) : true}
369+ label={title}
370+ description={text}
371+ />
378372 ))}
379373 </div>
380374 </fieldset>
383377 <fieldset>
384378 <legend className="mb-2 text-sm font-medium text-muted">Projects</legend>
385379 <div className="space-y-2 text-sm">
386− <label className="flex items-center gap-2">
387− <input type="radio" name="reach" value="all" checked={reach === "all"} onChange={() => setReach("all")} className="accent-accent" />
388− Every project
389− </label>
390− <label className="flex items-center gap-2">
391− <input type="radio" name="reach" value="some" checked={reach === "some"} onChange={() => setReach("some")} className="accent-accent" />
392− Only these
393− </label>
380+ <RadioGroup name="reach" value={reach} onValueChange={(value) => setReach(value as typeof reach)} aria-label="Projects">
381+ <RadioOption value="all" label="Every project" />
382+ <RadioOption value="some" label="Only these" />
383+ </RadioGroup>
394384 {reach === "some" && (
395− <div className="ml-6 grid max-h-48 gap-1.5 overflow-y-auto sm:grid-cols-2">
385+ <div className="ml-6 grid max-h-48 gap-1.5 overflow-y-auto p-0.5 sm:grid-cols-2">
396386 {projects.map((name) => (
397− <label key={name} className="flex items-center gap-2 font-mono text-xs">
398− <input
399− type="checkbox"
400− name="project"
401− value={name}
402− defaultChecked={row?.projects.includes(name)}
403− className="accent-accent"
404− />
405− {name}
406− </label>
387+ <CheckboxOption
388+ key={name}
389+ name="project"
390+ value={name}
391+ defaultChecked={row?.projects.includes(name)}
392+ label={name}
393+ className="items-center"
394+ labelClassName="font-mono text-xs"
395+ />
407396 ))}
408397 </div>
409398 )}
+36−24
4141 import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
4242 import { Form, Link, NavLink, useLocation, useNavigate, useNavigation, useSubmit } from "react-router";
4343
44−import type { User } from "@g1t/contracts";
44+import type { Membership, User } from "@g1t/contracts";
4545 import { MICROS_PER_DOLLAR } from "@g1t/contracts";
4646
4747 import { Mark } from "./logo";
5959 /** What the sidebar needs, worked out by the root loader for a signed-in person. */
6060 export type ShellData = {
6161 /** The workspace the sidebar is about: the one being looked at, or their first. */
62− workspace: { slug: string; role: "owner" | "member" } | null;
62+ workspace: Membership | null;
6363 /** Its projects, by name: `name` is the slug in their address. */
6464 repos: { namespace: string; name: string; title?: string; isPrivate: boolean }[];
6565 /** The project being looked at, if any, whoever owns it. */
213213 );
214214 }
215215
216+/** What a workspace is called where people read it: its display name, else its slug. */
217+function displayName(membership: Membership): string {
218+ return membership.name?.trim() || membership.slug;
219+}
220+
216221 function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) {
217− const current = shell.workspace?.slug;
222+ const workspace = shell.workspace;
223+ const current = workspace?.slug;
224+ const label = workspace ? displayName(workspace) : "Choose a workspace";
218225 return (
219226 <DropdownMenu>
220− {/* The name goes to the workspace; only the arrows switch it. */}
227+ {/* The name goes to the workspace; only the arrows switch it. The
228+ name gets all the room there is, and the whole of it on hover. */}
221229 <Link
222230 to={current ? `/${current}` : "/workspaces/new"}
223− className="flex h-8 min-w-0 items-center gap-2 rounded-md px-1.5 transition-colors hover:bg-raised"
231+ title={workspace ? `${label} · g1t.sh/${workspace.slug}` : undefined}
232+ className="flex h-8 min-w-0 grow items-center gap-1.5 rounded-md px-1 transition-colors hover:bg-raised"
224233 >
225− {current ? (
226− <Avatar name={current} size={20} square />
234+ {workspace ? (
235+ <Avatar name={workspace.slug} image={workspace.avatar} size={20} square />
227236 ) : (
228− <span className="size-5 rounded-md border border-dashed border-line-strong" />
237+ <span className="size-5 shrink-0 rounded-md border border-dashed border-line-strong" />
229238 )}
230− <span className="min-w-0 truncate text-sm font-medium">{current ?? "Choose a workspace"}</span>
231− {shell.workspace && (
232− <span className="shrink-0 rounded-full bg-raised px-1.5 py-px text-[0.625rem] font-medium text-muted ring-1 ring-line capitalize">
233− {shell.workspace.role}
234− </span>
235− )}
239+ <span className="min-w-0 truncate text-[0.8125rem] font-medium">{label}</span>
236240 </Link>
237241 <DropdownMenuTrigger
238242 aria-label="Switch workspace"
239− className="ml-auto flex h-8 w-6 shrink-0 items-center justify-center rounded-md text-faint outline-none transition-colors hover:bg-raised hover:text-fg data-[state=open]:bg-raised data-[state=open]:text-fg"
243+ className="flex h-8 w-5 shrink-0 items-center justify-center rounded-md text-faint outline-none transition-colors hover:bg-raised hover:text-fg data-[state=open]:bg-raised data-[state=open]:text-fg"
240244 >
241245 <ChevronsUpDown size={14} />
242246 </DropdownMenuTrigger>
243− <DropdownMenuContent align="start" className="w-60">
247+ <DropdownMenuContent align="start" className="w-64">
244248 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
245249 {(user.workspaces ?? []).map((membership) => (
246250 <DropdownMenuItem asChild key={membership.slug}>
247− <Link to={`/${membership.slug}`}>
248− <Avatar name={membership.slug} size={16} square />
249− <span className="grow">{membership.slug}</span>
250− {membership.slug === current && <Check className="text-accent" />}
251+ <Link to={`/${membership.slug}`} title={displayName(membership)}>
252+ <Avatar name={membership.slug} image={membership.avatar} size={24} square />
253+ <span className="flex min-w-0 grow flex-col leading-tight">
254+ <span className="truncate">{displayName(membership)}</span>
255+ <span className="truncate font-mono text-[0.6875rem] text-faint">{membership.slug}</span>
256+ </span>
257+ <span className="shrink-0 text-[0.6875rem] text-faint capitalize">{membership.role}</span>
258+ {membership.slug === current ? (
259+ <Check className="shrink-0 text-accent" />
260+ ) : (
261+ <span className="size-4 shrink-0" aria-hidden="true" />
262+ )}
251263 </Link>
252264 </DropdownMenuItem>
253265 ))}
315327 return (
316328 <DropdownMenu>
317329 <DropdownMenuTrigger className="flex h-10 w-full items-center gap-2.5 rounded-md px-2 text-left outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised">
318− <Avatar name={user.username} size={22} />
330+ <Avatar name={user.username} image={user.avatar} size={22} />
319331 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
320332 <Ellipsis size={15} className="shrink-0 text-faint" />
321333 </DropdownMenuTrigger>
589601 return (
590602 <div className="flex h-full flex-col">
591603 {/* The same height and rule as the top bar, so the two read as one line. */}
592− <div className="flex h-16 shrink-0 items-center gap-2 border-b border-line px-3">
593− <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-2 hover:bg-raised">
604+ <div className="flex h-16 shrink-0 items-center gap-1 border-b border-line pr-2 pl-2.5">
605+ <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
594606 <Mark className="size-6" />
595607 </Link>
596608 <span className="shrink-0 text-line-strong" aria-hidden="true">
835847 }
836848 for (const membership of user.workspaces ?? []) {
837849 commands.push(
838− { label: membership.slug, hint: "Workspace", to: `/${membership.slug}`, icon: <Avatar name={membership.slug} size={15} square /> },
850+ { label: displayName(membership), hint: `Workspace · ${membership.slug}`, to: `/${membership.slug}`, icon: <Avatar name={membership.slug} image={membership.avatar} size={15} square /> },
839851 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
840852 { label: "Billing and plans", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
841853 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
+58−0
1+import { AlertDialog as Primitive } from "radix-ui";
2+import type { ComponentProps } from "react";
3+
4+import { cn } from "../../lib/cn";
5+import { DIALOG_PANEL } from "./dialog";
6+
7+// shadcn/ui's alert dialog, for confirming what cannot be undone. It does
8+// not close on a click outside; the person has to choose.
9+
10+export const AlertDialog = Primitive.Root;
11+export const AlertDialogTrigger = Primitive.Trigger;
12+
13+export function AlertDialogContent({ className, ...props }: ComponentProps<typeof Primitive.Content>) {
14+ return (
15+ <Primitive.Portal>
16+ <Primitive.Overlay className="fixed inset-0 z-50 bg-black/60 backdrop-blur-[2px] data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out motion-reduce:animate-none" />
17+ <Primitive.Content className={cn(DIALOG_PANEL, "max-w-md", className)} {...props} />
18+ </Primitive.Portal>
19+ );
20+}
21+
22+export function AlertDialogHeader({ className, ...props }: ComponentProps<"div">) {
23+ return <div className={cn("flex flex-col gap-1.5", className)} {...props} />;
24+}
25+
26+export function AlertDialogFooter({ className, ...props }: ComponentProps<"div">) {
27+ return <div className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />;
28+}
29+
30+export function AlertDialogTitle({ className, ...props }: ComponentProps<typeof Primitive.Title>) {
31+ return <Primitive.Title className={cn("text-base font-semibold tracking-tight", className)} {...props} />;
32+}
33+
34+export function AlertDialogDescription({ className, ...props }: ComponentProps<typeof Primitive.Description>) {
35+ return <Primitive.Description className={cn("text-sm text-muted", className)} {...props} />;
36+}
37+
38+const BUTTON =
39+ "inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium transition-colors disabled:opacity-50";
40+
41+/** The destructive choice. Put a submit button inside with `asChild` to post a form. */
42+export function AlertDialogAction({ className, ...props }: ComponentProps<typeof Primitive.Action>) {
43+ return (
44+ <Primitive.Action
45+ className={cn(BUTTON, "bg-danger text-bg hover:bg-danger/90", className)}
46+ {...props}
47+ />
48+ );
49+}
50+
51+export function AlertDialogCancel({ className, ...props }: ComponentProps<typeof Primitive.Cancel>) {
52+ return (
53+ <Primitive.Cancel
54+ className={cn(BUTTON, "border border-line text-fg/80 hover:border-line-strong hover:bg-raised hover:text-fg", className)}
55+ {...props}
56+ />
57+ );
58+}
+60−0
1+import { Check, Minus } from "lucide-react";
2+import { Checkbox as Primitive } from "radix-ui";
3+import { type ComponentProps, type ReactNode, useId } from "react";
4+
5+import { cn } from "../../lib/cn";
6+
7+// shadcn/ui's checkbox, styled with g1t's tokens.
8+//
9+// Inside a <Form>, a `name` makes Radix render a hidden checkbox beside it,
10+// so it posts `value` (default "on") when checked and nothing when not,
11+// exactly as a native one does. Wrap it and its text in a <label>, or point
12+// a <Label htmlFor> at its `id`.
13+
14+/** A checkbox with its label, and optionally a line under it. */
15+export function CheckboxOption({
16+ label,
17+ description,
18+ className,
19+ labelClassName,
20+ ...props
21+}: ComponentProps<typeof Primitive.Root> & { label: ReactNode; description?: ReactNode; labelClassName?: string }) {
22+ const generated = useId();
23+ const id = props.id ?? generated;
24+ return (
25+ <div className={cn("flex items-start gap-2.5", props.disabled && "opacity-60", className)}>
26+ <Checkbox id={id} className={className?.includes("items-center") ? undefined : "mt-0.5"} aria-describedby={description ? `${id}-d` : undefined} {...props} />
27+ <label htmlFor={id} className={cn("min-w-0 text-sm text-fg", props.disabled ? "cursor-not-allowed" : "cursor-pointer", labelClassName)}>
28+ {label}
29+ {description && (
30+ <span id={`${id}-d`} className="mt-0.5 block text-xs text-faint">
31+ {description}
32+ </span>
33+ )}
34+ </label>
35+ </div>
36+ );
37+}
38+
39+export function Checkbox({ className, ...props }: ComponentProps<typeof Primitive.Root>) {
40+ return (
41+ <Primitive.Root
42+ className={cn(
43+ "peer inline-flex size-4 shrink-0 items-center justify-center rounded-[5px] border border-line-strong bg-bg text-bg outline-none transition-colors",
44+ "hover:border-faint focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-bg focus-visible:outline-none",
45+ "data-[state=checked]:border-accent data-[state=checked]:bg-accent data-[state=indeterminate]:border-accent data-[state=indeterminate]:bg-accent",
46+ "disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-danger/70",
47+ className,
48+ )}
49+ {...props}
50+ >
51+ <Primitive.Indicator className="flex items-center justify-center data-[state=checked]:animate-pop-in motion-reduce:animate-none">
52+ {props.checked === "indeterminate" ? (
53+ <Minus size={12} strokeWidth={3} />
54+ ) : (
55+ <Check size={12} strokeWidth={3} />
56+ )}
57+ </Primitive.Indicator>
58+ </Primitive.Root>
59+ );
60+}
+169−0
1+import { Command as Cmdk } from "cmdk";
2+import { Check, ChevronsUpDown, Search } from "lucide-react";
3+import { type ComponentProps, type ReactNode, useState } from "react";
4+
5+import { cn } from "../../lib/cn";
6+import { Popover, PopoverContent, PopoverTrigger } from "./popover";
7+
8+// shadcn/ui's command menu and combobox, styled with g1t's tokens: a select
9+// with a search box, for lists too long to scroll. A `name` posts the
10+// chosen value from a hidden input, so it works in a plain <Form>.
11+
12+export function Command({ className, ...props }: ComponentProps<typeof Cmdk>) {
13+ return <Cmdk className={cn("flex w-full flex-col overflow-hidden text-sm text-fg", className)} {...props} />;
14+}
15+
16+export function CommandInput({ className, ...props }: ComponentProps<typeof Cmdk.Input>) {
17+ return (
18+ <div className="flex items-center gap-2 border-b border-line px-3" cmdk-input-wrapper="">
19+ <Search size={14} className="shrink-0 text-faint" />
20+ <Cmdk.Input
21+ className={cn("h-10 w-full bg-transparent text-sm outline-none placeholder:text-faint focus-visible:outline-none", className)}
22+ {...props}
23+ />
24+ </div>
25+ );
26+}
27+
28+export function CommandList({ className, ...props }: ComponentProps<typeof Cmdk.List>) {
29+ return <Cmdk.List className={cn("max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto p-1", className)} {...props} />;
30+}
31+
32+export function CommandEmpty({ className, ...props }: ComponentProps<typeof Cmdk.Empty>) {
33+ return <Cmdk.Empty className={cn("py-6 text-center text-sm text-faint", className)} {...props} />;
34+}
35+
36+export function CommandGroup({ className, ...props }: ComponentProps<typeof Cmdk.Group>) {
37+ return (
38+ <Cmdk.Group
39+ className={cn(
40+ "[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pt-2 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:text-[0.6875rem] [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:tracking-wide [&_[cmdk-group-heading]]:text-faint [&_[cmdk-group-heading]]:uppercase",
41+ className,
42+ )}
43+ {...props}
44+ />
45+ );
46+}
47+
48+export function CommandSeparator({ className, ...props }: ComponentProps<typeof Cmdk.Separator>) {
49+ return <Cmdk.Separator className={cn("-mx-1 my-1 h-px bg-line", className)} {...props} />;
50+}
51+
52+export function CommandItem({ className, ...props }: ComponentProps<typeof Cmdk.Item>) {
53+ return (
54+ <Cmdk.Item
55+ className={cn(
56+ "relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-fg/90 outline-none select-none",
57+ "data-[selected=true]:bg-line data-[selected=true]:text-fg data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-45",
58+ "[&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted",
59+ className,
60+ )}
61+ {...props}
62+ />
63+ );
64+}
65+
66+export type ComboboxOption = {
67+ value: string;
68+ label: string;
69+ description?: ReactNode;
70+ icon?: ReactNode;
71+ /** More words that should find it. */
72+ keywords?: string[];
73+ disabled?: boolean;
74+};
75+
76+/** A searchable select. Controlled with `value`, or not with `defaultValue`. */
77+export function Combobox({
78+ options,
79+ value: controlled,
80+ defaultValue = "",
81+ onValueChange,
82+ name,
83+ disabled,
84+ placeholder = "Choose…",
85+ searchPlaceholder = "Search…",
86+ emptyText = "Nothing matches.",
87+ id,
88+ className,
89+ "aria-label": ariaLabel,
90+}: {
91+ options: ComboboxOption[];
92+ value?: string;
93+ defaultValue?: string;
94+ onValueChange?: (value: string) => void;
95+ name?: string;
96+ disabled?: boolean;
97+ placeholder?: string;
98+ searchPlaceholder?: string;
99+ emptyText?: string;
100+ id?: string;
101+ className?: string;
102+ "aria-label"?: string;
103+}) {
104+ const [open, setOpen] = useState(false);
105+ const [uncontrolled, setUncontrolled] = useState(defaultValue);
106+ const value = controlled ?? uncontrolled;
107+ const chosen = options.find((option) => option.value === value);
108+ const choose = (next: string) => {
109+ setUncontrolled(next);
110+ onValueChange?.(next);
111+ setOpen(false);
112+ };
113+ return (
114+ <Popover open={open} onOpenChange={setOpen}>
115+ {name && <input type="hidden" name={name} value={value} />}
116+ <PopoverTrigger asChild>
117+ <button
118+ type="button"
119+ id={id}
120+ role="combobox"
121+ aria-expanded={open}
122+ aria-label={ariaLabel}
123+ disabled={disabled}
124+ className={cn(
125+ "group/combobox flex h-9 w-full min-w-0 items-center justify-between gap-2 rounded-md border border-line bg-bg px-3 text-left text-sm text-fg outline-none transition-colors",
126+ "hover:border-line-strong focus-visible:border-accent-dim focus-visible:ring-2 focus-visible:ring-accent/25 focus-visible:outline-none",
127+ "data-[state=open]:border-accent-dim data-[state=open]:ring-2 data-[state=open]:ring-accent/25 disabled:cursor-not-allowed disabled:opacity-50",
128+ className,
129+ )}
130+ >
131+ <span className={cn("flex min-w-0 items-center gap-2 truncate [&_svg]:size-4 [&_svg]:shrink-0", !chosen && "text-faint")}>
132+ {chosen?.icon}
133+ <span className="truncate">{chosen ? chosen.label : placeholder}</span>
134+ </span>
135+ <ChevronsUpDown size={14} className="shrink-0 text-faint" />
136+ </button>
137+ </PopoverTrigger>
138+ <PopoverContent align="start" className="w-(--radix-popover-trigger-width) min-w-60 p-0">
139+ <Command>
140+ <CommandInput placeholder={searchPlaceholder} />
141+ <CommandList>
142+ <CommandEmpty>{emptyText}</CommandEmpty>
143+ {options.map((option) => (
144+ <CommandItem
145+ key={option.value}
146+ value={option.value}
147+ keywords={[option.label, ...(option.keywords ?? [])]}
148+ disabled={option.disabled}
149+ onSelect={() => choose(option.value)}
150+ className="items-start pr-8"
151+ >
152+ <span className="flex min-w-0 flex-col">
153+ <span className="flex min-w-0 items-center gap-2">
154+ {option.icon}
155+ <span className="truncate">{option.label}</span>
156+ </span>
157+ {option.description && (
158+ <span className={cn("mt-0.5 text-xs text-faint", option.icon != null && "pl-6")}>{option.description}</span>
159+ )}
160+ </span>
161+ {option.value === value && <Check className="absolute top-2 right-2 size-3.5! text-accent!" />}
162+ </CommandItem>
163+ ))}
164+ </CommandList>
165+ </Command>
166+ </PopoverContent>
167+ </Popover>
168+ );
169+}
+69−0
1+import { X } from "lucide-react";
2+import { Dialog as Primitive } from "radix-ui";
3+import type { ComponentProps } from "react";
4+
5+import { cn } from "../../lib/cn";
6+
7+// shadcn/ui's dialog, styled with g1t's tokens.
8+
9+export const Dialog = Primitive.Root;
10+export const DialogTrigger = Primitive.Trigger;
11+export const DialogClose = Primitive.Close;
12+export const DialogPortal = Primitive.Portal;
13+
14+export function DialogOverlay({ className, ...props }: ComponentProps<typeof Primitive.Overlay>) {
15+ return (
16+ <Primitive.Overlay
17+ className={cn(
18+ "fixed inset-0 z-50 bg-black/60 backdrop-blur-[2px]",
19+ "data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out motion-reduce:animate-none",
20+ className,
21+ )}
22+ {...props}
23+ />
24+ );
25+}
26+
27+/** The panel, centred; on a phone it fills the width less a gutter. */
28+export const DIALOG_PANEL =
29+ "fixed top-1/2 left-1/2 z-50 grid max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-xl border border-line-strong bg-surface p-6 text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-dialog-in data-[state=closed]:animate-dialog-out motion-reduce:animate-none";
30+
31+export function DialogContent({
32+ className,
33+ children,
34+ showClose = true,
35+ ...props
36+}: ComponentProps<typeof Primitive.Content> & { showClose?: boolean }) {
37+ return (
38+ <Primitive.Portal>
39+ <DialogOverlay />
40+ <Primitive.Content className={cn(DIALOG_PANEL, className)} {...props}>
41+ {children}
42+ {showClose && (
43+ <Primitive.Close
44+ aria-label="Close"
45+ className="absolute top-4 right-4 rounded-md p-1 text-faint transition-colors hover:bg-raised hover:text-fg"
46+ >
47+ <X size={16} />
48+ </Primitive.Close>
49+ )}
50+ </Primitive.Content>
51+ </Primitive.Portal>
52+ );
53+}
54+
55+export function DialogHeader({ className, ...props }: ComponentProps<"div">) {
56+ return <div className={cn("flex flex-col gap-1.5 pr-6", className)} {...props} />;
57+}
58+
59+export function DialogFooter({ className, ...props }: ComponentProps<"div">) {
60+ return <div className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />;
61+}
62+
63+export function DialogTitle({ className, ...props }: ComponentProps<typeof Primitive.Title>) {
64+ return <Primitive.Title className={cn("text-base font-semibold tracking-tight", className)} {...props} />;
65+}
66+
67+export function DialogDescription({ className, ...props }: ComponentProps<typeof Primitive.Description>) {
68+ return <Primitive.Description className={cn("text-sm text-muted", className)} {...props} />;
69+}
+1−0
2020 sideOffset={sideOffset}
2121 className={cn(
2222 "z-50 min-w-48 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg border border-line-strong bg-raised p-1 text-sm shadow-xl shadow-black/40",
23+ "data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
2324 className,
2425 )}
2526 {...props}
+49−0
1+import type { ComponentProps, ReactNode } from "react";
2+
3+import { cn } from "../../lib/cn";
4+import { Label } from "./label";
5+
6+// shadcn/ui's field: a label, a control, a line of help and an error,
7+// spaced the same way on every form.
8+//
9+// <Field>
10+// <FieldLabel htmlFor="name">Name</FieldLabel>
11+// <Input id="name" name="name" />
12+// <FieldDescription>Shown on the project page.</FieldDescription>
13+// <FieldError>{actionData?.error}</FieldError>
14+// </Field>
15+//
16+// A group of radios or checkboxes goes in a <FieldSet> with a <FieldLegend>.
17+
18+export function Field({ className, ...props }: ComponentProps<"div">) {
19+ return <div role="group" className={cn("flex flex-col gap-1.5", className)} {...props} />;
20+}
21+
22+export function FieldLabel({ className, ...props }: ComponentProps<typeof Label>) {
23+ return <Label className={cn("mb-0", className)} {...props} />;
24+}
25+
26+export function FieldDescription({ className, ...props }: ComponentProps<"p">) {
27+ return <p className={cn("text-xs leading-relaxed text-faint", className)} {...props} />;
28+}
29+
30+export function FieldError({ className, children, ...props }: ComponentProps<"p"> & { children?: ReactNode }) {
31+ if (!children) return null;
32+ return (
33+ <p role="alert" className={cn("text-xs text-danger", className)} {...props}>
34+ {children}
35+ </p>
36+ );
37+}
38+
39+export function FieldSet({ className, ...props }: ComponentProps<"fieldset">) {
40+ return <fieldset className={cn("flex min-w-0 flex-col gap-3", className)} {...props} />;
41+}
42+
43+export function FieldLegend({ className, ...props }: ComponentProps<"legend">) {
44+ return <legend className={cn("mb-3 text-sm font-medium text-muted", className)} {...props} />;
45+}
46+
47+export function FieldGroup({ className, ...props }: ComponentProps<"div">) {
48+ return <div className={cn("flex flex-col gap-6", className)} {...props} />;
49+}
+28−2
153153 /** How g1t's agents are named: as reviewers and assignees, and as commit authors. */
154154 const AGENT_NAMES = new Set(["g1t-agent", "g1t agent", "g1t"]);
155155
156+/** Where an uploaded avatar is served, from the hash it is stored by. */
157+export function avatarUrl(avatar: string): string {
158+ return `/avatars/${avatar}`;
159+}
160+
156161 /**
157− * A letter avatar whose colour is stable for a given name. People are
158− * round; a workspace is `square`.
162+ * An uploaded avatar if there is one, else a letter avatar whose colour is
163+ * stable for a given name. People are round; a workspace is `square`. An
164+ * image that fails to load falls back to the letter.
159165 */
160166 export function Avatar({
161167 name,
162168 size = 20,
163169 square,
170+ image,
164171 }: {
165172 name: string;
166173 size?: number;
167174 square?: boolean;
175+ /** The uploaded avatar's hash, as identity returns it. */
176+ image?: string | null;
168177 }) {
178+ const [failed, setFailed] = useState<string | null>(null);
179+ if (image && failed !== image) {
180+ return (
181+ <img
182+ src={avatarUrl(image)}
183+ alt=""
184+ aria-hidden="true"
185+ width={size}
186+ height={size}
187+ loading="lazy"
188+ decoding="async"
189+ onError={() => setFailed(image)}
190+ className="inline-block shrink-0 bg-raised object-cover"
191+ style={{ width: size, height: size, borderRadius: square ? size * 0.24 : size }}
192+ />
193+ );
194+ }
169195 // g1t's own agents wear the agent colour and mark everywhere they appear.
170196 if (AGENT_NAMES.has(name)) {
171197 return (
+43−0
1+import type { ComponentProps } from "react";
2+
3+import { cn } from "../../lib/cn";
4+import { notACredential } from "./index";
5+
6+// shadcn/ui's input, styled with g1t's tokens. Unlike the older `Input` in
7+// ./index it takes a className, and fields that are not part of signing in
8+// still keep password managers out.
9+
10+export const CONTROL =
11+ "w-full min-w-0 rounded-md border border-line bg-bg px-3 py-2 text-sm text-fg outline-none transition-[border-color,box-shadow] placeholder:text-faint hover:border-line-strong focus-visible:border-accent-dim focus-visible:ring-2 focus-visible:ring-accent/25 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-danger/70";
12+
13+export function Input({ className, ...props }: ComponentProps<"input">) {
14+ return <input {...notACredential(props.autoComplete)} {...props} className={cn(CONTROL, "h-9", className)} />;
15+}
16+
17+/**
18+ * Text either side of an input, joined into one control, as in
19+ * `workspace / name` or `https:// … .g1t.page`. Put addons and an input
20+ * inside; the group draws the border and the focus ring.
21+ */
22+export function InputGroup({ className, ...props }: ComponentProps<"div">) {
23+ return (
24+ <div
25+ className={cn(
26+ "flex h-9 w-full min-w-0 items-stretch overflow-hidden rounded-md border border-line bg-bg text-sm transition-[border-color,box-shadow]",
27+ "hover:border-line-strong has-[:focus-visible]:border-accent-dim has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent/25",
28+ "[&_input]:h-full [&_input]:rounded-none [&_input]:border-0 [&_input]:bg-transparent [&_input]:ring-0 [&_input]:focus-visible:ring-0",
29+ className,
30+ )}
31+ {...props}
32+ />
33+ );
34+}
35+
36+export function InputAddon({ className, ...props }: ComponentProps<"span">) {
37+ return (
38+ <span
39+ className={cn("flex shrink-0 items-center px-2.5 text-faint select-none", className)}
40+ {...props}
41+ />
42+ );
43+}
+19−0
1+import { Label as Primitive } from "radix-ui";
2+import type { ComponentProps } from "react";
3+
4+import { cn } from "../../lib/cn";
5+
6+// shadcn/ui's label, styled with g1t's tokens.
7+
8+export function Label({ className, ...props }: ComponentProps<typeof Primitive.Root>) {
9+ return (
10+ <Primitive.Root
11+ className={cn(
12+ "flex items-center gap-2 text-sm font-medium text-muted select-none",
13+ "peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
14+ className,
15+ )}
16+ {...props}
17+ />
18+ );
19+}
+33−0
1+import { Popover as Primitive } from "radix-ui";
2+import type { ComponentProps } from "react";
3+
4+import { cn } from "../../lib/cn";
5+
6+// shadcn/ui's popover, styled with g1t's tokens.
7+
8+export const Popover = Primitive.Root;
9+export const PopoverTrigger = Primitive.Trigger;
10+export const PopoverAnchor = Primitive.Anchor;
11+export const PopoverClose = Primitive.Close;
12+
13+export function PopoverContent({
14+ className,
15+ align = "center",
16+ sideOffset = 6,
17+ ...props
18+}: ComponentProps<typeof Primitive.Content>) {
19+ return (
20+ <Primitive.Portal>
21+ <Primitive.Content
22+ align={align}
23+ sideOffset={sideOffset}
24+ className={cn(
25+ "z-50 w-72 max-w-[calc(100vw-2rem)] rounded-lg border border-line-strong bg-raised p-4 text-sm text-fg shadow-xl shadow-black/40 outline-none",
26+ "origin-(--radix-popover-content-transform-origin) data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
27+ className,
28+ )}
29+ {...props}
30+ />
31+ </Primitive.Portal>
32+ );
33+}
+138−0
1+import { RadioGroup as Primitive } from "radix-ui";
2+import { type ComponentProps, type ReactNode, useId } from "react";
3+
4+import { cn } from "../../lib/cn";
5+
6+// shadcn/ui's radio group, styled with g1t's tokens, in two shapes:
7+//
8+// - compact: <RadioOption> rows, a dot and a line of text each;
9+// - cards: <RadioCard> tiles, an icon, a title and a description each.
10+//
11+// Inside a <Form>, the group's `name` makes Radix render a hidden radio per
12+// item, so the chosen `value` posts exactly as native radios do. The arrow
13+// keys move between items and choose as they go; disabled items are skipped.
14+
15+export function RadioGroup({ className, ...props }: ComponentProps<typeof Primitive.Root>) {
16+ return <Primitive.Root className={cn("grid gap-2", className)} {...props} />;
17+}
18+
19+/** The bare dot. */
20+export function RadioGroupItem({ className, ...props }: ComponentProps<typeof Primitive.Item>) {
21+ return (
22+ <Primitive.Item
23+ className={cn(
24+ "peer relative inline-flex size-4 shrink-0 items-center justify-center rounded-full border border-line-strong bg-bg outline-none transition-colors",
25+ "hover:border-faint focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-bg focus-visible:outline-none",
26+ "data-[state=checked]:border-accent disabled:cursor-not-allowed disabled:opacity-50",
27+ className,
28+ )}
29+ {...props}
30+ >
31+ <Primitive.Indicator className="size-2 rounded-full bg-accent data-[state=checked]:animate-pop-in motion-reduce:animate-none" />
32+ </Primitive.Item>
33+ );
34+}
35+
36+/** A compact choice: the dot, a label, and optionally a line under it. */
37+export function RadioOption({
38+ value,
39+ label,
40+ description,
41+ icon,
42+ disabled,
43+ className,
44+}: {
45+ value: string;
46+ label: ReactNode;
47+ description?: ReactNode;
48+ icon?: ReactNode;
49+ disabled?: boolean;
50+ className?: string;
51+}) {
52+ const id = useId();
53+ return (
54+ <div className={cn("flex items-start gap-2.5", disabled && "opacity-60", className)}>
55+ <RadioGroupItem value={value} id={id} disabled={disabled} className="mt-0.5" aria-describedby={description ? `${id}-d` : undefined} />
56+ <label htmlFor={id} className={cn("min-w-0 text-sm", disabled ? "cursor-not-allowed" : "cursor-pointer")}>
57+ <span className="flex items-center gap-1.5 text-fg [&_svg]:size-3.5 [&_svg]:text-muted">
58+ {icon}
59+ {label}
60+ </span>
61+ {description && (
62+ <span id={`${id}-d`} className="mt-0.5 block text-xs text-muted">
63+ {description}
64+ </span>
65+ )}
66+ </label>
67+ </div>
68+ );
69+}
70+
71+/**
72+ * A big selectable card. The whole card is the radio: it takes focus, and
73+ * Space or a click chooses it. `badge` replaces the dot (for "Soon").
74+ */
75+export function RadioCard({
76+ value,
77+ title,
78+ description,
79+ icon,
80+ badge,
81+ disabled,
82+ className,
83+ children,
84+}: {
85+ value: string;
86+ title: ReactNode;
87+ description?: ReactNode;
88+ icon?: ReactNode;
89+ badge?: ReactNode;
90+ disabled?: boolean;
91+ className?: string;
92+ children?: ReactNode;
93+}) {
94+ const id = useId();
95+ return (
96+ <Primitive.Item
97+ value={value}
98+ disabled={disabled}
99+ aria-labelledby={`${id}-t`}
100+ aria-describedby={description ? `${id}-d` : undefined}
101+ className={cn(
102+ "group/card relative flex h-full w-full flex-col items-start rounded-xl border border-line bg-surface/40 p-4 text-left outline-none transition-[border-color,background-color,box-shadow] duration-150",
103+ "hover:border-line-strong hover:bg-surface",
104+ "focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none",
105+ "data-[state=checked]:border-accent/70 data-[state=checked]:bg-accent/[0.06] data-[state=checked]:shadow-[0_0_0_1px_color-mix(in_srgb,var(--color-accent)_35%,transparent),0_8px_24px_-12px_color-mix(in_srgb,var(--color-accent)_40%,transparent)]",
106+ "disabled:cursor-not-allowed disabled:border-dashed disabled:bg-transparent disabled:opacity-60 disabled:hover:border-line",
107+ className,
108+ )}
109+ >
110+ <span className="flex w-full items-start justify-between gap-3">
111+ {icon && (
112+ <span className="flex size-8 items-center justify-center rounded-lg bg-raised text-muted ring-1 ring-line transition-colors group-data-[state=checked]/card:bg-accent/10 group-data-[state=checked]/card:text-accent group-data-[state=checked]/card:ring-accent/30 [&_svg]:size-4">
113+ {icon}
114+ </span>
115+ )}
116+ {badge ? (
117+ <span className="ml-auto rounded-full bg-raised px-2 py-0.5 text-[0.6875rem] text-muted ring-1 ring-line">{badge}</span>
118+ ) : (
119+ <span
120+ aria-hidden="true"
121+ className="ml-auto flex size-4 items-center justify-center rounded-full border border-line-strong transition-colors group-data-[state=checked]/card:border-accent"
122+ >
123+ <span className="size-2 scale-0 rounded-full bg-accent transition-transform duration-150 group-data-[state=checked]/card:scale-100" />
124+ </span>
125+ )}
126+ </span>
127+ <span id={`${id}-t`} className={cn("block text-sm font-medium text-fg", icon && "mt-3")}>
128+ {title}
129+ </span>
130+ {description && (
131+ <span id={`${id}-d`} className="mt-1 block text-xs leading-relaxed text-muted">
132+ {description}
133+ </span>
134+ )}
135+ {children}
136+ </Primitive.Item>
137+ );
138+}
+159−0
1+import { Check, ChevronDown, ChevronUp } from "lucide-react";
2+import { Select as Primitive } from "radix-ui";
3+import type { ComponentProps, ReactNode } from "react";
4+
5+import { cn } from "../../lib/cn";
6+
7+// shadcn/ui's select, styled with g1t's tokens.
8+//
9+// Inside a <Form>, give <Select> a `name` and Radix renders a hidden native
10+// <select> beside the trigger, so the value posts like any other field.
11+// An item's value can never be "": use a word such as "none" and map it.
12+
13+export const Select = Primitive.Root;
14+export const SelectGroup = Primitive.Group;
15+export const SelectValue = Primitive.Value;
16+
17+const TRIGGER_SIZES = { sm: "h-8 px-2.5 text-[0.8125rem]", default: "h-9", lg: "h-11" };
18+
19+/** The control people see: the chosen value, or the placeholder, and a chevron. */
20+export function SelectTrigger({
21+ className,
22+ size = "default",
23+ children,
24+ ...props
25+}: ComponentProps<typeof Primitive.Trigger> & { size?: "sm" | "default" | "lg" }) {
26+ return (
27+ <Primitive.Trigger
28+ data-size={size}
29+ className={cn(
30+ "group/select flex w-full min-w-0 items-center justify-between gap-2 rounded-md border border-line bg-bg px-3 text-left text-sm whitespace-nowrap text-fg outline-none transition-colors",
31+ TRIGGER_SIZES[size],
32+ "hover:border-line-strong focus-visible:border-accent-dim focus-visible:ring-2 focus-visible:ring-accent/25 focus-visible:outline-none",
33+ "data-[state=open]:border-accent-dim data-[state=open]:ring-2 data-[state=open]:ring-accent/25",
34+ "data-placeholder:text-faint aria-invalid:border-danger/70",
35+ "disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-line",
36+ "[&>span]:flex [&>span]:min-w-0 [&>span]:items-center [&>span]:gap-2 [&>span]:truncate",
37+ "[&_svg]:shrink-0",
38+ className,
39+ )}
40+ {...props}
41+ >
42+ {children}
43+ <Primitive.Icon asChild>
44+ <ChevronDown
45+ size={15}
46+ className="text-faint transition-transform duration-200 group-data-[state=open]/select:rotate-180"
47+ />
48+ </Primitive.Icon>
49+ </Primitive.Trigger>
50+ );
51+}
52+
53+export function SelectContent({
54+ className,
55+ children,
56+ position = "popper",
57+ sideOffset = 6,
58+ ...props
59+}: ComponentProps<typeof Primitive.Content>) {
60+ return (
61+ <Primitive.Portal>
62+ <Primitive.Content
63+ position={position}
64+ sideOffset={position === "popper" ? sideOffset : undefined}
65+ className={cn(
66+ "relative z-50 max-h-(--radix-select-content-available-height) min-w-36 overflow-hidden rounded-lg border border-line-strong bg-raised text-sm text-fg shadow-xl shadow-black/40",
67+ "origin-(--radix-select-content-transform-origin) data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
68+ position === "popper" && "min-w-(--radix-select-trigger-width) max-w-[calc(100vw-2rem)]",
69+ className,
70+ )}
71+ {...props}
72+ >
73+ <SelectScrollUpButton />
74+ <Primitive.Viewport
75+ className={cn("p-1", position === "popper" && "max-h-80 scroll-my-1")}
76+ >
77+ {children}
78+ </Primitive.Viewport>
79+ <SelectScrollDownButton />
80+ </Primitive.Content>
81+ </Primitive.Portal>
82+ );
83+}
84+
85+export function SelectLabel({ className, ...props }: ComponentProps<typeof Primitive.Label>) {
86+ return (
87+ <Primitive.Label
88+ className={cn("px-2 pt-2 pb-1 text-[0.6875rem] font-medium tracking-wide text-faint uppercase", className)}
89+ {...props}
90+ />
91+ );
92+}
93+
94+/**
95+ * One option. `icon` (an icon or an avatar) and the label show in the
96+ * trigger once chosen; `description` shows only in the list.
97+ */
98+export function SelectItem({
99+ className,
100+ children,
101+ icon,
102+ description,
103+ ...props
104+}: ComponentProps<typeof Primitive.Item> & { icon?: ReactNode; description?: ReactNode }) {
105+ return (
106+ <Primitive.Item
107+ className={cn(
108+ "relative flex w-full cursor-default items-start gap-2 rounded-md py-1.5 pr-8 pl-2 text-fg/90 outline-none select-none",
109+ "data-highlighted:bg-line data-highlighted:text-fg data-disabled:pointer-events-none data-disabled:opacity-45",
110+ "data-[state=checked]:text-fg",
111+ className,
112+ )}
113+ {...props}
114+ >
115+ <span className="flex min-w-0 flex-col">
116+ <Primitive.ItemText>
117+ <span className="flex min-w-0 items-center gap-2 [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted">
118+ {icon}
119+ <span className="truncate">{children}</span>
120+ </span>
121+ </Primitive.ItemText>
122+ {description && (
123+ <span className={cn("mt-0.5 text-xs leading-snug text-faint", icon != null && "pl-6")}>{description}</span>
124+ )}
125+ </span>
126+ <span className="absolute top-2 right-2 flex size-4 items-center justify-center">
127+ <Primitive.ItemIndicator>
128+ <Check size={14} className="text-accent" />
129+ </Primitive.ItemIndicator>
130+ </span>
131+ </Primitive.Item>
132+ );
133+}
134+
135+export function SelectSeparator({ className, ...props }: ComponentProps<typeof Primitive.Separator>) {
136+ return <Primitive.Separator className={cn("-mx-1 my-1 h-px bg-line", className)} {...props} />;
137+}
138+
139+export function SelectScrollUpButton({ className, ...props }: ComponentProps<typeof Primitive.ScrollUpButton>) {
140+ return (
141+ <Primitive.ScrollUpButton
142+ className={cn("flex cursor-default items-center justify-center py-1 text-faint", className)}
143+ {...props}
144+ >
145+ <ChevronUp size={14} />
146+ </Primitive.ScrollUpButton>
147+ );
148+}
149+
150+export function SelectScrollDownButton({ className, ...props }: ComponentProps<typeof Primitive.ScrollDownButton>) {
151+ return (
152+ <Primitive.ScrollDownButton
153+ className={cn("flex cursor-default items-center justify-center py-1 text-faint", className)}
154+ {...props}
155+ >
156+ <ChevronDown size={14} />
157+ </Primitive.ScrollDownButton>
158+ );
159+}
+66−0
1+import { Switch as Primitive } from "radix-ui";
2+import { type ComponentProps, type ReactNode, useId } from "react";
3+
4+import { cn } from "../../lib/cn";
5+
6+// shadcn/ui's switch, styled with g1t's tokens. Like a checkbox, inside a
7+// <Form> a `name` posts `value` (default "on") when on and nothing when off.
8+
9+export function Switch({
10+ className,
11+ size = "default",
12+ ...props
13+}: ComponentProps<typeof Primitive.Root> & { size?: "sm" | "default" }) {
14+ return (
15+ <Primitive.Root
16+ data-size={size}
17+ className={cn(
18+ "peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent p-px outline-none transition-colors",
19+ "data-[size=default]:h-5 data-[size=default]:w-9 data-[size=sm]:h-4 data-[size=sm]:w-7",
20+ "bg-line-strong data-[state=checked]:bg-accent",
21+ "focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-bg focus-visible:outline-none",
22+ "disabled:cursor-not-allowed disabled:opacity-50",
23+ className,
24+ )}
25+ {...props}
26+ >
27+ <Primitive.Thumb
28+ className={cn(
29+ "pointer-events-none block rounded-full bg-fg shadow-sm ring-0 transition-transform duration-200",
30+ "group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3",
31+ "data-[state=unchecked]:translate-x-0 group-data-[size=default]/switch:data-[state=checked]:translate-x-4 group-data-[size=sm]/switch:data-[state=checked]:translate-x-3",
32+ )}
33+ />
34+ </Primitive.Root>
35+ );
36+}
37+
38+/**
39+ * A setting that is on or off, as a card: a title, what it means, and the
40+ * switch. The whole card toggles it.
41+ */
42+export function SwitchCard({
43+ title,
44+ children,
45+ className,
46+ ...props
47+}: ComponentProps<typeof Primitive.Root> & { title: ReactNode; children?: ReactNode }) {
48+ const generated = useId();
49+ const id = props.id ?? generated;
50+ return (
51+ <label
52+ htmlFor={id}
53+ className={cn(
54+ "flex cursor-pointer items-start justify-between gap-4 rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong",
55+ "has-[[data-state=checked]]:border-accent/40 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60",
56+ className,
57+ )}
58+ >
59+ <span className="min-w-0">
60+ <span className="block text-sm font-medium text-fg">{title}</span>
61+ {children && <span className="mt-1 block text-sm text-muted">{children}</span>}
62+ </span>
63+ <Switch id={id} className="mt-0.5" {...props} />
64+ </label>
65+ );
66+}
+17−0
1+import type { ComponentProps } from "react";
2+
3+import { cn } from "../../lib/cn";
4+import { notACredential } from "./index";
5+import { CONTROL } from "./input";
6+
7+// shadcn/ui's textarea, styled with g1t's tokens.
8+
9+export function Textarea({ className, ...props }: ComponentProps<"textarea">) {
10+ return (
11+ <textarea
12+ {...notACredential(props.autoComplete)}
13+ {...props}
14+ className={cn(CONTROL, "min-h-20 resize-y leading-relaxed", className)}
15+ />
16+ );
17+}
+45−0
1+import { Tooltip as Primitive } from "radix-ui";
2+import type { ComponentProps } from "react";
3+
4+import { cn } from "../../lib/cn";
5+
6+// shadcn/ui's tooltip, styled with g1t's tokens. Each tooltip carries its
7+// own provider, so one can be dropped in anywhere without setup.
8+
9+export function TooltipProvider({ delayDuration = 200, ...props }: ComponentProps<typeof Primitive.Provider>) {
10+ return <Primitive.Provider delayDuration={delayDuration} {...props} />;
11+}
12+
13+export function Tooltip(props: ComponentProps<typeof Primitive.Root>) {
14+ return (
15+ <TooltipProvider>
16+ <Primitive.Root {...props} />
17+ </TooltipProvider>
18+ );
19+}
20+
21+export const TooltipTrigger = Primitive.Trigger;
22+
23+export function TooltipContent({
24+ className,
25+ sideOffset = 6,
26+ children,
27+ ...props
28+}: ComponentProps<typeof Primitive.Content>) {
29+ return (
30+ <Primitive.Portal>
31+ <Primitive.Content
32+ sideOffset={sideOffset}
33+ className={cn(
34+ "z-50 max-w-xs rounded-md border border-line-strong bg-raised px-2.5 py-1.5 text-xs text-fg shadow-lg shadow-black/40",
35+ "origin-(--radix-tooltip-content-transform-origin) animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
36+ className,
37+ )}
38+ {...props}
39+ >
40+ {children}
41+ <Primitive.Arrow className="fill-raised" width={10} height={5} />
42+ </Primitive.Content>
43+ </Primitive.Portal>
44+ );
45+}
+20−14
1010
1111 import type { WebhooksAction, WebhooksData } from "../lib/webhooks.server";
1212 import { Button, CopyLine, EmptyState, ErrorText, Field, Input, TimeAgo } from "./ui";
13+import { CheckboxOption } from "./ui/checkbox";
14+import { RadioGroup, RadioOption } from "./ui/radio-group";
1315
1416 /** The events, in groups people recognise. */
1517 const GROUPS: { title: string; events: string[] }[] = [
183185 </Field>
184186 <fieldset>
185187 <legend className="mb-1.5 text-sm font-medium text-muted">Which events</legend>
186− <div className="flex flex-wrap gap-4 text-sm">
187− <label className="flex items-center gap-2">
188− <input type="radio" name="which" value="all" checked={which === "all"} onChange={() => setWhich("all")} />
189− Everything, including events added later
190− </label>
191− <label className="flex items-center gap-2">
192− <input type="radio" name="which" value="some" checked={which === "some"} onChange={() => setWhich("some")} />
193− Let me choose
194− </label>
195− </div>
188+ <RadioGroup
189+ name="which"
190+ value={which}
191+ onValueChange={(value) => setWhich(value as typeof which)}
192+ aria-label="Which events"
193+ className="flex flex-wrap gap-x-6 gap-y-2"
194+ >
195+ <RadioOption value="all" label="Everything, including events added later" />
196+ <RadioOption value="some" label="Let me choose" />
197+ </RadioGroup>
196198 {which === "some" && (
197199 <div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
198200 {GROUPS.map((group) => (
202204 {group.events
203205 .filter((event) => (EVENT_TYPES as readonly string[]).includes(event))
204206 .map((event) => (
205− <label key={event} className="flex items-center gap-2 font-mono text-[0.8125rem]">
206− <input type="checkbox" name="event" value={event} />
207− {event}
208− </label>
207+ <CheckboxOption
208+ key={event}
209+ name="event"
210+ value={event}
211+ label={event}
212+ className="items-center"
213+ labelClassName="font-mono text-[0.8125rem]"
214+ />
209215 ))}
210216 </div>
211217 </div>
+15−11
1616 import { repoAt } from "../lib/markdown-plugins";
1717 import { Markdown } from "./markdown";
1818 import { Avatar, Button, Textarea, TimeAgo } from "./ui";
19+import { CheckboxOption } from "./ui/checkbox";
1920
2021 /** Hues for the labels every repository starts with. */
2122 const LABEL_HUES: Record<string, number> = {
426427 {people.length > 0 && (
427428 <div className="space-y-1.5">
428429 {people.map((person) => (
429− <label key={person} className="flex cursor-pointer items-center gap-2 text-sm">
430− <input
431− type="checkbox"
432− name={name}
433− value={person}
434− defaultChecked={chosen.includes(person)}
435− className="accent-accent"
436− />
437− <Avatar name={person} size={18} />
438− <span className="font-mono text-xs">{person}</span>
439− </label>
430+ <CheckboxOption
431+ key={person}
432+ name={name}
433+ value={person}
434+ defaultChecked={chosen.includes(person)}
435+ className="items-center"
436+ labelClassName="flex items-center gap-2"
437+ label={
438+ <>
439+ <Avatar name={person} size={18} />
440+ <span className="font-mono text-xs">{person}</span>
441+ </>
442+ }
443+ />
440444 ))}
441445 </div>
442446 )}
+49−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { MAX_AVATAR_BYTES, readAvatarUpload, sniffImage, toBase64 } from "./avatar-upload.ts";
5+
6+const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
7+const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 16]);
8+const text = (value: string) => new TextEncoder().encode(value);
9+
10+function form(bytes: Uint8Array, name = "icon.png", type = "image/png"): FormData {
11+ const data = new FormData();
12+ data.set("avatar", new File([bytes], name, { type }));
13+ return data;
14+}
15+
16+test("an image is known by its bytes", () => {
17+ assert.equal(sniffImage(PNG), "image/png");
18+ assert.equal(sniffImage(JPEG), "image/jpeg");
19+ assert.equal(sniffImage(text("GIF89a\x01\x00")), "image/gif");
20+ assert.equal(sniffImage(text("RIFF\x24\x00\x00\x00WEBPVP8 ")), "image/webp");
21+});
22+
23+test("SVG and anything else is refused, whatever it is called", async () => {
24+ assert.equal(sniffImage(text('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>')), null);
25+ assert.equal(sniffImage(text("RIFF\x00\x00\x00\x00WAVEfmt ")), null);
26+ const disguised = await readAvatarUpload(form(text("<svg onload=alert(1)/>"), "icon.png", "image/png"));
27+ assert.ok("error" in disguised);
28+});
29+
30+test("more than a megabyte is refused", async () => {
31+ const big = new Uint8Array(MAX_AVATAR_BYTES + 1);
32+ big.set(PNG);
33+ const result = await readAvatarUpload(form(big));
34+ assert.deepEqual(result, { error: "Use an image of at most 1 MB." });
35+ const exact = new Uint8Array(MAX_AVATAR_BYTES);
36+ exact.set(PNG);
37+ const fits = await readAvatarUpload(form(exact));
38+ assert.ok("image" in fits);
39+});
40+
41+test("an image is sent as base64", async () => {
42+ const result = await readAvatarUpload(form(PNG));
43+ assert.deepEqual(result, { image: toBase64(PNG) });
44+ assert.equal(Buffer.from(toBase64(PNG), "base64").compare(Buffer.from(PNG)), 0);
45+});
46+
47+test("a missing file is asked for", async () => {
48+ assert.deepEqual(await readAvatarUpload(new FormData()), { error: "Choose an image to upload." });
49+});
+50−0
1+/**
2+ * An avatar as it arrives from a form: read, checked and turned into the
3+ * base64 the identity service takes. Identity checks it again, by its
4+ * bytes, and is the one that decides; this only turns away what is
5+ * plainly wrong before sending a megabyte across.
6+ */
7+
8+/** As `MAX_AVATAR_BYTES` in the contracts: kept here so tests run without them. */
9+export const MAX_AVATAR_BYTES = 1024 * 1024;
10+
11+/** What an `<input type="file">` offers to pick. SVG is left out on purpose. */
12+export const AVATAR_ACCEPT = "image/png,image/jpeg,image/webp,image/gif";
13+
14+/** The type an image's first bytes show it to be, or null. As identity's `sniff`. */
15+export function sniffImage(bytes: Uint8Array): string | null {
16+ const starts = (...prefix: number[]) => prefix.every((byte, i) => bytes[i] === byte);
17+ const ascii = (at: number, text: string) => [...text].every((char, i) => bytes[at + i] === char.charCodeAt(0));
18+ if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
19+ if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
20+ if (ascii(0, "GIF87a") || ascii(0, "GIF89a")) return "image/gif";
21+ if (bytes.length >= 12 && ascii(0, "RIFF") && ascii(8, "WEBP")) return "image/webp";
22+ return null;
23+}
24+
25+/** Bytes as base64, in chunks so a large file does not overflow the stack. */
26+export function toBase64(bytes: Uint8Array): string {
27+ let binary = "";
28+ for (let i = 0; i < bytes.length; i += 0x8000) {
29+ binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
30+ }
31+ return btoa(binary);
32+}
33+
34+export type AvatarUpload = { image: string } | { error: string };
35+
36+/** Reads the file in `field` of a submitted form. */
37+export async function readAvatarUpload(form: FormData, field = "avatar"): Promise<AvatarUpload> {
38+ const file = form.get(field);
39+ if (!file || typeof file === "string" || file.size === 0) {
40+ return { error: "Choose an image to upload." };
41+ }
42+ if (file.size > MAX_AVATAR_BYTES) {
43+ return { error: "Use an image of at most 1 MB." };
44+ }
45+ const bytes = new Uint8Array(await file.arrayBuffer());
46+ if (!sniffImage(bytes)) {
47+ return { error: "Use a PNG, JPEG, WebP or GIF image." };
48+ }
49+ return { image: toBase64(bytes) };
50+}
+7−2
4040 project?: { name?: string; description?: string | null } | null;
4141 open?: { issues?: number; pulls?: number };
4242 };
43−type WorkspaceData = { workspace?: { name?: string; description?: string | null } };
43+type WorkspaceData = { workspace?: { name?: string; description?: string | null; avatar?: string | null } };
4444
4545 function loaded<T>(args: PageArgs, id: string): T | undefined {
4646 return args.matches.find((match) => match?.id === id)?.loaderData as T | undefined;
101101 (project
102102 ? [project.project?.name, project.project?.description ?? project.repo?.description, project.open]
103103 : workspace
104− ? [workspace.workspace?.name, workspace.workspace?.description]
104+ ? [
105+ workspace.workspace?.name,
106+ workspace.workspace?.description,
107+ // Only when there is one, so cards without an icon keep their address.
108+ ...(workspace.workspace?.avatar ? [workspace.workspace.avatar] : []),
109+ ]
105110 : undefined);
106111 const pathname = args.location.pathname.replace(/\/+$/, "") || "/";
107112 // The title a preview shows needs no "· g1t": the site name is beside it.
+3−3
191191 aria-label="Account menu"
192192 className="flex items-center gap-1.5 rounded-md p-1 outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised"
193193 >
194− <Avatar name={user.username} size={24} />
194+ <Avatar name={user.username} image={user.avatar} size={24} />
195195 <ChevronDown size={14} className="text-faint" />
196196 </DropdownMenuTrigger>
197197 <DropdownMenuContent align="end" className="w-56">
205205 {(user.workspaces ?? []).map((membership) => (
206206 <DropdownMenuItem asChild key={membership.slug}>
207207 <Link to={`/${membership.slug}`}>
208− <Avatar name={membership.slug} size={16} square />
209− {membership.slug}
208+ <Avatar name={membership.slug} image={membership.avatar} size={16} square />
209+ <span className="min-w-0 truncate">{membership.name || membership.slug}</span>
210210 </Link>
211211 </DropdownMenuItem>
212212 ))}
+24−6
153153 * what is moving in it, and the way to its people and settings. The rest of
154154 * the page stays the viewer's own, across every workspace.
155155 */
156−function WorkspaceCard({ slug, role, repos, active }: { slug: string; role: string; repos: Repo[]; active: Active[] }) {
156+function WorkspaceCard({
157+ slug,
158+ name,
159+ avatar,
160+ role,
161+ repos,
162+ active,
163+}: {
164+ slug: string;
165+ name: string;
166+ avatar?: string | null;
167+ role: string;
168+ repos: Repo[];
169+ active: Active[];
170+}) {
157171 const mine = active.filter((item) => item.repo.namespace.toLowerCase() === slug);
158172 const stats = [
159173 { value: repos.filter((repo) => repo.namespace.toLowerCase() === slug).length, label: "repositories" },
163177 return (
164178 <div className="rounded-xl border border-line bg-surface p-5">
165179 <div className="flex items-center gap-3">
166− <Avatar name={slug} size={32} square />
180+ <Avatar name={slug} image={avatar} size={32} square />
167181 <div className="min-w-0">
168− <Link to={`/${slug}`} className="block truncate font-medium hover:underline">
169− {slug}
182+ <Link to={`/${slug}`} title={name} className="block truncate font-medium hover:underline">
183+ {name}
170184 </Link>
171− <p className="text-xs text-muted">Workspace · {role}</p>
185+ <p className="truncate text-xs text-muted">
186+ <span className="font-mono">g1t.sh/{slug}</span> · <span className="capitalize">{role}</span>
187+ </p>
172188 </div>
173189 </div>
174190 <dl className="mt-4 grid grid-cols-3 gap-2">
339355 <div className="min-w-0 space-y-10">
340356 <section>
341357 <div className="flex items-center gap-3">
342− <Avatar name={viewer.username} size={36} />
358+ <Avatar name={viewer.username} image={viewer.avatar} size={36} />
343359 <div>
344360 <h1 className="text-xl font-semibold tracking-tight">
345361 Mission control
429445 {shell?.workspace && (
430446 <WorkspaceCard
431447 slug={shell.workspace.slug.toLowerCase()}
448+ name={shell.workspace.name || shell.workspace.slug}
449+ avatar={shell.workspace.avatar}
432450 role={shell.workspace.role}
433451 repos={repos}
434452 active={active}
+116−80
1−import { Box, Download, GitBranch, Sparkles } from "lucide-react";
1+import { Box, Download, GitBranch, Globe, Lock, Sparkles } from "lucide-react";
22 import { useState } from "react";
33 import { Form, redirect, useNavigation } from "react-router";
44
55 import type { Route } from "./+types/new";
66 import { page } from "../lib/meta";
7−import { Button, ErrorText, Field, Input } from "../components/ui";
7+import { Avatar, Button, ErrorText } from "../components/ui";
8+import { Field, FieldDescription, FieldLabel, FieldLegend, FieldSet } from "../components/ui/field";
9+import { Input, InputAddon, InputGroup } from "../components/ui/input";
10+import { RadioCard, RadioGroup } from "../components/ui/radio-group";
11+import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "../components/ui/select";
812 import { repos } from "../lib/services.server";
913 import { assertSameOrigin, requireUser } from "../lib/session.server";
1014
1418
1519 export function loader({ request, context }: Route.LoaderArgs) {
1620 const user = requireUser(context, request);
17− const workspaces = (user.workspaces ?? []).map((membership) => membership.slug);
21+ const workspaces = (user.workspaces ?? []).map((membership) => ({
22+ slug: membership.slug,
23+ role: membership.role,
24+ name: membership.name ?? membership.slug,
25+ avatar: membership.avatar ?? null,
26+ }));
1827 // Projects live in a workspace, so there has to be one first.
1928 if (workspaces.length === 0) throw redirect("/workspaces/new");
2029 const asked = new URL(request.url).searchParams.get("workspace");
2130 return {
2231 workspaces,
23− selected: asked && workspaces.includes(asked) ? asked : workspaces[0],
32+ selected: asked && workspaces.some((workspace) => workspace.slug === asked) ? asked : workspaces[0].slug,
2433 };
2534 }
2635
4857 id: "empty",
4958 title: "Start empty",
5059 text: "A new repository on g1t. Push to it, or hand an agent the first issue.",
51− icon: <Sparkles size={16} />,
60+ icon: <Sparkles />,
5261 },
5362 {
5463 id: "import",
5564 title: "Import code",
5665 text: "Copy a public repository from GitHub or any git host into a new one on g1t.",
57− icon: <Download size={16} />,
66+ icon: <Download />,
5867 },
5968 {
6069 id: "mirror",
6170 title: "Mirror GitHub, GitLab or Bitbucket",
6271 text: "Keep the code where it is and get g1t's deployments and agents on it.",
63− icon: <GitBranch size={16} />,
72+ icon: <GitBranch />,
6473 soon: true,
6574 },
6675 ];
6776
77+const ROLE_LABELS: Record<string, string> = { owner: "Owner", admin: "Admin", member: "Member" };
78+
6879 export default function NewProject({ loaderData, actionData }: Route.ComponentProps) {
6980 const [source, setSource] = useState<Source>("empty");
81+ const [workspace, setWorkspace] = useState(loaderData.selected);
82+ const [name, setName] = useState("");
7083 const busy = useNavigation().state === "submitting";
7184 return (
7285 <main className="mx-auto max-w-2xl px-4 py-12">
8093 </p>
8194
8295 <Form method="post" className="mt-8 space-y-8">
83− <fieldset>
84− <legend className="mb-3 text-sm font-medium text-muted">Where its code comes from</legend>
85− <div className="grid gap-3 sm:grid-cols-3">
86− {SOURCES.map((option) => {
87− const chosen = source === option.id;
88− return (
89− <label
90− key={option.id}
91− className={`relative rounded-xl border p-4 transition-colors ${
92− option.soon
93− ? "cursor-not-allowed border-dashed border-line opacity-60"
94− : chosen
95− ? "cursor-pointer border-accent bg-accent/5"
96− : "cursor-pointer border-line hover:border-line-strong"
97− }`}
98− >
99− <input
100− type="radio"
101− name="source"
102− value={option.id}
103− checked={chosen}
104− disabled={option.soon}
105− onChange={() => !option.soon && setSource(option.id as Source)}
106− className="sr-only"
107− />
108− <span className={chosen ? "text-accent" : "text-muted"}>{option.icon}</span>
109− <span className="mt-2 block text-sm font-medium">{option.title}</span>
110− <span className="mt-1 block text-xs text-muted">{option.text}</span>
111− {option.soon && (
112− <span className="absolute top-3 right-3 rounded-full bg-raised px-2 py-0.5 text-[0.6875rem] text-muted ring-1 ring-line">
113− Soon
114− </span>
115− )}
116− </label>
117− );
118− })}
119− </div>
120− </fieldset>
96+ <FieldSet>
97+ <FieldLegend>Where its code comes from</FieldLegend>
98+ <RadioGroup
99+ name="source"
100+ value={source}
101+ onValueChange={(value) => setSource(value as Source)}
102+ aria-label="Where its code comes from"
103+ className="gap-3 sm:grid-cols-3"
104+ >
105+ {SOURCES.map((option) => (
106+ <RadioCard
107+ key={option.id}
108+ value={option.id}
109+ title={option.title}
110+ description={option.text}
111+ icon={option.icon}
112+ disabled={option.soon}
113+ badge={option.soon ? "Soon" : undefined}
114+ />
115+ ))}
116+ </RadioGroup>
117+ </FieldSet>
121118
122119 {source === "import" && (
123− <Field
124− label="Repository to import"
125− hint="The address of a public repository. Its default branch is copied, up to 40 MB."
126− >
127− <Input name="importUrl" type="url" required placeholder="https://github.com/owner/repo" />
120+ <Field className="animate-fade-in">
121+ <FieldLabel htmlFor="importUrl">Repository to import</FieldLabel>
122+ <Input id="importUrl" name="importUrl" type="url" required placeholder="https://github.com/owner/repo" />
123+ <FieldDescription>The address of a public repository. Its default branch is copied, up to 40 MB.</FieldDescription>
128124 </Field>
129125 )}
130126
131− <Field label="Name" hint="The project and its repository share it: g1t.sh/<workspace>/<name>.">
132− <div className="flex items-center gap-2 font-mono text-sm">
133− <select
134− name="workspace"
135− defaultValue={loaderData.selected}
136− aria-label="Workspace"
137− className="rounded-md border border-line bg-bg px-2 py-2 text-sm"
138− >
139− {loaderData.workspaces.map((slug) => (
140− <option key={slug} value={slug}>
141− {slug}
142− </option>
143− ))}
144− </select>
145− <span className="text-muted">/</span>
146− <Input name="name" required autoFocus maxLength={100} placeholder="my-app" />
147− </div>
127+ <Field>
128+ <FieldLabel htmlFor="name">Name</FieldLabel>
129+ <InputGroup className="h-10">
130+ <Select name="workspace" value={workspace} onValueChange={setWorkspace}>
131+ <SelectTrigger
132+ aria-label="Workspace"
133+ className="h-full w-auto max-w-[55%] shrink-0 rounded-none border-0 bg-surface px-2.5 font-medium hover:bg-raised focus-visible:ring-0 data-[state=open]:bg-raised data-[state=open]:ring-0 sm:max-w-[45%]"
134+ >
135+ <SelectValue />
136+ </SelectTrigger>
137+ <SelectContent align="start" className="min-w-64">
138+ <SelectGroup>
139+ <SelectLabel>Workspaces</SelectLabel>
140+ {loaderData.workspaces.map((option) => (
141+ <SelectItem
142+ key={option.slug}
143+ value={option.slug}
144+ icon={<Avatar name={option.name} image={option.avatar} size={18} square />}
145+ description={`g1t.sh/${option.slug} · ${ROLE_LABELS[option.role] ?? option.role}`}
146+ >
147+ {option.name}
148+ </SelectItem>
149+ ))}
150+ </SelectGroup>
151+ </SelectContent>
152+ </Select>
153+ <InputAddon className="border-l border-line px-2 font-mono text-base text-faint">/</InputAddon>
154+ <Input
155+ id="name"
156+ name="name"
157+ required
158+ autoFocus
159+ maxLength={100}
160+ placeholder="my-app"
161+ value={name}
162+ onChange={(event) => setName(event.target.value)}
163+ className="pl-1 font-mono"
164+ />
165+ </InputGroup>
166+ <FieldDescription>
167+ The project and its repository share it:{" "}
168+ <span className="font-mono text-muted">
169+ g1t.sh/{workspace}/<span className={name ? "text-fg" : undefined}>{name || "name"}</span>
170+ </span>
171+ </FieldDescription>
148172 </Field>
149− <Field label="Description (optional)">
150− <Input name="description" maxLength={200} placeholder="What it is, in a line" />
173+
174+ <Field>
175+ <FieldLabel htmlFor="description">
176+ Description <span className="font-normal text-faint">(optional)</span>
177+ </FieldLabel>
178+ <Input id="description" name="description" maxLength={200} placeholder="What it is, in a line" />
151179 </Field>
152− <fieldset className="space-y-2 text-sm">
153− <legend className="mb-2 font-medium text-muted">Who can see it</legend>
154− <label className="flex items-center gap-2">
155− <input type="radio" name="visibility" value="public" defaultChecked className="accent-accent" />
156− Public: anyone can see its code and open issues
157− </label>
158− <label className="flex items-center gap-2">
159− <input type="radio" name="visibility" value="private" className="accent-accent" />
160− Private: only members of the workspace
161− </label>
162− </fieldset>
180+
181+ <FieldSet>
182+ <FieldLegend>Who can see it</FieldLegend>
183+ <RadioGroup name="visibility" defaultValue="public" aria-label="Who can see it" className="gap-3 sm:grid-cols-2">
184+ <RadioCard
185+ value="public"
186+ icon={<Globe />}
187+ title="Public"
188+ description="Anyone can see its code and open issues."
189+ />
190+ <RadioCard
191+ value="private"
192+ icon={<Lock />}
193+ title="Private"
194+ description="Only members of the workspace."
195+ />
196+ </RadioGroup>
197+ </FieldSet>
198+
163199 <ErrorText>{actionData?.error}</ErrorText>
164200 <Button type="submit" variant="accent" disabled={busy}>
165201 Create project
+22−11
99 import { page } from "../../lib/meta";
1010 import { Notes, StatusIcon, duration, shortRef } from "../../components/actions";
1111 import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
12+import { CheckboxOption } from "../../components/ui/checkbox";
13+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
1214 import { actions } from "../../lib/services.server";
1315 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
1416
124126 const control = "w-full rounded-md border border-line bg-bg px-2.5 py-1.5 text-sm outline-none focus:border-accent-dim";
125127 if (spec.type === "boolean") {
126128 return (
127− <label className="flex items-center gap-2 text-sm">
128− <input type="checkbox" name={`input.${name}`} defaultChecked={spec.default === true || spec.default === "true"} />
129− {spec.description || name}
130− </label>
129+ <CheckboxOption
130+ name={`input.${name}`}
131+ defaultChecked={spec.default === true || spec.default === "true"}
132+ label={spec.description || name}
133+ />
131134 );
132135 }
133136 if (spec.type === "choice" && spec.options) {
134137 return (
135− <label className="block">
138+ <div>
136139 {label}
137− <select name={`input.${name}`} defaultValue={String(spec.default ?? spec.options[0] ?? "")} className={control}>
138− {spec.options.map((option) => (
139− <option key={option}>{option}</option>
140− ))}
141− </select>
142− </label>
140+ <Select name={`input.${name}`} defaultValue={String(spec.default ?? spec.options[0] ?? "") || undefined}>
141+ <SelectTrigger size="sm" aria-label={spec.description || name}>
142+ <SelectValue placeholder="Choose" />
143+ </SelectTrigger>
144+ <SelectContent>
145+ {/* A Radix item cannot have the value "". */}
146+ {spec.options.filter((option) => option !== "").map((option) => (
147+ <SelectItem key={option} value={option}>
148+ {option}
149+ </SelectItem>
150+ ))}
151+ </SelectContent>
152+ </Select>
153+ </div>
143154 );
144155 }
145156 return (
+3−8
66 import type { Route } from "./+types/issue-new";
77 import { page } from "../../lib/meta";
88 import { Button, ErrorText, Field, Input, Textarea } from "../../components/ui";
9+import { CheckboxOption } from "../../components/ui/checkbox";
910 import { Label } from "../../components/work";
1011 import { integrations, work } from "../../lib/services.server";
1112 import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
7980 <div className="min-w-56 grow">
8081 <Input name="reference" required placeholder="TECH-1234" aria-label="Ticket key or address" />
8182 </div>
82− <label className="flex items-center gap-1.5 text-sm text-muted">
83− <input type="checkbox" name="assign" className="accent-accent" />
84− Put an agent on it
85− </label>
83+ <CheckboxOption name="assign" label="Put an agent on it" className="items-center" labelClassName="text-muted" />
8684 <Button type="submit" variant="quiet" disabled={busy}>
8785 Import
8886 </Button>
114112 <legend className="mb-1.5 text-sm font-medium text-muted">Labels</legend>
115113 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
116114 {loaderData.labels.map((name) => (
117− <label key={name} className="flex cursor-pointer items-center gap-1.5">
118− <input type="checkbox" name="label" value={name} className="accent-accent" />
119− <Label name={name} />
120− </label>
115+ <CheckboxOption key={name} name="label" value={name} label={<Label name={name} />} className="items-center gap-1.5" />
121116 ))}
122117 </div>
123118 <div className="mt-2">
+9−10
1818 Textarea,
1919 TimeAgo,
2020 } from "../../components/ui";
21+import { CheckboxOption } from "../../components/ui/checkbox";
2122 import { CheckBadge } from "../../components/checks";
2223 import {
2324 Assignee,
609610 <input type="hidden" name="action" value="labels" />
610611 <div className="flex flex-wrap gap-x-3 gap-y-2">
611612 {labels.map((name) => (
612− <label key={name} className="flex cursor-pointer items-center gap-1.5">
613− <input
614− type="checkbox"
615− name="label"
616− value={name}
617− defaultChecked={issue.labels.includes(name)}
618− className="accent-accent"
619− />
620− <Label name={name} />
621− </label>
613+ <CheckboxOption
614+ key={name}
615+ name="label"
616+ value={name}
617+ defaultChecked={issue.labels.includes(name)}
618+ label={<Label name={name} />}
619+ className="items-center gap-1.5"
620+ />
622621 ))}
623622 </div>
624623 <Input name="labels" placeholder="New labels, comma separated" />
+4−4
55 import type { Route } from "./+types/issues";
66 import { page } from "../../lib/meta";
77 import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
8+import { Checkbox } from "../../components/ui/checkbox";
89 import {
910 Assignee,
1011 AssigneeStack,
153154 {issues.map((issue) => (
154155 <li key={issue.id} className="flex items-start transition-colors hover:bg-surface">
155156 {assignable && (
156− <input
157− type="checkbox"
157+ <Checkbox
158158 name="issue"
159− value={issue.number}
159+ value={String(issue.number)}
160160 // Already being worked on: nothing more to hand over.
161161 disabled={issue.agent != null || issue.queued}
162162 aria-label={`Select issue #${issue.number}`}
163− className="mt-4 ml-4 accent-accent disabled:opacity-30"
163+ className="mt-4 ml-4 disabled:opacity-30"
164164 />
165165 )}
166166 <Link
+4−4
1010 import { Activity, Exchanges } from "../../components/activity";
1111 import { Outcome } from "../../components/outcome";
1212 import { Button, ErrorText, TimeAgo } from "../../components/ui";
13+import { Checkbox } from "../../components/ui/checkbox";
1314 import { Label } from "../../components/work";
1415 import { billing, events, identity, work } from "../../lib/services.server";
1516 import {
185186 className="flex gap-3 rounded-xl border border-line bg-surface p-4"
186187 >
187188 {plan.status === "ready" ? (
188− <input
189− type="checkbox"
189+ <Checkbox
190190 name="keep"
191− value={position}
191+ value={String(position)}
192192 defaultChecked
193193 aria-label={`Open issue ${position}`}
194− className="mt-1.5 accent-accent"
194+ className="mt-1"
195195 />
196196 ) : (
197197 <span className="mt-0.5 w-5 shrink-0 text-center font-mono text-sm text-faint">
+7−9
33
44 import type { Route } from "./+types/pull-new";
55 import { page } from "../../lib/meta";
6+import { Combobox } from "../../components/ui/combobox";
67 import {
78 Button,
89 CopyLine,
9394 <div className="flex flex-wrap items-center gap-2 rounded-xl border border-line bg-surface px-4 py-3 text-sm">
9495 <GitBranch size={15} className="text-faint" />
9596 <span className="text-muted">Merge</span>
96− <select
97+ <Combobox
9798 name="branch"
9899 defaultValue={branches.includes(selected) ? selected : branches[0]}
99100 aria-label="Branch to merge"
100− className="rounded-md border border-line bg-bg px-2 py-1 font-mono text-sm"
101− >
102− {branches.map((name) => (
103− <option key={name} value={name}>
104− {name}
105− </option>
106− ))}
107− </select>
101+ searchPlaceholder="Find a branch"
102+ emptyText="No branch by that name."
103+ options={branches.map((name) => ({ value: name, label: name, icon: <GitBranch /> }))}
104+ className="h-8 w-auto max-w-full min-w-40 font-mono"
105+ />
108106 <span className="text-muted">into</span>
109107 <span className="font-mono">{defaultBranch}</span>
110108 </div>
+11−19
5050 Textarea,
5151 TimeAgo,
5252 } from "../../components/ui";
53+import { CheckboxOption } from "../../components/ui/checkbox";
5354 import { WorkflowStatuses } from "../../components/actions";
5455 import { ChecksPanel } from "../../components/checks";
5556 import {
850851 {canMerge && !landing && lifecycle?.stage !== "queued" && (
851852 <Form method="post" className="space-y-3 px-4 py-3">
852853 {issue?.state === "open" && (
853− <label className="flex items-start gap-2 text-xs text-muted">
854− <input
855− type="checkbox"
856− name="keepIssueOpen"
857− className="mt-0.5 accent-accent"
858− />
859− <span>Keep #{issue.number} open. This is only part of the work.</span>
860− </label>
854+ <CheckboxOption
855+ name="keepIssueOpen"
856+ label={`Keep #${issue.number} open. This is only part of the work.`}
857+ labelClassName="text-xs text-muted"
858+ />
861859 )}
862860 {unchecked && canIgnoreChecks && (
863− <label className="flex items-start gap-2 text-xs text-muted">
864− <input
865− type="checkbox"
866− name="ignoreChecks"
867− className="mt-0.5 accent-accent"
868− />
869− <span>
870− Merge although the checks{" "}
871− {checking ? "have not finished" : "did not pass"}.
872− </span>
873− </label>
861+ <CheckboxOption
862+ name="ignoreChecks"
863+ label={`Merge although the checks ${checking ? "have not finished" : "did not pass"}.`}
864+ labelClassName="text-xs text-muted"
865+ />
874866 )}
875867 <div className="flex flex-wrap items-center gap-3">
876868 <Button
+19−8
66 import type { Route } from "./+types/settings-dependencies";
77 import { page } from "../../lib/meta";
88 import { RepoSettingsTabs } from "../../components/repo-settings-tabs";
9−import { Button, EmptyState, ErrorText, Field, Input } from "../../components/ui";
9+import { Avatar, Button, EmptyState, ErrorText, Field, Input } from "../../components/ui";
10+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
1011 import { projects } from "../../lib/services.server";
1112 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
1213
121122 <Form method="post" className="mt-4 grid items-end gap-3 rounded-xl border border-line bg-surface p-4 sm:grid-cols-[1fr_1fr_auto]">
122123 <input type="hidden" name="intent" value="add" />
123124 <Field label="Project">
124− <select name="on" required className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm">
125− {choices.map((project) => (
126− <option key={project.slug} value={project.slug}>
127− {project.name}
128− </option>
129− ))}
130− </select>
125+ <Select name="on" required defaultValue={choices[0].slug}>
126+ <SelectTrigger aria-label="Project" className="h-auto py-2">
127+ <SelectValue />
128+ </SelectTrigger>
129+ <SelectContent>
130+ {choices.map((project) => (
131+ <SelectItem
132+ key={project.slug}
133+ value={project.slug}
134+ icon={<Avatar name={project.name} size={16} square />}
135+ description={project.slug !== project.name ? project.slug : undefined}
136+ >
137+ {project.name}
138+ </SelectItem>
139+ ))}
140+ </SelectContent>
141+ </Select>
131142 </Field>
132143 <Field label="Its address as">
133144 <Input name="as" placeholder="API_URL" className="font-mono" />
+4−7
44 import { page } from "../../lib/meta";
55 import { RepoSettingsTabs } from "../../components/repo-settings-tabs";
66 import { Button, ErrorText, Field, Input } from "../../components/ui";
7+import { SwitchCard } from "../../components/ui/switch";
78 import { deployments } from "../../lib/services.server";
89 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
910
4344
4445 function Check({ name, on, title, children }: { name: string; on: boolean; title: string; children: string }) {
4546 return (
46− <label className="flex cursor-pointer items-start gap-3 rounded-xl border border-line bg-surface p-4 hover:border-line-strong">
47− <input type="checkbox" name={name} defaultChecked={on} className="mt-1 accent-accent" />
48− <span>
49− <span className="block text-sm font-medium">{title}</span>
50− <span className="mt-1 block text-sm text-muted">{children}</span>
51− </span>
52− </label>
47+ <SwitchCard name={name} defaultChecked={on} title={title}>
48+ {children}
49+ </SwitchCard>
5350 );
5451 }
5552
+29−46
1+import { Globe, Lock } from "lucide-react";
12 import type { ReactNode } from "react";
23 import { Form, data, useNavigation } from "react-router";
34
56 import type { Route } from "./+types/settings-repository";
67 import { page } from "../../lib/meta";
78 import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
9+import { RadioCard, RadioGroup } from "../../components/ui/radio-group";
10+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
11+import { SwitchCard } from "../../components/ui/switch";
812 import { repos, work } from "../../lib/services.server";
913 import {
1014 assertSameOrigin,
99103 children: ReactNode;
100104 }) {
101105 return (
102− <label className="flex cursor-pointer items-start gap-3 rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
103− <input type="checkbox" name={name} defaultChecked={on} className="mt-1 accent-accent" />
104− <span>
105− <span className="block text-sm font-medium">{title}</span>
106− <span className="mt-1 block text-sm text-muted">{children}</span>
107− </span>
108− </label>
106+ <SwitchCard name={name} defaultChecked={on} title={title}>
107+ {children}
108+ </SwitchCard>
109109 );
110110 }
111111
129129 <p className="text-sm font-medium">{title}</p>
130130 <p className="mt-1 text-sm text-muted">{children}</p>
131131 </div>
132− <select
133− name={name}
134− defaultValue={value}
135− aria-label={title}
136− className="shrink-0 rounded-md border border-line bg-bg px-2 py-1.5 text-sm"
137− >
138− {options.map(([option, label]) => (
139− <option key={option} value={option}>
140− {label}
141− </option>
142− ))}
143− </select>
132+ <Select name={name} defaultValue={String(value)}>
133+ <SelectTrigger size="sm" aria-label={title} className="w-auto min-w-28 shrink-0">
134+ <SelectValue />
135+ </SelectTrigger>
136+ <SelectContent align="end">
137+ {options.map(([option, label]) => (
138+ <SelectItem key={option} value={String(option)}>
139+ {label}
140+ </SelectItem>
141+ ))}
142+ </SelectContent>
143+ </Select>
144144 </div>
145145 );
146146 }
157157 <Field label="Description">
158158 <Input name="description" maxLength={200} defaultValue={repo.description ?? ""} />
159159 </Field>
160− <fieldset className="space-y-2 rounded-xl border border-line bg-surface p-4 text-sm">
160+ <fieldset>
161161 <legend className="sr-only">Visibility</legend>
162− <label className="flex items-start gap-3">
163− <input
164− type="radio"
165− name="visibility"
166− value="public"
167− defaultChecked={!repo.isPrivate}
168− className="mt-1 accent-accent"
169− />
170− <span>
171− <span className="block font-medium">Public</span>
172− <span className="text-muted">Anyone can see and clone it.</span>
173− </span>
174− </label>
175− <label className="flex items-start gap-3">
176− <input
177− type="radio"
178− name="visibility"
179− value="private"
180− defaultChecked={repo.isPrivate}
181− className="mt-1 accent-accent"
182− />
183− <span>
184− <span className="block font-medium">Private</span>
185− <span className="text-muted">Only members of the workspace can see it.</span>
186− </span>
187− </label>
162+ <RadioGroup
163+ name="visibility"
164+ defaultValue={repo.isPrivate ? "private" : "public"}
165+ aria-label="Visibility"
166+ className="gap-3 sm:grid-cols-2"
167+ >
168+ <RadioCard value="public" icon={<Globe />} title="Public" description="Anyone can see and clone it." />
169+ <RadioCard value="private" icon={<Lock />} title="Private" description="Only members of the workspace can see it." />
170+ </RadioGroup>
188171 </fieldset>
189172 </Section>
190173
+24−0
33
44 import type { Route } from "./+types/settings";
55 import { page } from "../lib/meta";
6+import { AvatarField } from "../components/avatar-field";
67 import { Button, ErrorText, Field, Input, TimeAgo } from "../components/ui";
8+import { readAvatarUpload } from "../lib/avatar-upload";
79 import { assertSameOrigin, requireUser } from "../lib/session.server";
810
911 export function meta(args: Route.MetaArgs) {
2729 const id = String(form.get("id") ?? "");
2830
2931 switch (form.get("intent")) {
32+ // The picture: identity checks the image's bytes again.
33+ case "avatar": {
34+ const upload = await readAvatarUpload(form);
35+ if ("error" in upload) return { avatarError: upload.error };
36+ const result = await identity.setUserAvatar(user, upload.image);
37+ return result.ok ? null : { avatarError: result.error.message };
38+ }
39+ case "remove-avatar": {
40+ const result = await identity.setUserAvatar(user, null);
41+ return result.ok ? null : { avatarError: result.error.message };
42+ }
3043 case "add-key": {
3144 const result = await identity.addSshKey(
3245 user,
89102 </p>
90103 </header>
91104 <div className="max-w-2xl space-y-12">
105+ <section id="picture" className="scroll-mt-20">
106+ <h2 className="font-medium">Picture</h2>
107+ <div className="mt-4">
108+ <AvatarField
109+ name={user.username}
110+ image={user.avatar}
111+ error={actionData && "avatarError" in actionData ? actionData.avatarError : undefined}
112+ about="Shown beside your username in the sidebar and on mission control. Without one, g1t draws your first letter."
113+ />
114+ </div>
115+ </section>
92116 <section id="ssh-keys" className="scroll-mt-20">
93117 <h2 className="font-medium">SSH keys</h2>
94118 <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden">
+9−8
1313
1414 import type { Route } from "./+types/billing";
1515 import { page } from "../../lib/meta";
16+import { RadioGroup, RadioGroupItem } from "../../components/ui/radio-group";
1617 import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
1718 import { billing, deployments } from "../../lib/services.server";
1819 import {
636637 <Form method="post" className="mt-5 border-t border-line pt-4">
637638 <fieldset>
638639 <legend className="text-xs text-muted">Your monthly spend limit</legend>
639− <div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
640− <label className="flex items-center gap-2">
641− <input type="radio" name="mode" value="automatic" defaultChecked={mode === "automatic"} className="accent-accent" />
640+ <RadioGroup name="mode" defaultValue={mode} className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
641+ <label className="flex cursor-pointer items-center gap-2">
642+ <RadioGroupItem value="automatic" />
642643 Automatic
643644 </label>
644− <label className="flex items-center gap-2">
645− <input type="radio" name="mode" value="fixed" defaultChecked={mode === "fixed"} className="accent-accent" />
645+ <label className="flex cursor-pointer items-center gap-2">
646+ <RadioGroupItem value="fixed" />
646647 Fixed at
647648 <span className="flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent">
648649 <span className="text-muted">$</span>
657658 />
658659 </span>
659660 </label>
660− <label className="flex items-center gap-2">
661− <input type="radio" name="mode" value="none" defaultChecked={mode === "none"} className="accent-accent" />
661+ <label className="flex cursor-pointer items-center gap-2">
662+ <RadioGroupItem value="none" />
662663 None
663664 </label>
664665 <Button variant="quiet" type="submit" name="intent" value="spend-limit" disabled={busy}>
665666 Save
666667 </Button>
667− </div>
668+ </RadioGroup>
668669 </fieldset>
669670 <ErrorText>{error}</ErrorText>
670671 <p className="mt-3 text-xs text-faint">
+23−21
2525 import type { Route } from "./+types/integrations";
2626 import { page } from "../../lib/meta";
2727 import { MODEL_CATALOG, ModelCatalog, ModelProviderFields, ProviderMark, ProviderTiles, Routing } from "../../components/model-providers";
28−import { Button, CopyLine, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
28+import { Avatar, Button, CopyLine, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
29+import { CheckboxOption } from "../../components/ui/checkbox";
30+import { Combobox } from "../../components/ui/combobox";
2931 import { billing, integrations, repos } from "../../lib/services.server";
3032 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
3133
487489 function RepoField({ repos, required, hint }: { repos: string[]; required?: boolean; hint: string }) {
488490 return (
489491 <Field label="Repository" hint={hint}>
490− <select
492+ <Combobox
491493 name="repo"
492− required={required}
493494 defaultValue={repos[0] ?? ""}
494− className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim"
495− >
496− {!required && <option value="">None</option>}
497− {repos.map((repo) => (
498− <option key={repo} value={repo}>
499− {repo}
500− </option>
501− ))}
502− </select>
495+ aria-label="Repository"
496+ placeholder={repos.length === 0 ? "No repositories yet" : "Choose a repository"}
497+ searchPlaceholder="Find a repository"
498+ emptyText="No repository by that name."
499+ options={[
500+ ...(required ? [] : [{ value: "", label: "None" }]),
501+ ...repos.map((repo) => ({ value: repo, label: repo, icon: <Avatar name={repo.split("/").pop() ?? repo} size={16} square /> })),
502+ ]}
503+ className="font-mono text-[0.8125rem]"
504+ />
503505 </Field>
504506 );
505507 }
506508
507509 function AssignField() {
508510 return (
509− <label className="flex items-start gap-2.5 text-sm">
510− <input type="checkbox" name="assign" className="mt-1 accent-[var(--color-accent)]" />
511− <span>
512− <span className="font-medium">Put a g1t agent on each new issue</span>
513− <span className="block text-xs text-muted">
514− It opens a pull request, gets reviewed, and lands through your merge rules, before anyone has
515− to look. Agents run only where g1t agents are enabled.
511+ <CheckboxOption
512+ name="assign"
513+ label={<span className="font-medium">Put a g1t agent on each new issue</span>}
514+ description={
515+ <span className="text-muted">
516+ It opens a pull request, gets reviewed, and lands through your merge rules, before anyone has to look.
517+ Agents run only where g1t agents are enabled.
516518 </span>
517− </span>
518− </label>
519+ }
520+ />
519521 );
520522 }
521523
+2−2
1919
2020 /** A workspace's own pages, each with its title and what it is for. */
2121 const PAGES: Record<string, { title: string; about: string }> = {
22− settings: { title: "General", about: "The workspace's name, address and description." },
22+ settings: { title: "General", about: "The workspace's name, icon, address and description." },
2323 people: {
2424 title: "Members",
2525 about: "Members create repositories, push, manage issues and merge pull requests. Owners also manage members, tokens, billing and integrations.",
6868 <div className="border-b border-line bg-surface/60">
6969 <div className="mx-auto max-w-6xl px-4 pt-8">
7070 <div className="flex flex-wrap items-center gap-4">
71− <Avatar name={workspace.slug} size={52} square />
71+ <Avatar name={workspace.slug} image={workspace.avatar} size={52} square />
7272 <div className="min-w-0 grow">
7373 <div className="flex flex-wrap items-center gap-2.5">
7474 <h1 className="truncate text-2xl font-semibold tracking-tight">
+53−21
22
33 import type { Route } from "./+types/settings";
44 import { page } from "../../lib/meta";
5+import { AvatarField } from "../../components/avatar-field";
56 import { Button, ErrorText, Field, Input } from "../../components/ui";
7+import { readAvatarUpload } from "../../lib/avatar-upload";
68 import { identity } from "../../lib/services.server";
79 import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
810
2426 assertSameOrigin(request);
2527 const user = requireUser(context, request);
2628 const form = await request.formData();
29+ const intent = form.get("intent");
30+ // The icon: identity checks the owner and the image's bytes again.
31+ if (intent === "avatar" || intent === "remove-avatar") {
32+ let image: string | null = null;
33+ if (intent === "avatar") {
34+ const upload = await readAvatarUpload(form);
35+ if ("error" in upload) return { avatarError: upload.error };
36+ image = upload.image;
37+ }
38+ const result = await identity.setWorkspaceAvatar(user, params.owner, image);
39+ if (!result.ok) return { avatarError: result.error.message };
40+ return { saved: "avatar" as const };
41+ }
2742 const result = await identity.updateWorkspace(user, params.owner, {
2843 name: String(form.get("displayName") ?? ""),
2944 description: String(form.get("description") ?? ""),
3550 export default function WorkspaceSettings({ loaderData, actionData }: Route.ComponentProps) {
3651 const { workspace } = loaderData;
3752 return (
38− <div className="max-w-lg">
39− <h2 className="font-medium">Workspace details</h2>
40− <Form method="post" className="mt-5 space-y-4">
41− <Field label="Display name">
42− <Input name="displayName" maxLength={80} defaultValue={workspace.name} />
43− </Field>
44− <Field label="Description" hint="One line, shown at the top of the workspace's page.">
45− <Input
46− name="description"
47− maxLength={160}
48− defaultValue={workspace.description ?? ""}
53+ <div className="max-w-lg space-y-10">
54+ <section>
55+ <h2 className="font-medium">Icon</h2>
56+ <div className="mt-4">
57+ <AvatarField
58+ name={workspace.slug}
59+ image={workspace.avatar}
60+ square
61+ error={actionData && "avatarError" in actionData ? actionData.avatarError : undefined}
62+ about="Shown beside the workspace's name everywhere on g1t, and on its link previews. Without one, g1t draws its first letter."
4963 />
50− </Field>
51− <Field
52− label="Name in URLs"
53− hint="This cannot be changed: repository addresses and clones depend on it."
54− >
55− <p className="font-mono text-sm text-muted">g1t.sh/{workspace.slug}</p>
56− </Field>
57− <ErrorText>{actionData?.error}</ErrorText>
58− <Button type="submit">Save</Button>
59− </Form>
64+ </div>
65+ </section>
66+
67+ <section>
68+ <h2 className="font-medium">Workspace details</h2>
69+ <Form method="post" className="mt-5 space-y-4">
70+ <Field
71+ label="Display name"
72+ hint="How people see the workspace: in the sidebar, at the top of its page and in link previews. Up to 80 characters; spaces and capitals are fine."
73+ >
74+ <Input name="displayName" maxLength={80} defaultValue={workspace.name} placeholder={workspace.slug} />
75+ </Field>
76+ <Field label="Description" hint="One line, shown at the top of the workspace's page.">
77+ <Input name="description" maxLength={160} defaultValue={workspace.description ?? ""} />
78+ </Field>
79+ <ErrorText>{actionData && "error" in actionData ? actionData.error : undefined}</ErrorText>
80+ <Button type="submit">Save</Button>
81+ </Form>
82+ </section>
83+
84+ <section>
85+ <h2 className="font-medium">Address</h2>
86+ <p className="mt-3 font-mono text-sm text-fg">g1t.sh/{workspace.slug}</p>
87+ <p className="mt-1.5 text-xs text-faint">
88+ The workspace's slug: lowercase, in every repository's address and every clone URL. It cannot be
89+ changed, so links and clones keep working. Change the display name instead.
90+ </p>
91+ </section>
6092 </div>
6193 );
6294 }
+1−0
1717 "@g1t/theme": "*",
1818 "class-variance-authority": "^0.7.1",
1919 "clsx": "^2.1.1",
20+ "cmdk": "^1.1.1",
2021 "isbot": "^5.1.36",
2122 "lucide-react": "^1.49.0",
2223 "mdast-util-find-and-replace": "^3.0.2",
+40−0
66 );
77
88 const GIT_PATH = /\/(info\/refs|git-upload-pack|git-receive-pack)$/;
9+/** An uploaded avatar, by the SHA-256 of its bytes. */
10+const AVATAR_PATH = /^\/avatars\/([0-9a-f]{64})$/;
11+/** The only types identity stores, having checked each image's bytes. */
12+const AVATAR_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
913 const DOCS = "https://docs.g1t.sh";
1014
1115 /** Where the documentation pages that used to live under /docs are now. */
2731 if (GIT_PATH.test(pathname)) {
2832 return env.REPOS.fetch(request);
2933 }
34+ const avatar = AVATAR_PATH.exec(pathname);
35+ if (avatar) {
36+ return serveAvatar(env, avatar[1], request.method);
37+ }
3038 // The documentation is its own site.
3139 if (pathname === "/docs" || pathname.startsWith("/docs/")) {
3240 const page = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
3644 return requestHandler(request);
3745 },
3846 } satisfies ExportedHandler<Env>;
47+
48+/**
49+ * An uploaded avatar. Its address is its hash, so it never changes and is
50+ * kept for good. It is served as nothing but an image: the stored type,
51+ * no sniffing, and a policy that lets nothing in it run.
52+ */
53+async function serveAvatar(env: Env, hash: string, method: string): Promise<Response> {
54+ if (method !== "GET" && method !== "HEAD") {
55+ return new Response("Method not allowed", { status: 405, headers: { allow: "GET, HEAD" } });
56+ }
57+ const { value, metadata } = await env.AVATARS.getWithMetadata<{ contentType?: string }>(hash, {
58+ type: "arrayBuffer",
59+ cacheTtl: 86400,
60+ });
61+ const contentType = metadata?.contentType;
62+ if (!value || !contentType || !AVATAR_TYPES.has(contentType)) {
63+ return new Response("Not found", {
64+ status: 404,
65+ headers: { "cache-control": "public, max-age=60" },
66+ });
67+ }
68+ return new Response(method === "HEAD" ? null : value, {
69+ headers: {
70+ "content-type": contentType,
71+ "content-length": String(value.byteLength),
72+ "cache-control": "public, max-age=31536000, immutable",
73+ "x-content-type-options": "nosniff",
74+ "content-security-policy": "default-src 'none'; sandbox",
75+ "cross-origin-resource-policy": "cross-origin",
76+ },
77+ });
78+}
+2−0
1616 DEPLOYMENTS: ServiceBinding;
1717 PROJECTS: ServiceBinding;
1818 BLOBS: KVNamespace;
19+ /** Uploaded avatars by SHA-256, with `{ contentType }`; written by identity. */
20+ AVATARS: KVNamespace;
1921 }
2022 }
2123 interface Env extends Cloudflare.Env {}
+6−1
1010 "routes": [{ "pattern": "g1t.sh", "custom_domain": true }],
1111 // The site holds no data of its own; everything goes through services.
1212 // GitHub Actions artifacts, as the API keeps them, for download from a run.
13− "kv_namespaces": [{ "binding": "BLOBS", "id": "16a4232cb746418db53782aa068be693" }],
13+ // Uploaded avatars (g1t-avatars), served at /avatars/<sha256>. Read
14+ // only here: the identity service checks and writes them.
15+ "kv_namespaces": [
16+ { "binding": "BLOBS", "id": "16a4232cb746418db53782aa068be693" },
17+ { "binding": "AVATARS", "id": "e627b571f07047e187c03e1fc2b3bbdd" }
18+ ],
1419 "services": [
1520 { "binding": "IDENTITY", "service": "g1t-identity" },
1621 { "binding": "REPOS", "service": "g1t-repos" },
+25−0
242242 /// RFC 3339.
243243 pub created_at: String,
244244 pub member_count: u32,
245+ /// The workspace's uploaded icon: the SHA-256 of its bytes, served at
246+ /// `/avatars/<avatar>`. Null means the generated letter avatar.
247+ #[serde(default)]
248+ pub avatar: Option<String>,
245249 }
246250
247251 #[derive(Clone, Debug, Serialize, Deserialize)]
291295 pub description: String,
292296 }
293297
298+/// `set_workspace_avatar`: owners only. `image` is the file's bytes in
299+/// base64: PNG, JPEG, WebP or GIF, at most `MAX_AVATAR_BYTES`. Null removes
300+/// the icon. Returns `Outcome<Workspace>`.
301+#[derive(Debug, Serialize, Deserialize)]
302+pub struct SetWorkspaceAvatarArgs {
303+ pub actor: User,
304+ pub slug: String,
305+ pub image: Option<String>,
306+}
307+
308+/// `set_user_avatar`: a person's own avatar, as `SetWorkspaceAvatarArgs`.
309+/// Returns `Outcome<Option<String>>`: the new avatar, or null once removed.
310+#[derive(Debug, Serialize, Deserialize)]
311+pub struct SetUserAvatarArgs {
312+ pub user: User,
313+ pub image: Option<String>,
314+}
315+
316+/// The largest avatar that can be uploaded, in bytes.
317+pub const MAX_AVATAR_BYTES: usize = 1024 * 1024;
318+
294319 /// `list_workspace_tokens`: members only. Returns
295320 /// `Outcome<Vec<AccessToken>>`.
296321 #[derive(Debug, Serialize, Deserialize)]
+25−0
4040 /// The workspace's name in URLs: `g1t.sh/<slug>`.
4141 pub slug: String,
4242 pub role: Role,
43+ /// The workspace's display name, for showing it to people. Set when a
44+ /// user is resolved from credentials; absent on principals made up by
45+ /// a service.
46+ #[serde(default, skip_serializing_if = "Option::is_none")]
47+ pub name: Option<String>,
48+ /// The workspace's uploaded icon: the SHA-256 of its bytes, served at
49+ /// `/avatars/<avatar>`. Absent means the generated letter avatar.
50+ #[serde(default, skip_serializing_if = "Option::is_none")]
51+ pub avatar: Option<String>,
4352 }
4453
54+impl Membership {
55+ /// A plain member of `slug`, as services act inside one workspace.
56+ pub fn member(slug: impl Into<String>) -> Self {
57+ Membership {
58+ slug: slug.into(),
59+ role: Role::Member,
60+ name: None,
61+ avatar: None,
62+ }
63+ }
64+}
65+
4566 /// What a set of credentials resolved to.
4667 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
4768 #[serde(rename_all = "lowercase")]
7394 /// resolved from credentials, so any service can authorize from it.
7495 #[serde(default)]
7596 pub workspaces: Vec<Membership>,
97+ /// The person's uploaded avatar: the SHA-256 of its bytes, served at
98+ /// `/avatars/<avatar>`. Absent means the generated letter avatar.
99+ #[serde(default, skip_serializing_if = "Option::is_none")]
100+ pub avatar: Option<String>,
76101 }
77102
78103 impl User {
+1−0
1414 "issues",
1515 "oauth",
1616 "assets",
17+ "avatars",
1718 "docs",
1819 "explore",
1920 "g1t",
+20−3
6060 "@g1t/theme": "*",
6161 "class-variance-authority": "^0.7.1",
6262 "clsx": "^2.1.1",
63+ "cmdk": "^1.1.1",
6364 "isbot": "^5.1.36",
6465 "lucide-react": "^1.49.0",
6566 "mdast-util-find-and-replace": "^3.0.2",
52825283 "node": ">=6"
52835284 }
52845285 },
5286+ "node_modules/cmdk": {
5287+ "version": "1.1.1",
5288+ "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
5289+ "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
5290+ "license": "MIT",
5291+ "dependencies": {
5292+ "@radix-ui/react-compose-refs": "^1.1.1",
5293+ "@radix-ui/react-dialog": "^1.1.6",
5294+ "@radix-ui/react-id": "^1.1.0",
5295+ "@radix-ui/react-primitive": "^2.0.2"
5296+ },
5297+ "peerDependencies": {
5298+ "react": "^18 || ^19 || ^19.0.0-rc",
5299+ "react-dom": "^18 || ^19 || ^19.0.0-rc"
5300+ }
5301+ },
52855302 "node_modules/color-name": {
52865303 "version": "1.1.4",
52875304 "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
59175934 }
59185935 },
59195936 "node_modules/fflate": {
5920− "version": "0.7.3",
5921− "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.3.tgz",
5922− "integrity": "sha512-0Zz1jOzJWERhyhsimS54VTqOteCNwRtIlh8isdL0AXLo0g7xNTfTL7oWrkmCnPhZGocKIkWHBistBrrpoNH3aw==",
5937+ "version": "0.7.5",
5938+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.5.tgz",
5939+ "integrity": "sha512-QieYf//cis6ywHNi5qW1+PXPQ4bC+XVJAtS4AXIML8P76GroEiOxm/oQtn1f02UkJY1+KsXMJcC+R2v/Eg4G3g==",
59235940 "license": "MIT"
59245941 },
59255942 "node_modules/find-proc": {
+8−1
22 "name": "g1t-monorepo",
33 "private": true,
44 "type": "module",
5− "workspaces": ["apps/*", "services/*", "packages/*"],
5+ "workspaces": [
6+ "apps/*",
7+ "services/*",
8+ "packages/*"
9+ ],
610 "scripts": {
711 "typecheck": "npm run typecheck --workspaces --if-present",
812 "deploy": "npm run deploy -w @g1t/web"
1014 "devDependencies": {
1115 "typescript": "^5.9.3",
1216 "wrangler": "^4.146.0"
17+ },
18+ "overrides": {
19+ "fflate": "^0.7.5"
1320 }
1421 }
+2−0
6767 call("remove_member", { actor, slug, username }),
6868 updateWorkspace: (actor, slug, details) =>
6969 call("update_workspace", { actor, slug, ...details }),
70+ setWorkspaceAvatar: (actor, slug, image) => call("set_workspace_avatar", { actor, slug, image }),
71+ setUserAvatar: (user, image) => call("set_user_avatar", { user, image }),
7072 listWorkspaceTokens: (slug, viewer) => call("list_workspace_tokens", { slug, viewer }),
7173 createWorkspaceToken: (actor, slug, name) =>
7274 call("create_workspace_token", { actor, slug, name }),
+30−1
2020 * credentials, so any service can authorize from it.
2121 */
2222 workspaces?: Membership[];
23+ /**
24+ * The person's uploaded avatar: the SHA-256 of its bytes, served at
25+ * `/avatars/<avatar>`. Only set on the signed-in person; absent means
26+ * the generated letter avatar.
27+ */
28+ avatar?: string;
2329 };
2430
2531 /** What a member may do: an owner also manages the workspace's members. */
2632 export type Role = "owner" | "member";
2733
28−export type Membership = { slug: string; role: Role };
34+export type Membership = {
35+ /** The workspace's name in URLs: `g1t.sh/<slug>`. */
36+ slug: string;
37+ role: Role;
38+ /** Its display name. Set on users resolved from credentials. */
39+ name?: string;
40+ /** Its uploaded icon, as `Workspace.avatar`. */
41+ avatar?: string;
42+};
2943
44+/** The largest avatar that can be uploaded, in bytes. */
45+export const MAX_AVATAR_BYTES = 1024 * 1024;
46+
3047 /**
3148 * A workspace: the owner of repositories, and the first segment of their
3249 * URLs. A person's own space and a team's are the same thing.
4057 /** RFC 3339. */
4158 createdAt: string;
4259 memberCount: number;
60+ /**
61+ * The workspace's uploaded icon: the SHA-256 of its bytes, served at
62+ * `/avatars/<avatar>`. Null means the generated letter avatar.
63+ */
64+ avatar: string | null;
4365 };
4466
4567 export type Member = { username: string; role: Role };
209231 removeMember(actor: User, slug: string, username: string): Promise<Result<boolean>>;
210232 /** Owners only. An empty name falls back to the slug. */
211233 updateWorkspace(actor: User, slug: string, details: { name: string; description: string }): Promise<Result<Workspace>>;
234+ /**
235+ * Owners only. `image` is the file in base64: PNG, JPEG, WebP or GIF, at
236+ * most `MAX_AVATAR_BYTES`, checked by its bytes. Null removes the icon.
237+ */
238+ setWorkspaceAvatar(actor: User, slug: string, image: string | null): Promise<Result<Workspace>>;
239+ /** A person's own avatar, as `setWorkspaceAvatar`: the new one, or null. */
240+ setUserAvatar(user: User, image: string | null): Promise<Result<string | null>>;
212241
213242 /**
214243 * A workspace's own access tokens. They belong to the workspace, act as
+1−1
66 /** Routes and reserved words that may not be registered as usernames. */
77 const RESERVED = new Set([
88 "api", "mcp", "login", "logout", "register", "new", "settings", "search",
9− "admin", "auth", "pulls", "issues", "verify", "forgot", "reset", "device", "workspaces", "u", "oauth", "assets", "docs", "explore", "g1t", "about", "pricing",
9+ "admin", "auth", "pulls", "issues", "verify", "forgot", "reset", "device", "workspaces", "u", "oauth", "assets", "avatars", "docs", "explore", "g1t", "about", "pricing",
1010 ]);
1111
1212 export function isValidNamespace(value: string): boolean {
+3−5
2525 use g1t_contracts::events::Event;
2626 use g1t_contracts::identity::{SlugArgs, Workspace};
2727 use g1t_contracts::repos::{GetArgs, GetByIdArgs, Repo, RepoPath};
28−use g1t_contracts::{FailureCode, Membership, Outcome, PrincipalKind, Role, User, Viewer};
28+use g1t_contracts::{FailureCode, Membership, Outcome, PrincipalKind, User, Viewer};
2929 use g1t_kit::{args, reply, rpc_method};
3030 use g1t_secrets::Sealer;
3131 use serde::Deserialize;
102102 username: workspace.slug.clone(),
103103 kind: PrincipalKind::Workspace,
104104 verified: true,
105− workspaces: vec![Membership {
106− slug: workspace.slug,
107− role: Role::Member,
108− }],
105+ workspaces: vec![Membership::member(workspace.slug)],
106+ ..User::default()
109107 }))
110108 }
111109
+6−0
1+-- Uploaded icons, as GitHub's organizations and people have. Each is the
2+-- SHA-256 of the image's bytes, which is also its key in the g1t-avatars
3+-- KV namespace, so an address never serves a stale image. Null means the
4+-- generated letter avatar.
5+ALTER TABLE workspaces ADD COLUMN avatar TEXT;
6+ALTER TABLE users ADD COLUMN avatar TEXT;
+256−0
1+//! Uploaded avatars: a workspace's icon and a person's picture.
2+//!
3+//! An image is checked by its bytes, not by what it claims to be: only
4+//! PNG, JPEG, WebP and GIF, which a browser shows as an image and nothing
5+//! else. SVG is refused, since it can carry script. Each is stored in the
6+//! `AVATARS` KV namespace under the SHA-256 of its bytes, with its media
7+//! type as metadata. The hash is also what the workspace or user row holds
8+//! and what the address `/avatars/<hash>` names: an address always means
9+//! the same image, so it can be cached for good. The site serves the
10+//! namespace; only this service writes to it.
11+
12+use base64::Engine;
13+use base64::engine::general_purpose::STANDARD;
14+use g1t_contracts::identity::*;
15+use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role};
16+use sha2::{Digest, Sha256};
17+use worker::wasm_bindgen::JsValue;
18+use worker::Result;
19+
20+use crate::Identity;
21+
22+/// The media type an image's bytes show it to be, or why it is refused.
23+pub fn sniff(bytes: &[u8]) -> std::result::Result<&'static str, &'static str> {
24+ if bytes.is_empty() {
25+ return Err("That file is empty.");
26+ }
27+ if bytes.len() > MAX_AVATAR_BYTES {
28+ return Err("Use an image of at most 1 MB.");
29+ }
30+ if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
31+ Ok("image/png")
32+ } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
33+ Ok("image/jpeg")
34+ } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
35+ Ok("image/gif")
36+ } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
37+ Ok("image/webp")
38+ } else {
39+ Err("Use a PNG, JPEG, WebP or GIF image.")
40+ }
41+}
42+
43+/// An upload decoded and checked: its key and media type.
44+pub struct Checked {
45+ pub key: String,
46+ pub content_type: &'static str,
47+ pub bytes: Vec<u8>,
48+}
49+
50+/// Decodes an uploaded image and checks it.
51+pub fn check(image: &str) -> std::result::Result<Checked, &'static str> {
52+ // Base64 is a third longer; anything far past the limit is refused
53+ // before it is decoded.
54+ if image.len() > MAX_AVATAR_BYTES / 3 * 4 + 8 {
55+ return Err("Use an image of at most 1 MB.");
56+ }
57+ let bytes = STANDARD
58+ .decode(image.trim())
59+ .map_err(|_| "That upload could not be read.")?;
60+ let content_type = sniff(&bytes)?;
61+ Ok(Checked {
62+ key: hex::encode(Sha256::digest(&bytes)),
63+ content_type,
64+ bytes,
65+ })
66+}
67+
68+/// Whether a stored avatar key is well formed: 64 lowercase hex digits.
69+pub fn is_key(key: &str) -> bool {
70+ key.len() == 64 && key.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
71+}
72+
73+/// What is kept beside an avatar's bytes, as the site reads it.
74+#[derive(serde::Serialize)]
75+#[serde(rename_all = "camelCase")]
76+struct Stored {
77+ content_type: &'static str,
78+}
79+
80+impl Identity {
81+ /// Stores a checked image. The same bytes always land on the same
82+ /// key, so storing them again changes nothing.
83+ async fn store_avatar(&self, checked: Checked) -> Result<String> {
84+ self.env
85+ .kv("AVATARS")?
86+ .put_bytes(&checked.key, &checked.bytes)?
87+ .metadata(Stored {
88+ content_type: checked.content_type,
89+ })?
90+ .execute()
91+ .await?;
92+ Ok(checked.key)
93+ }
94+
95+ /// Deletes an avatar no workspace or person uses any more.
96+ async fn forget_avatar(&self, key: Option<String>) -> Result<()> {
97+ let Some(key) = key.filter(|key| is_key(key)) else {
98+ return Ok(());
99+ };
100+ let used = self
101+ .db
102+ .prepare(
103+ "SELECT 1 FROM workspaces WHERE avatar = ?1
104+ UNION ALL SELECT 1 FROM users WHERE avatar = ?1 LIMIT 1",
105+ )
106+ .bind(&[key.as_str().into()])?
107+ .first::<serde_json::Value>(None)
108+ .await?
109+ .is_some();
110+ if !used {
111+ self.env.kv("AVATARS")?.delete(&key).await?;
112+ }
113+ Ok(())
114+ }
115+
116+ /// The avatar a row has now, before it is changed.
117+ async fn current_avatar(&self, sql: &str, param: &str) -> Result<Option<String>> {
118+ #[derive(serde::Deserialize)]
119+ struct Row {
120+ avatar: Option<String>,
121+ }
122+ Ok(self
123+ .db
124+ .prepare(sql)
125+ .bind(&[param.into()])?
126+ .first::<Row>(None)
127+ .await?
128+ .and_then(|row| row.avatar))
129+ }
130+
131+ /// Checks and stores `image`, or does nothing for none; the new key.
132+ async fn upload(&self, image: Option<&str>) -> Result<std::result::Result<Option<String>, &'static str>> {
133+ let Some(image) = image else {
134+ return Ok(Ok(None));
135+ };
136+ match check(image) {
137+ Ok(checked) => Ok(Ok(Some(self.store_avatar(checked).await?))),
138+ Err(message) => Ok(Err(message)),
139+ }
140+ }
141+
142+ pub async fn set_workspace_avatar(&self, a: SetWorkspaceAvatarArgs) -> Result<Outcome<Workspace>> {
143+ let slug = a.slug.to_lowercase();
144+ // Checked here, not only by the page: only an owner, as a person.
145+ if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
146+ return Ok(Outcome::fail(
147+ FailureCode::Forbidden,
148+ "Only an owner can change a workspace's icon.",
149+ ));
150+ }
151+ let previous = self
152+ .current_avatar("SELECT avatar FROM workspaces WHERE slug = ?", &slug)
153+ .await?;
154+ let key = match self.upload(a.image.as_deref()).await? {
155+ Ok(key) => key,
156+ Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
157+ };
158+ self.db
159+ .prepare("UPDATE workspaces SET avatar = ? WHERE slug = ?")
160+ .bind(&[
161+ key.as_deref().map_or(JsValue::NULL, JsValue::from),
162+ slug.as_str().into(),
163+ ])?
164+ .run()
165+ .await?;
166+ if previous != key {
167+ self.forget_avatar(previous).await?;
168+ }
169+ Ok(match self.get_workspace(SlugArgs { slug }).await? {
170+ Some(workspace) => Outcome::Ok(workspace),
171+ None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
172+ })
173+ }
174+
175+ pub async fn set_user_avatar(&self, a: SetUserAvatarArgs) -> Result<Outcome<Option<String>>> {
176+ if a.user.kind != PrincipalKind::User || a.user.id.is_empty() {
177+ return Ok(Outcome::fail(
178+ FailureCode::Forbidden,
179+ "Only a person can change their own picture.",
180+ ));
181+ }
182+ let previous = self
183+ .current_avatar("SELECT avatar FROM users WHERE id = ?", &a.user.id)
184+ .await?;
185+ let key = match self.upload(a.image.as_deref()).await? {
186+ Ok(key) => key,
187+ Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
188+ };
189+ self.db
190+ .prepare("UPDATE users SET avatar = ? WHERE id = ?")
191+ .bind(&[
192+ key.as_deref().map_or(JsValue::NULL, JsValue::from),
193+ a.user.id.as_str().into(),
194+ ])?
195+ .run()
196+ .await?;
197+ if previous != key {
198+ self.forget_avatar(previous).await?;
199+ }
200+ Ok(Outcome::Ok(key))
201+ }
202+}
203+
204+#[cfg(test)]
205+mod tests {
206+ use super::*;
207+
208+ const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR";
209+ const JPEG: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10, b'J', b'F', b'I', b'F'];
210+ const WEBP: &[u8] = b"RIFF\x24\0\0\0WEBPVP8 ";
211+ const GIF: &[u8] = b"GIF89a\x01\0\x01\0";
212+
213+ #[test]
214+ fn knows_each_image_by_its_bytes() {
215+ assert_eq!(sniff(PNG), Ok("image/png"));
216+ assert_eq!(sniff(JPEG), Ok("image/jpeg"));
217+ assert_eq!(sniff(WEBP), Ok("image/webp"));
218+ assert_eq!(sniff(GIF), Ok("image/gif"));
219+ assert_eq!(sniff(b"GIF87a\x01\0"), Ok("image/gif"));
220+ }
221+
222+ #[test]
223+ fn refuses_svg_and_anything_else() {
224+ assert!(sniff(b"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>").is_err());
225+ assert!(sniff(b"<?xml version=\"1.0\"?><svg/>").is_err());
226+ assert!(sniff(b"<html><body>hi</body></html>").is_err());
227+ assert!(sniff(b"RIFF\0\0\0\0WAVEfmt ").is_err());
228+ assert!(sniff(b"\x89PNX").is_err());
229+ assert!(sniff(b"").is_err());
230+ }
231+
232+ #[test]
233+ fn refuses_more_than_a_megabyte() {
234+ let mut big = PNG.to_vec();
235+ big.resize(MAX_AVATAR_BYTES, 0);
236+ assert_eq!(sniff(&big), Ok("image/png"));
237+ big.push(0);
238+ assert!(sniff(&big).is_err());
239+ assert!(check(&STANDARD.encode(&big)).is_err());
240+ }
241+
242+ #[test]
243+ fn keys_an_image_by_its_hash() {
244+ let checked = check(&STANDARD.encode(PNG)).ok().unwrap();
245+ assert_eq!(checked.content_type, "image/png");
246+ assert_eq!(checked.key, hex::encode(Sha256::digest(PNG)));
247+ assert!(is_key(&checked.key));
248+ assert!(!is_key("../etc/passwd"));
249+ assert!(!is_key(&checked.key.to_uppercase()));
250+ }
251+
252+ #[test]
253+ fn refuses_what_is_not_base64() {
254+ assert!(check("not base64!").is_err());
255+ }
256+}
+9−1
44 //! the methods and their arguments.
55
66 mod admin;
7+mod avatars;
78 mod crypto;
89 mod device;
910 mod email;
3233 id: String,
3334 username: String,
3435 verified: u8,
36+ /// Selected only where the person is being shown to themselves.
37+ #[serde(default)]
38+ avatar: Option<String>,
3539 }
3640
3741 impl From<Account> for User {
4044 id: row.id,
4145 username: row.username,
4246 verified: row.verified != 0,
47+ avatar: row.avatar,
4348 ..User::default()
4449 }
4550 }
389394 async fn user_for_session(&self, a: SessionArgs) -> Result<Viewer> {
390395 self.find_user(
391396 &format!(
392− "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified
397+ "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified,
398+ users.avatar
393399 FROM sessions JOIN users ON users.id = sessions.user_id
394400 WHERE sessions.id = ? AND sessions.expires_at > {SQL_NOW}"
395401 ),
540546 "add_member" => reply(&identity.add_member(args(body)?).await?),
541547 "remove_member" => reply(&identity.remove_member(args(body)?).await?),
542548 "update_workspace" => reply(&identity.update_workspace(args(body)?).await?),
549+ "set_workspace_avatar" => reply(&identity.set_workspace_avatar(args(body)?).await?),
550+ "set_user_avatar" => reply(&identity.set_user_avatar(args(body)?).await?),
543551 "list_workspace_tokens" => reply(&identity.list_workspace_tokens(args(body)?).await?),
544552 "create_workspace_token" => reply(&identity.create_workspace_token(args(body)?).await?),
545553 "remove_workspace_token" => reply(&identity.remove_workspace_token(args(body)?).await?),
+4−8
105105 username: AGENT_NAME.to_owned(),
106106 kind: PrincipalKind::Agent,
107107 verified: true,
108− workspaces: vec![Membership {
109− slug: scope.repo.namespace,
110− role: Role::Member,
111− }],
108+ workspaces: vec![Membership::member(scope.repo.namespace)],
109+ ..User::default()
112110 }));
113111 }
114112 let viewer = match (&presented.user_id, &presented.workspace_id) {
143141 username: workspace.slug.clone(),
144142 kind: PrincipalKind::Workspace,
145143 verified: true,
146− workspaces: vec![Membership {
147− slug: workspace.slug,
148− role: Role::Member,
149− }],
144+ workspaces: vec![Membership::member(workspace.slug)],
145+ ..User::default()
150146 }))
151147 }
152148
+8−3
2323 const MAX_DESCRIPTION_LENGTH: usize = 160;
2424
2525 const WORKSPACE_COLUMNS: &str = "workspaces.id, workspaces.slug, workspaces.name,
26− workspaces.description, workspaces.created_at,
26+ workspaces.description, workspaces.avatar, workspaces.created_at,
2727 (SELECT count(*) FROM workspace_members
2828 WHERE workspace_members.workspace_id = workspaces.id) AS member_count";
2929
3333 slug: String,
3434 name: String,
3535 description: Option<String>,
36+ avatar: Option<String>,
3637 created_at: String,
3738 member_count: u32,
3839 }
4647 description: row.description,
4748 created_at: row.created_at,
4849 member_count: row.member_count,
50+ avatar: row.avatar,
4951 }
5052 }
5153 }
5860
5961 impl Identity {
6062 /// The workspaces a user belongs to, attached to every user resolved
61− /// from credentials.
63+ /// from credentials, with what the site needs to show each one.
6264 pub async fn memberships(&self, user_id: &str) -> Result<Vec<Membership>> {
6365 self.db
6466 .prepare(
65− "SELECT workspaces.slug, workspace_members.role FROM workspace_members
67+ "SELECT workspaces.slug, workspace_members.role, workspaces.name,
68+ workspaces.avatar
69+ FROM workspace_members
6670 JOIN workspaces ON workspaces.id = workspace_members.workspace_id
6771 WHERE workspace_members.user_id = ? ORDER BY workspaces.slug",
6872 )
129133 slug,
130134 created_at: rfc3339(now),
131135 member_count: 1,
136+ avatar: None,
132137 };
133138 self.db
134139 .batch(vec![
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.