Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
1 commit
74 files+2242−4630/74 viewed
| 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.