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.
| 1 | 1 | --- | |
| 2 | 2 | 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. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | A workspace owns repositories and is the first part of their address: | |
| 37 | 37 | create a workspace with that name, and nobody can register a username that | |
| 38 | 38 | is already a workspace. | |
| 39 | 39 | ||
| 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 | + | ||
| 40 | 70 | ## Members and roles | |
| 41 | 71 | ||
| 42 | 72 | | Role | Can | | |
| 58 | 88 | ||
| 59 | 89 | | Settings | Who | | | |
| 60 | 90 | | --- | --- | --- | | |
| 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. | | |
| 62 | 92 | | **Members** | Members | Who belongs, and their roles. Owners add and remove people. | | |
| 63 | 93 | | **Access tokens** | Members | The workspace's own tokens. Owners create and delete them. | | |
| 64 | 94 | | **Billing and plans** | Members | [Plans](/guides/usage-and-billing/#plans), the balance and the statement. Owners turn plans on and add credit. | |
| 27 | 27 | ||
| 28 | 28 | --animate-fade-up: fade-up 0.6s ease-out both; | |
| 29 | 29 | --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 | + | } | |
| 30 | 78 | } | |
| 31 | 79 | ||
| 32 | 80 | @keyframes fade-up { |
| 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 | + | } |
| 22 | 22 | } from "../lib/diff"; | |
| 23 | 23 | import { Markdown } from "./markdown"; | |
| 24 | 24 | import { Avatar, Button, EmptyState, Textarea, TimeAgo } from "./ui"; | |
| 25 | + | import { Checkbox } from "./ui/checkbox"; | |
| 25 | 26 | ||
| 26 | 27 | const ROW_STYLES: Record<DiffLine["kind"], string> = { | |
| 27 | 28 | context: "", | |
| 440 | 441 | viewed ? "border-accent/40 bg-accent/10 text-accent" : "border-line text-muted hover:text-fg" | |
| 441 | 442 | }`} | |
| 442 | 443 | > | |
| 443 | − | <input | |
| 444 | − | type="checkbox" | |
| 444 | + | <Checkbox | |
| 445 | 445 | 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" | |
| 448 | 448 | /> | |
| 449 | 449 | Viewed | |
| 450 | 450 | </label> |
| 15 | 15 | import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; | |
| 16 | 16 | import remarkGfm from "remark-gfm"; | |
| 17 | 17 | ||
| 18 | + | import { Checkbox } from "./ui/checkbox"; | |
| 18 | 19 | import { type AlertKind, type MarkdownRepo, rehypeAlerts, rehypeReferences } from "../lib/markdown-plugins"; | |
| 19 | 20 | ||
| 20 | 21 | /** The text inside a React tree, for anchors and copying. */ | |
| 225 | 226 | input({ type, checked, disabled }) { | |
| 226 | 227 | // Task list boxes: shown, not editable. | |
| 227 | 228 | 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 | + | /> | |
| 229 | 235 | ) : null; | |
| 230 | 236 | }, | |
| 231 | 237 | img({ src, alt }) { |
| 3 | 3 | * fields each one asks for, and the routing of each kind of work to a | |
| 4 | 4 | * provider and model. | |
| 5 | 5 | */ | |
| 6 | − | import { Bot, ChevronRight, Webhook } from "lucide-react"; | |
| 6 | + | import { Bot, ChevronRight, Sparkles, Webhook } from "lucide-react"; | |
| 7 | 7 | import { type ReactNode, useState } from "react"; | |
| 8 | 8 | import { Form, Link } from "react-router"; | |
| 9 | 9 | ||
| 10 | 10 | import { type Connection, MODEL_TASKS, type ModelRoute, type ModelTask, type Provider, PROVIDERS } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | 12 | import { Button, Field, Input } from "./ui"; | |
| 13 | + | import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select"; | |
| 13 | 14 | ||
| 14 | 15 | type Group = "labs" | "platforms" | "any"; | |
| 15 | 16 | ||
| 244 | 245 | ); | |
| 245 | 246 | } | |
| 246 | 247 | ||
| 248 | + | /** Radix Select items cannot be "": the routing value for "same as everything". */ | |
| 249 | + | const SAME = "same"; | |
| 250 | + | ||
| 247 | 251 | const SELECT = | |
| 248 | 252 | "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"; | |
| 249 | 253 | ||
| 289 | 293 | <Input name="secret" type="password" /> | |
| 290 | 294 | </Field> | |
| 291 | 295 | <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> | |
| 296 | 309 | </Field> | |
| 297 | 310 | {GATEWAY_TOKEN} | |
| 298 | 311 | <Field | |
| 399 | 412 | <p className="text-xs text-faint">{TASK_LABELS[task].hint}</p> | |
| 400 | 413 | </div> | |
| 401 | 414 | <input type="hidden" name={`route-${task}`} value={value} /> | |
| 402 | − | <select | |
| 403 | − | aria-label={`${TASK_LABELS[task].label}: provider`} | |
| 415 | + | <Select | |
| 404 | 416 | 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: "" })} | |
| 408 | 419 | > | |
| 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> | |
| 419 | 452 | <div> | |
| 420 | 453 | <input | |
| 421 | 454 | aria-label={`${TASK_LABELS[task].label}: model`} |
| 13 | 13 | ||
| 14 | 14 | import type { SecretsAction, SecretsData } from "../lib/secrets.server"; | |
| 15 | 15 | 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"; | |
| 16 | 19 | ||
| 17 | 20 | /** The environments every deployment knows; workflow jobs may name others. */ | |
| 18 | 21 | const KNOWN_ENVIRONMENTS = ["production", "preview"]; | |
| 100 | 103 | className={`${SELECT} w-full pl-9`} | |
| 101 | 104 | /> | |
| 102 | 105 | </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> | |
| 116 | 131 | </div> | |
| 117 | 132 | ||
| 118 | 133 | <ErrorText>{data.error}</ErrorText> | |
| 238 | 253 | ||
| 239 | 254 | <fieldset> | |
| 240 | 255 | <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 | + | > | |
| 242 | 263 | {( | |
| 243 | 264 | [ | |
| 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" />], | |
| 246 | 267 | ] as const | |
| 247 | − | ).map(([value, title, text]) => { | |
| 268 | + | ).map(([value, title, text, icon]) => ( | |
| 248 | 269 | // 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> | |
| 274 | 280 | {row?.kind === "variable" && ( | |
| 275 | 281 | <p className="mt-2 text-xs text-faint">Config can become a secret; a secret cannot become config.</p> | |
| 276 | 282 | )} | |
| 316 | 322 | <fieldset> | |
| 317 | 323 | <legend className="mb-2 text-sm font-medium text-muted">Environments</legend> | |
| 318 | 324 | <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> | |
| 327 | 329 | {some && ( | |
| 328 | 330 | <div className="ml-6 space-y-2"> | |
| 329 | 331 | {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 | + | /> | |
| 340 | 339 | ))} | |
| 341 | 340 | <input | |
| 342 | 341 | name="envCustom" | |
| 362 | 361 | ["deployments", "Deployments", "The build's environment, and env.KEY in the running app"], | |
| 363 | 362 | ] as const | |
| 364 | 363 | ).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 | + | /> | |
| 378 | 372 | ))} | |
| 379 | 373 | </div> | |
| 380 | 374 | </fieldset> | |
| 383 | 377 | <fieldset> | |
| 384 | 378 | <legend className="mb-2 text-sm font-medium text-muted">Projects</legend> | |
| 385 | 379 | <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> | |
| 394 | 384 | {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"> | |
| 396 | 386 | {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 | + | /> | |
| 407 | 396 | ))} | |
| 408 | 397 | </div> | |
| 409 | 398 | )} |
| 41 | 41 | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; | |
| 42 | 42 | import { Form, Link, NavLink, useLocation, useNavigate, useNavigation, useSubmit } from "react-router"; | |
| 43 | 43 | ||
| 44 | − | import type { User } from "@g1t/contracts"; | |
| 44 | + | import type { Membership, User } from "@g1t/contracts"; | |
| 45 | 45 | import { MICROS_PER_DOLLAR } from "@g1t/contracts"; | |
| 46 | 46 | ||
| 47 | 47 | import { Mark } from "./logo"; | |
| 59 | 59 | /** What the sidebar needs, worked out by the root loader for a signed-in person. */ | |
| 60 | 60 | export type ShellData = { | |
| 61 | 61 | /** 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; | |
| 63 | 63 | /** Its projects, by name: `name` is the slug in their address. */ | |
| 64 | 64 | repos: { namespace: string; name: string; title?: string; isPrivate: boolean }[]; | |
| 65 | 65 | /** The project being looked at, if any, whoever owns it. */ | |
| 213 | 213 | ); | |
| 214 | 214 | } | |
| 215 | 215 | ||
| 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 | + | ||
| 216 | 221 | 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"; | |
| 218 | 225 | return ( | |
| 219 | 226 | <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. */} | |
| 221 | 229 | <Link | |
| 222 | 230 | 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" | |
| 224 | 233 | > | |
| 225 | − | {current ? ( | |
| 226 | − | <Avatar name={current} size={20} square /> | |
| 234 | + | {workspace ? ( | |
| 235 | + | <Avatar name={workspace.slug} image={workspace.avatar} size={20} square /> | |
| 227 | 236 | ) : ( | |
| 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" /> | |
| 229 | 238 | )} | |
| 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> | |
| 236 | 240 | </Link> | |
| 237 | 241 | <DropdownMenuTrigger | |
| 238 | 242 | 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" | |
| 240 | 244 | > | |
| 241 | 245 | <ChevronsUpDown size={14} /> | |
| 242 | 246 | </DropdownMenuTrigger> | |
| 243 | − | <DropdownMenuContent align="start" className="w-60"> | |
| 247 | + | <DropdownMenuContent align="start" className="w-64"> | |
| 244 | 248 | <DropdownMenuLabel>Workspaces</DropdownMenuLabel> | |
| 245 | 249 | {(user.workspaces ?? []).map((membership) => ( | |
| 246 | 250 | <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 | + | )} | |
| 251 | 263 | </Link> | |
| 252 | 264 | </DropdownMenuItem> | |
| 253 | 265 | ))} | |
| 315 | 327 | return ( | |
| 316 | 328 | <DropdownMenu> | |
| 317 | 329 | <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} /> | |
| 319 | 331 | <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span> | |
| 320 | 332 | <Ellipsis size={15} className="shrink-0 text-faint" /> | |
| 321 | 333 | </DropdownMenuTrigger> | |
| 589 | 601 | return ( | |
| 590 | 602 | <div className="flex h-full flex-col"> | |
| 591 | 603 | {/* 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"> | |
| 594 | 606 | <Mark className="size-6" /> | |
| 595 | 607 | </Link> | |
| 596 | 608 | <span className="shrink-0 text-line-strong" aria-hidden="true"> | |
| 835 | 847 | } | |
| 836 | 848 | for (const membership of user.workspaces ?? []) { | |
| 837 | 849 | 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 /> }, | |
| 839 | 851 | { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> }, | |
| 840 | 852 | { label: "Billing and plans", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> }, | |
| 841 | 853 | { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> }, |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | 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 | + | } |
| 153 | 153 | /** How g1t's agents are named: as reviewers and assignees, and as commit authors. */ | |
| 154 | 154 | const AGENT_NAMES = new Set(["g1t-agent", "g1t agent", "g1t"]); | |
| 155 | 155 | ||
| 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 | + | ||
| 156 | 161 | /** | |
| 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. | |
| 159 | 165 | */ | |
| 160 | 166 | export function Avatar({ | |
| 161 | 167 | name, | |
| 162 | 168 | size = 20, | |
| 163 | 169 | square, | |
| 170 | + | image, | |
| 164 | 171 | }: { | |
| 165 | 172 | name: string; | |
| 166 | 173 | size?: number; | |
| 167 | 174 | square?: boolean; | |
| 175 | + | /** The uploaded avatar's hash, as identity returns it. */ | |
| 176 | + | image?: string | null; | |
| 168 | 177 | }) { | |
| 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 | + | } | |
| 169 | 195 | // g1t's own agents wear the agent colour and mark everywhere they appear. | |
| 170 | 196 | if (AGENT_NAMES.has(name)) { | |
| 171 | 197 | return ( |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 10 | 10 | ||
| 11 | 11 | import type { WebhooksAction, WebhooksData } from "../lib/webhooks.server"; | |
| 12 | 12 | import { Button, CopyLine, EmptyState, ErrorText, Field, Input, TimeAgo } from "./ui"; | |
| 13 | + | import { CheckboxOption } from "./ui/checkbox"; | |
| 14 | + | import { RadioGroup, RadioOption } from "./ui/radio-group"; | |
| 13 | 15 | ||
| 14 | 16 | /** The events, in groups people recognise. */ | |
| 15 | 17 | const GROUPS: { title: string; events: string[] }[] = [ | |
| 183 | 185 | </Field> | |
| 184 | 186 | <fieldset> | |
| 185 | 187 | <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> | |
| 196 | 198 | {which === "some" && ( | |
| 197 | 199 | <div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> | |
| 198 | 200 | {GROUPS.map((group) => ( | |
| 202 | 204 | {group.events | |
| 203 | 205 | .filter((event) => (EVENT_TYPES as readonly string[]).includes(event)) | |
| 204 | 206 | .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 | + | /> | |
| 209 | 215 | ))} | |
| 210 | 216 | </div> | |
| 211 | 217 | </div> |
| 16 | 16 | import { repoAt } from "../lib/markdown-plugins"; | |
| 17 | 17 | import { Markdown } from "./markdown"; | |
| 18 | 18 | import { Avatar, Button, Textarea, TimeAgo } from "./ui"; | |
| 19 | + | import { CheckboxOption } from "./ui/checkbox"; | |
| 19 | 20 | ||
| 20 | 21 | /** Hues for the labels every repository starts with. */ | |
| 21 | 22 | const LABEL_HUES: Record<string, number> = { | |
| 426 | 427 | {people.length > 0 && ( | |
| 427 | 428 | <div className="space-y-1.5"> | |
| 428 | 429 | {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 | + | /> | |
| 440 | 444 | ))} | |
| 441 | 445 | </div> | |
| 442 | 446 | )} |
| 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 | + | }); |
| 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 | + | } |
| 40 | 40 | project?: { name?: string; description?: string | null } | null; | |
| 41 | 41 | open?: { issues?: number; pulls?: number }; | |
| 42 | 42 | }; | |
| 43 | − | type WorkspaceData = { workspace?: { name?: string; description?: string | null } }; | |
| 43 | + | type WorkspaceData = { workspace?: { name?: string; description?: string | null; avatar?: string | null } }; | |
| 44 | 44 | ||
| 45 | 45 | function loaded<T>(args: PageArgs, id: string): T | undefined { | |
| 46 | 46 | return args.matches.find((match) => match?.id === id)?.loaderData as T | undefined; | |
| 101 | 101 | (project | |
| 102 | 102 | ? [project.project?.name, project.project?.description ?? project.repo?.description, project.open] | |
| 103 | 103 | : 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 | + | ] | |
| 105 | 110 | : undefined); | |
| 106 | 111 | const pathname = args.location.pathname.replace(/\/+$/, "") || "/"; | |
| 107 | 112 | // The title a preview shows needs no "· g1t": the site name is beside it. |
| 191 | 191 | aria-label="Account menu" | |
| 192 | 192 | className="flex items-center gap-1.5 rounded-md p-1 outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised" | |
| 193 | 193 | > | |
| 194 | − | <Avatar name={user.username} size={24} /> | |
| 194 | + | <Avatar name={user.username} image={user.avatar} size={24} /> | |
| 195 | 195 | <ChevronDown size={14} className="text-faint" /> | |
| 196 | 196 | </DropdownMenuTrigger> | |
| 197 | 197 | <DropdownMenuContent align="end" className="w-56"> | |
| 205 | 205 | {(user.workspaces ?? []).map((membership) => ( | |
| 206 | 206 | <DropdownMenuItem asChild key={membership.slug}> | |
| 207 | 207 | <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> | |
| 210 | 210 | </Link> | |
| 211 | 211 | </DropdownMenuItem> | |
| 212 | 212 | ))} |
| 153 | 153 | * what is moving in it, and the way to its people and settings. The rest of | |
| 154 | 154 | * the page stays the viewer's own, across every workspace. | |
| 155 | 155 | */ | |
| 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 | + | }) { | |
| 157 | 171 | const mine = active.filter((item) => item.repo.namespace.toLowerCase() === slug); | |
| 158 | 172 | const stats = [ | |
| 159 | 173 | { value: repos.filter((repo) => repo.namespace.toLowerCase() === slug).length, label: "repositories" }, | |
| 163 | 177 | return ( | |
| 164 | 178 | <div className="rounded-xl border border-line bg-surface p-5"> | |
| 165 | 179 | <div className="flex items-center gap-3"> | |
| 166 | − | <Avatar name={slug} size={32} square /> | |
| 180 | + | <Avatar name={slug} image={avatar} size={32} square /> | |
| 167 | 181 | <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} | |
| 170 | 184 | </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> | |
| 172 | 188 | </div> | |
| 173 | 189 | </div> | |
| 174 | 190 | <dl className="mt-4 grid grid-cols-3 gap-2"> | |
| 339 | 355 | <div className="min-w-0 space-y-10"> | |
| 340 | 356 | <section> | |
| 341 | 357 | <div className="flex items-center gap-3"> | |
| 342 | − | <Avatar name={viewer.username} size={36} /> | |
| 358 | + | <Avatar name={viewer.username} image={viewer.avatar} size={36} /> | |
| 343 | 359 | <div> | |
| 344 | 360 | <h1 className="text-xl font-semibold tracking-tight"> | |
| 345 | 361 | Mission control | |
| 429 | 445 | {shell?.workspace && ( | |
| 430 | 446 | <WorkspaceCard | |
| 431 | 447 | slug={shell.workspace.slug.toLowerCase()} | |
| 448 | + | name={shell.workspace.name || shell.workspace.slug} | |
| 449 | + | avatar={shell.workspace.avatar} | |
| 432 | 450 | role={shell.workspace.role} | |
| 433 | 451 | repos={repos} | |
| 434 | 452 | active={active} |
| 1 | − | import { Box, Download, GitBranch, Sparkles } from "lucide-react"; | |
| 1 | + | import { Box, Download, GitBranch, Globe, Lock, Sparkles } from "lucide-react"; | |
| 2 | 2 | import { useState } from "react"; | |
| 3 | 3 | import { Form, redirect, useNavigation } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/new"; | |
| 6 | 6 | 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"; | |
| 8 | 12 | import { repos } from "../lib/services.server"; | |
| 9 | 13 | import { assertSameOrigin, requireUser } from "../lib/session.server"; | |
| 10 | 14 | ||
| 14 | 18 | ||
| 15 | 19 | export function loader({ request, context }: Route.LoaderArgs) { | |
| 16 | 20 | 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 | + | })); | |
| 18 | 27 | // Projects live in a workspace, so there has to be one first. | |
| 19 | 28 | if (workspaces.length === 0) throw redirect("/workspaces/new"); | |
| 20 | 29 | const asked = new URL(request.url).searchParams.get("workspace"); | |
| 21 | 30 | return { | |
| 22 | 31 | workspaces, | |
| 23 | − | selected: asked && workspaces.includes(asked) ? asked : workspaces[0], | |
| 32 | + | selected: asked && workspaces.some((workspace) => workspace.slug === asked) ? asked : workspaces[0].slug, | |
| 24 | 33 | }; | |
| 25 | 34 | } | |
| 26 | 35 | ||
| 48 | 57 | id: "empty", | |
| 49 | 58 | title: "Start empty", | |
| 50 | 59 | text: "A new repository on g1t. Push to it, or hand an agent the first issue.", | |
| 51 | − | icon: <Sparkles size={16} />, | |
| 60 | + | icon: <Sparkles />, | |
| 52 | 61 | }, | |
| 53 | 62 | { | |
| 54 | 63 | id: "import", | |
| 55 | 64 | title: "Import code", | |
| 56 | 65 | 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 />, | |
| 58 | 67 | }, | |
| 59 | 68 | { | |
| 60 | 69 | id: "mirror", | |
| 61 | 70 | title: "Mirror GitHub, GitLab or Bitbucket", | |
| 62 | 71 | text: "Keep the code where it is and get g1t's deployments and agents on it.", | |
| 63 | − | icon: <GitBranch size={16} />, | |
| 72 | + | icon: <GitBranch />, | |
| 64 | 73 | soon: true, | |
| 65 | 74 | }, | |
| 66 | 75 | ]; | |
| 67 | 76 | ||
| 77 | + | const ROLE_LABELS: Record<string, string> = { owner: "Owner", admin: "Admin", member: "Member" }; | |
| 78 | + | ||
| 68 | 79 | export default function NewProject({ loaderData, actionData }: Route.ComponentProps) { | |
| 69 | 80 | const [source, setSource] = useState<Source>("empty"); | |
| 81 | + | const [workspace, setWorkspace] = useState(loaderData.selected); | |
| 82 | + | const [name, setName] = useState(""); | |
| 70 | 83 | const busy = useNavigation().state === "submitting"; | |
| 71 | 84 | return ( | |
| 72 | 85 | <main className="mx-auto max-w-2xl px-4 py-12"> | |
| 80 | 93 | </p> | |
| 81 | 94 | ||
| 82 | 95 | <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> | |
| 121 | 118 | ||
| 122 | 119 | {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> | |
| 128 | 124 | </Field> | |
| 129 | 125 | )} | |
| 130 | 126 | ||
| 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> | |
| 148 | 172 | </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" /> | |
| 151 | 179 | </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 | + | ||
| 163 | 199 | <ErrorText>{actionData?.error}</ErrorText> | |
| 164 | 200 | <Button type="submit" variant="accent" disabled={busy}> | |
| 165 | 201 | Create project |
| 9 | 9 | import { page } from "../../lib/meta"; | |
| 10 | 10 | import { Notes, StatusIcon, duration, shortRef } from "../../components/actions"; | |
| 11 | 11 | 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"; | |
| 12 | 14 | import { actions } from "../../lib/services.server"; | |
| 13 | 15 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 14 | 16 | ||
| 124 | 126 | 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"; | |
| 125 | 127 | if (spec.type === "boolean") { | |
| 126 | 128 | 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 | + | /> | |
| 131 | 134 | ); | |
| 132 | 135 | } | |
| 133 | 136 | if (spec.type === "choice" && spec.options) { | |
| 134 | 137 | return ( | |
| 135 | − | <label className="block"> | |
| 138 | + | <div> | |
| 136 | 139 | {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> | |
| 143 | 154 | ); | |
| 144 | 155 | } | |
| 145 | 156 | return ( |
| 6 | 6 | import type { Route } from "./+types/issue-new"; | |
| 7 | 7 | import { page } from "../../lib/meta"; | |
| 8 | 8 | import { Button, ErrorText, Field, Input, Textarea } from "../../components/ui"; | |
| 9 | + | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 9 | 10 | import { Label } from "../../components/work"; | |
| 10 | 11 | import { integrations, work } from "../../lib/services.server"; | |
| 11 | 12 | import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server"; | |
| 79 | 80 | <div className="min-w-56 grow"> | |
| 80 | 81 | <Input name="reference" required placeholder="TECH-1234" aria-label="Ticket key or address" /> | |
| 81 | 82 | </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" /> | |
| 86 | 84 | <Button type="submit" variant="quiet" disabled={busy}> | |
| 87 | 85 | Import | |
| 88 | 86 | </Button> | |
| 114 | 112 | <legend className="mb-1.5 text-sm font-medium text-muted">Labels</legend> | |
| 115 | 113 | <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 116 | 114 | {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" /> | |
| 121 | 116 | ))} | |
| 122 | 117 | </div> | |
| 123 | 118 | <div className="mt-2"> |
| 18 | 18 | Textarea, | |
| 19 | 19 | TimeAgo, | |
| 20 | 20 | } from "../../components/ui"; | |
| 21 | + | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 21 | 22 | import { CheckBadge } from "../../components/checks"; | |
| 22 | 23 | import { | |
| 23 | 24 | Assignee, | |
| 609 | 610 | <input type="hidden" name="action" value="labels" /> | |
| 610 | 611 | <div className="flex flex-wrap gap-x-3 gap-y-2"> | |
| 611 | 612 | {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 | + | /> | |
| 622 | 621 | ))} | |
| 623 | 622 | </div> | |
| 624 | 623 | <Input name="labels" placeholder="New labels, comma separated" /> |
| 5 | 5 | import type { Route } from "./+types/issues"; | |
| 6 | 6 | import { page } from "../../lib/meta"; | |
| 7 | 7 | import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "../../components/ui"; | |
| 8 | + | import { Checkbox } from "../../components/ui/checkbox"; | |
| 8 | 9 | import { | |
| 9 | 10 | Assignee, | |
| 10 | 11 | AssigneeStack, | |
| 153 | 154 | {issues.map((issue) => ( | |
| 154 | 155 | <li key={issue.id} className="flex items-start transition-colors hover:bg-surface"> | |
| 155 | 156 | {assignable && ( | |
| 156 | − | <input | |
| 157 | − | type="checkbox" | |
| 157 | + | <Checkbox | |
| 158 | 158 | name="issue" | |
| 159 | − | value={issue.number} | |
| 159 | + | value={String(issue.number)} | |
| 160 | 160 | // Already being worked on: nothing more to hand over. | |
| 161 | 161 | disabled={issue.agent != null || issue.queued} | |
| 162 | 162 | 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" | |
| 164 | 164 | /> | |
| 165 | 165 | )} | |
| 166 | 166 | <Link |
| 10 | 10 | import { Activity, Exchanges } from "../../components/activity"; | |
| 11 | 11 | import { Outcome } from "../../components/outcome"; | |
| 12 | 12 | import { Button, ErrorText, TimeAgo } from "../../components/ui"; | |
| 13 | + | import { Checkbox } from "../../components/ui/checkbox"; | |
| 13 | 14 | import { Label } from "../../components/work"; | |
| 14 | 15 | import { billing, events, identity, work } from "../../lib/services.server"; | |
| 15 | 16 | import { | |
| 185 | 186 | className="flex gap-3 rounded-xl border border-line bg-surface p-4" | |
| 186 | 187 | > | |
| 187 | 188 | {plan.status === "ready" ? ( | |
| 188 | − | <input | |
| 189 | − | type="checkbox" | |
| 189 | + | <Checkbox | |
| 190 | 190 | name="keep" | |
| 191 | − | value={position} | |
| 191 | + | value={String(position)} | |
| 192 | 192 | defaultChecked | |
| 193 | 193 | aria-label={`Open issue ${position}`} | |
| 194 | − | className="mt-1.5 accent-accent" | |
| 194 | + | className="mt-1" | |
| 195 | 195 | /> | |
| 196 | 196 | ) : ( | |
| 197 | 197 | <span className="mt-0.5 w-5 shrink-0 text-center font-mono text-sm text-faint"> |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/pull-new"; | |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| 6 | + | import { Combobox } from "../../components/ui/combobox"; | |
| 6 | 7 | import { | |
| 7 | 8 | Button, | |
| 8 | 9 | CopyLine, | |
| 93 | 94 | <div className="flex flex-wrap items-center gap-2 rounded-xl border border-line bg-surface px-4 py-3 text-sm"> | |
| 94 | 95 | <GitBranch size={15} className="text-faint" /> | |
| 95 | 96 | <span className="text-muted">Merge</span> | |
| 96 | − | <select | |
| 97 | + | <Combobox | |
| 97 | 98 | name="branch" | |
| 98 | 99 | defaultValue={branches.includes(selected) ? selected : branches[0]} | |
| 99 | 100 | 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 | + | /> | |
| 108 | 106 | <span className="text-muted">into</span> | |
| 109 | 107 | <span className="font-mono">{defaultBranch}</span> | |
| 110 | 108 | </div> |
| 50 | 50 | Textarea, | |
| 51 | 51 | TimeAgo, | |
| 52 | 52 | } from "../../components/ui"; | |
| 53 | + | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 53 | 54 | import { WorkflowStatuses } from "../../components/actions"; | |
| 54 | 55 | import { ChecksPanel } from "../../components/checks"; | |
| 55 | 56 | import { | |
| 850 | 851 | {canMerge && !landing && lifecycle?.stage !== "queued" && ( | |
| 851 | 852 | <Form method="post" className="space-y-3 px-4 py-3"> | |
| 852 | 853 | {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 | + | /> | |
| 861 | 859 | )} | |
| 862 | 860 | {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 | + | /> | |
| 874 | 866 | )} | |
| 875 | 867 | <div className="flex flex-wrap items-center gap-3"> | |
| 876 | 868 | <Button |
| 6 | 6 | import type { Route } from "./+types/settings-dependencies"; | |
| 7 | 7 | import { page } from "../../lib/meta"; | |
| 8 | 8 | 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"; | |
| 10 | 11 | import { projects } from "../../lib/services.server"; | |
| 11 | 12 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 12 | 13 | ||
| 121 | 122 | <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]"> | |
| 122 | 123 | <input type="hidden" name="intent" value="add" /> | |
| 123 | 124 | <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> | |
| 131 | 142 | </Field> | |
| 132 | 143 | <Field label="Its address as"> | |
| 133 | 144 | <Input name="as" placeholder="API_URL" className="font-mono" /> |
| 4 | 4 | import { page } from "../../lib/meta"; | |
| 5 | 5 | import { RepoSettingsTabs } from "../../components/repo-settings-tabs"; | |
| 6 | 6 | import { Button, ErrorText, Field, Input } from "../../components/ui"; | |
| 7 | + | import { SwitchCard } from "../../components/ui/switch"; | |
| 7 | 8 | import { deployments } from "../../lib/services.server"; | |
| 8 | 9 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 9 | 10 | ||
| 43 | 44 | ||
| 44 | 45 | function Check({ name, on, title, children }: { name: string; on: boolean; title: string; children: string }) { | |
| 45 | 46 | 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> | |
| 53 | 50 | ); | |
| 54 | 51 | } | |
| 55 | 52 |
| 1 | + | import { Globe, Lock } from "lucide-react"; | |
| 1 | 2 | import type { ReactNode } from "react"; | |
| 2 | 3 | import { Form, data, useNavigation } from "react-router"; | |
| 3 | 4 | ||
| 5 | 6 | import type { Route } from "./+types/settings-repository"; | |
| 6 | 7 | import { page } from "../../lib/meta"; | |
| 7 | 8 | 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"; | |
| 8 | 12 | import { repos, work } from "../../lib/services.server"; | |
| 9 | 13 | import { | |
| 10 | 14 | assertSameOrigin, | |
| 99 | 103 | children: ReactNode; | |
| 100 | 104 | }) { | |
| 101 | 105 | 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> | |
| 109 | 109 | ); | |
| 110 | 110 | } | |
| 111 | 111 | ||
| 129 | 129 | <p className="text-sm font-medium">{title}</p> | |
| 130 | 130 | <p className="mt-1 text-sm text-muted">{children}</p> | |
| 131 | 131 | </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> | |
| 144 | 144 | </div> | |
| 145 | 145 | ); | |
| 146 | 146 | } | |
| 157 | 157 | <Field label="Description"> | |
| 158 | 158 | <Input name="description" maxLength={200} defaultValue={repo.description ?? ""} /> | |
| 159 | 159 | </Field> | |
| 160 | − | <fieldset className="space-y-2 rounded-xl border border-line bg-surface p-4 text-sm"> | |
| 160 | + | <fieldset> | |
| 161 | 161 | <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> | |
| 188 | 171 | </fieldset> | |
| 189 | 172 | </Section> | |
| 190 | 173 |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/settings"; | |
| 5 | 5 | import { page } from "../lib/meta"; | |
| 6 | + | import { AvatarField } from "../components/avatar-field"; | |
| 6 | 7 | import { Button, ErrorText, Field, Input, TimeAgo } from "../components/ui"; | |
| 8 | + | import { readAvatarUpload } from "../lib/avatar-upload"; | |
| 7 | 9 | import { assertSameOrigin, requireUser } from "../lib/session.server"; | |
| 8 | 10 | ||
| 9 | 11 | export function meta(args: Route.MetaArgs) { | |
| 27 | 29 | const id = String(form.get("id") ?? ""); | |
| 28 | 30 | ||
| 29 | 31 | 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 | + | } | |
| 30 | 43 | case "add-key": { | |
| 31 | 44 | const result = await identity.addSshKey( | |
| 32 | 45 | user, | |
| 89 | 102 | </p> | |
| 90 | 103 | </header> | |
| 91 | 104 | <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> | |
| 92 | 116 | <section id="ssh-keys" className="scroll-mt-20"> | |
| 93 | 117 | <h2 className="font-medium">SSH keys</h2> | |
| 94 | 118 | <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden"> |
| 13 | 13 | ||
| 14 | 14 | import type { Route } from "./+types/billing"; | |
| 15 | 15 | import { page } from "../../lib/meta"; | |
| 16 | + | import { RadioGroup, RadioGroupItem } from "../../components/ui/radio-group"; | |
| 16 | 17 | import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui"; | |
| 17 | 18 | import { billing, deployments } from "../../lib/services.server"; | |
| 18 | 19 | import { | |
| 636 | 637 | <Form method="post" className="mt-5 border-t border-line pt-4"> | |
| 637 | 638 | <fieldset> | |
| 638 | 639 | <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" /> | |
| 642 | 643 | Automatic | |
| 643 | 644 | </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" /> | |
| 646 | 647 | Fixed at | |
| 647 | 648 | <span className="flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent"> | |
| 648 | 649 | <span className="text-muted">$</span> | |
| 657 | 658 | /> | |
| 658 | 659 | </span> | |
| 659 | 660 | </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" /> | |
| 662 | 663 | None | |
| 663 | 664 | </label> | |
| 664 | 665 | <Button variant="quiet" type="submit" name="intent" value="spend-limit" disabled={busy}> | |
| 665 | 666 | Save | |
| 666 | 667 | </Button> | |
| 667 | − | </div> | |
| 668 | + | </RadioGroup> | |
| 668 | 669 | </fieldset> | |
| 669 | 670 | <ErrorText>{error}</ErrorText> | |
| 670 | 671 | <p className="mt-3 text-xs text-faint"> |
| 25 | 25 | import type { Route } from "./+types/integrations"; | |
| 26 | 26 | import { page } from "../../lib/meta"; | |
| 27 | 27 | 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"; | |
| 29 | 31 | import { billing, integrations, repos } from "../../lib/services.server"; | |
| 30 | 32 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 31 | 33 | ||
| 487 | 489 | function RepoField({ repos, required, hint }: { repos: string[]; required?: boolean; hint: string }) { | |
| 488 | 490 | return ( | |
| 489 | 491 | <Field label="Repository" hint={hint}> | |
| 490 | − | <select | |
| 492 | + | <Combobox | |
| 491 | 493 | name="repo" | |
| 492 | − | required={required} | |
| 493 | 494 | 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 | + | /> | |
| 503 | 505 | </Field> | |
| 504 | 506 | ); | |
| 505 | 507 | } | |
| 506 | 508 | ||
| 507 | 509 | function AssignField() { | |
| 508 | 510 | 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. | |
| 516 | 518 | </span> | |
| 517 | − | </span> | |
| 518 | − | </label> | |
| 519 | + | } | |
| 520 | + | /> | |
| 519 | 521 | ); | |
| 520 | 522 | } | |
| 521 | 523 |
| 19 | 19 | ||
| 20 | 20 | /** A workspace's own pages, each with its title and what it is for. */ | |
| 21 | 21 | 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." }, | |
| 23 | 23 | people: { | |
| 24 | 24 | title: "Members", | |
| 25 | 25 | about: "Members create repositories, push, manage issues and merge pull requests. Owners also manage members, tokens, billing and integrations.", | |
| 68 | 68 | <div className="border-b border-line bg-surface/60"> | |
| 69 | 69 | <div className="mx-auto max-w-6xl px-4 pt-8"> | |
| 70 | 70 | <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 /> | |
| 72 | 72 | <div className="min-w-0 grow"> | |
| 73 | 73 | <div className="flex flex-wrap items-center gap-2.5"> | |
| 74 | 74 | <h1 className="truncate text-2xl font-semibold tracking-tight"> |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/settings"; | |
| 4 | 4 | import { page } from "../../lib/meta"; | |
| 5 | + | import { AvatarField } from "../../components/avatar-field"; | |
| 5 | 6 | import { Button, ErrorText, Field, Input } from "../../components/ui"; | |
| 7 | + | import { readAvatarUpload } from "../../lib/avatar-upload"; | |
| 6 | 8 | import { identity } from "../../lib/services.server"; | |
| 7 | 9 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 8 | 10 | ||
| 24 | 26 | assertSameOrigin(request); | |
| 25 | 27 | const user = requireUser(context, request); | |
| 26 | 28 | 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 | + | } | |
| 27 | 42 | const result = await identity.updateWorkspace(user, params.owner, { | |
| 28 | 43 | name: String(form.get("displayName") ?? ""), | |
| 29 | 44 | description: String(form.get("description") ?? ""), | |
| 35 | 50 | export default function WorkspaceSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 36 | 51 | const { workspace } = loaderData; | |
| 37 | 52 | 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." | |
| 49 | 63 | /> | |
| 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> | |
| 60 | 92 | </div> | |
| 61 | 93 | ); | |
| 62 | 94 | } |
| 17 | 17 | "@g1t/theme": "*", | |
| 18 | 18 | "class-variance-authority": "^0.7.1", | |
| 19 | 19 | "clsx": "^2.1.1", | |
| 20 | + | "cmdk": "^1.1.1", | |
| 20 | 21 | "isbot": "^5.1.36", | |
| 21 | 22 | "lucide-react": "^1.49.0", | |
| 22 | 23 | "mdast-util-find-and-replace": "^3.0.2", |
| 6 | 6 | ); | |
| 7 | 7 | ||
| 8 | 8 | 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"]); | |
| 9 | 13 | const DOCS = "https://docs.g1t.sh"; | |
| 10 | 14 | ||
| 11 | 15 | /** Where the documentation pages that used to live under /docs are now. */ | |
| 27 | 31 | if (GIT_PATH.test(pathname)) { | |
| 28 | 32 | return env.REPOS.fetch(request); | |
| 29 | 33 | } | |
| 34 | + | const avatar = AVATAR_PATH.exec(pathname); | |
| 35 | + | if (avatar) { | |
| 36 | + | return serveAvatar(env, avatar[1], request.method); | |
| 37 | + | } | |
| 30 | 38 | // The documentation is its own site. | |
| 31 | 39 | if (pathname === "/docs" || pathname.startsWith("/docs/")) { | |
| 32 | 40 | const page = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; | |
| 36 | 44 | return requestHandler(request); | |
| 37 | 45 | }, | |
| 38 | 46 | } 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 | + | } |
| 16 | 16 | DEPLOYMENTS: ServiceBinding; | |
| 17 | 17 | PROJECTS: ServiceBinding; | |
| 18 | 18 | BLOBS: KVNamespace; | |
| 19 | + | /** Uploaded avatars by SHA-256, with `{ contentType }`; written by identity. */ | |
| 20 | + | AVATARS: KVNamespace; | |
| 19 | 21 | } | |
| 20 | 22 | } | |
| 21 | 23 | interface Env extends Cloudflare.Env {} |
| 10 | 10 | "routes": [{ "pattern": "g1t.sh", "custom_domain": true }], | |
| 11 | 11 | // The site holds no data of its own; everything goes through services. | |
| 12 | 12 | // 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 | + | ], | |
| 14 | 19 | "services": [ | |
| 15 | 20 | { "binding": "IDENTITY", "service": "g1t-identity" }, | |
| 16 | 21 | { "binding": "REPOS", "service": "g1t-repos" }, |
| 242 | 242 | /// RFC 3339. | |
| 243 | 243 | pub created_at: String, | |
| 244 | 244 | 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>, | |
| 245 | 249 | } | |
| 246 | 250 | ||
| 247 | 251 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 291 | 295 | pub description: String, | |
| 292 | 296 | } | |
| 293 | 297 | ||
| 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 | + | ||
| 294 | 319 | /// `list_workspace_tokens`: members only. Returns | |
| 295 | 320 | /// `Outcome<Vec<AccessToken>>`. | |
| 296 | 321 | #[derive(Debug, Serialize, Deserialize)] |
| 40 | 40 | /// The workspace's name in URLs: `g1t.sh/<slug>`. | |
| 41 | 41 | pub slug: String, | |
| 42 | 42 | 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>, | |
| 43 | 52 | } | |
| 44 | 53 | ||
| 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 | + | ||
| 45 | 66 | /// What a set of credentials resolved to. | |
| 46 | 67 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 47 | 68 | #[serde(rename_all = "lowercase")] | |
| 73 | 94 | /// resolved from credentials, so any service can authorize from it. | |
| 74 | 95 | #[serde(default)] | |
| 75 | 96 | 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>, | |
| 76 | 101 | } | |
| 77 | 102 | ||
| 78 | 103 | impl User { |
| 14 | 14 | "issues", | |
| 15 | 15 | "oauth", | |
| 16 | 16 | "assets", | |
| 17 | + | "avatars", | |
| 17 | 18 | "docs", | |
| 18 | 19 | "explore", | |
| 19 | 20 | "g1t", |
| 60 | 60 | "@g1t/theme": "*", | |
| 61 | 61 | "class-variance-authority": "^0.7.1", | |
| 62 | 62 | "clsx": "^2.1.1", | |
| 63 | + | "cmdk": "^1.1.1", | |
| 63 | 64 | "isbot": "^5.1.36", | |
| 64 | 65 | "lucide-react": "^1.49.0", | |
| 65 | 66 | "mdast-util-find-and-replace": "^3.0.2", | |
| 5282 | 5283 | "node": ">=6" | |
| 5283 | 5284 | } | |
| 5284 | 5285 | }, | |
| 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 | + | }, | |
| 5285 | 5302 | "node_modules/color-name": { | |
| 5286 | 5303 | "version": "1.1.4", | |
| 5287 | 5304 | "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", | |
| 5917 | 5934 | } | |
| 5918 | 5935 | }, | |
| 5919 | 5936 | "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==", | |
| 5923 | 5940 | "license": "MIT" | |
| 5924 | 5941 | }, | |
| 5925 | 5942 | "node_modules/find-proc": { |
| 2 | 2 | "name": "g1t-monorepo", | |
| 3 | 3 | "private": true, | |
| 4 | 4 | "type": "module", | |
| 5 | − | "workspaces": ["apps/*", "services/*", "packages/*"], | |
| 5 | + | "workspaces": [ | |
| 6 | + | "apps/*", | |
| 7 | + | "services/*", | |
| 8 | + | "packages/*" | |
| 9 | + | ], | |
| 6 | 10 | "scripts": { | |
| 7 | 11 | "typecheck": "npm run typecheck --workspaces --if-present", | |
| 8 | 12 | "deploy": "npm run deploy -w @g1t/web" | |
| 10 | 14 | "devDependencies": { | |
| 11 | 15 | "typescript": "^5.9.3", | |
| 12 | 16 | "wrangler": "^4.146.0" | |
| 17 | + | }, | |
| 18 | + | "overrides": { | |
| 19 | + | "fflate": "^0.7.5" | |
| 13 | 20 | } | |
| 14 | 21 | } |
| 67 | 67 | call("remove_member", { actor, slug, username }), | |
| 68 | 68 | updateWorkspace: (actor, slug, details) => | |
| 69 | 69 | 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 }), | |
| 70 | 72 | listWorkspaceTokens: (slug, viewer) => call("list_workspace_tokens", { slug, viewer }), | |
| 71 | 73 | createWorkspaceToken: (actor, slug, name) => | |
| 72 | 74 | call("create_workspace_token", { actor, slug, name }), |
| 20 | 20 | * credentials, so any service can authorize from it. | |
| 21 | 21 | */ | |
| 22 | 22 | 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; | |
| 23 | 29 | }; | |
| 24 | 30 | ||
| 25 | 31 | /** What a member may do: an owner also manages the workspace's members. */ | |
| 26 | 32 | export type Role = "owner" | "member"; | |
| 27 | 33 | ||
| 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 | + | }; | |
| 29 | 43 | ||
| 44 | + | /** The largest avatar that can be uploaded, in bytes. */ | |
| 45 | + | export const MAX_AVATAR_BYTES = 1024 * 1024; | |
| 46 | + | ||
| 30 | 47 | /** | |
| 31 | 48 | * A workspace: the owner of repositories, and the first segment of their | |
| 32 | 49 | * URLs. A person's own space and a team's are the same thing. | |
| 40 | 57 | /** RFC 3339. */ | |
| 41 | 58 | createdAt: string; | |
| 42 | 59 | 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; | |
| 43 | 65 | }; | |
| 44 | 66 | ||
| 45 | 67 | export type Member = { username: string; role: Role }; | |
| 209 | 231 | removeMember(actor: User, slug: string, username: string): Promise<Result<boolean>>; | |
| 210 | 232 | /** Owners only. An empty name falls back to the slug. */ | |
| 211 | 233 | 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>>; | |
| 212 | 241 | ||
| 213 | 242 | /** | |
| 214 | 243 | * A workspace's own access tokens. They belong to the workspace, act as |
| 6 | 6 | /** Routes and reserved words that may not be registered as usernames. */ | |
| 7 | 7 | const RESERVED = new Set([ | |
| 8 | 8 | "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", | |
| 10 | 10 | ]); | |
| 11 | 11 | ||
| 12 | 12 | export function isValidNamespace(value: string): boolean { |
| 25 | 25 | use g1t_contracts::events::Event; | |
| 26 | 26 | use g1t_contracts::identity::{SlugArgs, Workspace}; | |
| 27 | 27 | 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}; | |
| 29 | 29 | use g1t_kit::{args, reply, rpc_method}; | |
| 30 | 30 | use g1t_secrets::Sealer; | |
| 31 | 31 | use serde::Deserialize; | |
| 102 | 102 | username: workspace.slug.clone(), | |
| 103 | 103 | kind: PrincipalKind::Workspace, | |
| 104 | 104 | 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() | |
| 109 | 107 | })) | |
| 110 | 108 | } | |
| 111 | 109 |
| 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; |
| 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 | + | } |
| 4 | 4 | //! the methods and their arguments. | |
| 5 | 5 | ||
| 6 | 6 | mod admin; | |
| 7 | + | mod avatars; | |
| 7 | 8 | mod crypto; | |
| 8 | 9 | mod device; | |
| 9 | 10 | mod email; | |
| 32 | 33 | id: String, | |
| 33 | 34 | username: String, | |
| 34 | 35 | verified: u8, | |
| 36 | + | /// Selected only where the person is being shown to themselves. | |
| 37 | + | #[serde(default)] | |
| 38 | + | avatar: Option<String>, | |
| 35 | 39 | } | |
| 36 | 40 | ||
| 37 | 41 | impl From<Account> for User { | |
| 40 | 44 | id: row.id, | |
| 41 | 45 | username: row.username, | |
| 42 | 46 | verified: row.verified != 0, | |
| 47 | + | avatar: row.avatar, | |
| 43 | 48 | ..User::default() | |
| 44 | 49 | } | |
| 45 | 50 | } | |
| 389 | 394 | async fn user_for_session(&self, a: SessionArgs) -> Result<Viewer> { | |
| 390 | 395 | self.find_user( | |
| 391 | 396 | &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 | |
| 393 | 399 | FROM sessions JOIN users ON users.id = sessions.user_id | |
| 394 | 400 | WHERE sessions.id = ? AND sessions.expires_at > {SQL_NOW}" | |
| 395 | 401 | ), | |
| 540 | 546 | "add_member" => reply(&identity.add_member(args(body)?).await?), | |
| 541 | 547 | "remove_member" => reply(&identity.remove_member(args(body)?).await?), | |
| 542 | 548 | "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?), | |
| 543 | 551 | "list_workspace_tokens" => reply(&identity.list_workspace_tokens(args(body)?).await?), | |
| 544 | 552 | "create_workspace_token" => reply(&identity.create_workspace_token(args(body)?).await?), | |
| 545 | 553 | "remove_workspace_token" => reply(&identity.remove_workspace_token(args(body)?).await?), |
| 105 | 105 | username: AGENT_NAME.to_owned(), | |
| 106 | 106 | kind: PrincipalKind::Agent, | |
| 107 | 107 | 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() | |
| 112 | 110 | })); | |
| 113 | 111 | } | |
| 114 | 112 | let viewer = match (&presented.user_id, &presented.workspace_id) { | |
| 143 | 141 | username: workspace.slug.clone(), | |
| 144 | 142 | kind: PrincipalKind::Workspace, | |
| 145 | 143 | 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() | |
| 150 | 146 | })) | |
| 151 | 147 | } | |
| 152 | 148 |
| 23 | 23 | const MAX_DESCRIPTION_LENGTH: usize = 160; | |
| 24 | 24 | ||
| 25 | 25 | const WORKSPACE_COLUMNS: &str = "workspaces.id, workspaces.slug, workspaces.name, | |
| 26 | − | workspaces.description, workspaces.created_at, | |
| 26 | + | workspaces.description, workspaces.avatar, workspaces.created_at, | |
| 27 | 27 | (SELECT count(*) FROM workspace_members | |
| 28 | 28 | WHERE workspace_members.workspace_id = workspaces.id) AS member_count"; | |
| 29 | 29 | ||
| 33 | 33 | slug: String, | |
| 34 | 34 | name: String, | |
| 35 | 35 | description: Option<String>, | |
| 36 | + | avatar: Option<String>, | |
| 36 | 37 | created_at: String, | |
| 37 | 38 | member_count: u32, | |
| 38 | 39 | } | |
| 46 | 47 | description: row.description, | |
| 47 | 48 | created_at: row.created_at, | |
| 48 | 49 | member_count: row.member_count, | |
| 50 | + | avatar: row.avatar, | |
| 49 | 51 | } | |
| 50 | 52 | } | |
| 51 | 53 | } | |
| 58 | 60 | ||
| 59 | 61 | impl Identity { | |
| 60 | 62 | /// 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. | |
| 62 | 64 | pub async fn memberships(&self, user_id: &str) -> Result<Vec<Membership>> { | |
| 63 | 65 | self.db | |
| 64 | 66 | .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 | |
| 66 | 70 | JOIN workspaces ON workspaces.id = workspace_members.workspace_id | |
| 67 | 71 | WHERE workspace_members.user_id = ? ORDER BY workspaces.slug", | |
| 68 | 72 | ) | |
| 129 | 133 | slug, | |
| 130 | 134 | created_at: rfc3339(now), | |
| 131 | 135 | member_count: 1, | |
| 136 | + | avatar: None, | |
| 132 | 137 | }; | |
| 133 | 138 | self.db | |
| 134 | 139 | .batch(vec![ |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.