Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1 | import { ImagePlus, Lock, Plus, Search, Trash2, Upload } from "lucide-react"; |
| 2 | import { type ChangeEvent, useEffect, useMemo, useRef, useState } from "react"; | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 3 | import { Link, useFetcher } from "react-router"; |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 4 | |
| 5 | import { type CustomEmoji, type EmojiList, type EmojiUpload, MAX_EMOJI_BYTES, MAX_EMOJI_SIDE } from "@g1t/contracts"; | |
| 6 | ||
| 7 | import { EmojiProvider, forgetCustomEmoji } from "./context"; | |
| 8 | import { loadEmojiData } from "./data"; | |
| 9 | import { CustomEmojiImage } from "./render"; | |
| 10 | import { MemberAvatar } from "../chat/marks"; | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 11 | import { EmptyState, ErrorText, TimeAgo, notACredential } from "../ui"; |
| 12 | import { Button } from "../ui/button"; | |
| 13 | import { Card } from "../ui/card"; | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 14 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog"; |
| 15 | import { Hint } from "../ui/hint"; | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 16 | import { SelectField } from "../ui/select"; |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 17 | import { cleanEmojiName, emojiNameProblem, standardCodes } from "../../lib/emoji"; |
| 18 | ||
| 19 | /** What the page's action answers (routes/workspace/emoji.tsx). */ | |
| 20 | export type EmojiActionResult = { ok: true; intent: string } | { ok: false; intent: string; error: string }; | |
| 21 | ||
| 22 | /** What an image may be: as the chat service checks, by its bytes. */ | |
| 23 | const ACCEPT = "image/png,image/gif,image/webp"; | |
| 24 | ||
| 25 | /** | |
| 26 | * A workspace's own emoji: every one with its name, who added it and when; | |
| 27 | * adding one from an image, giving one another name, and removing them. | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 28 | * Who may add them is a chat setting (routes/workspace/chat-settings.tsx). |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 29 | */ |
| 30 | export function EmojiSettings({ slug, list, usercontent, meId }: { slug: string; list: EmojiList; usercontent: string; meId: string }) { | |
| 31 | const [query, setQuery] = useState(""); | |
| 32 | const [adding, setAdding] = useState(false); | |
| 33 | const [aliasing, setAliasing] = useState(false); | |
| 34 | const shown = useMemo(() => { | |
| 35 | const q = cleanEmojiName(query); | |
| 36 | return q ? list.emoji.filter((e) => e.name.includes(q) || e.alias_of?.includes(q)) : list.emoji; | |
| 37 | }, [list.emoji, query]); | |
| 38 | const taken = useMemo(() => new Set(list.emoji.map((e) => e.name)), [list.emoji]); | |
| 39 | useEffect(() => forgetCustomEmoji(slug), [slug, list.emoji]); | |
| 40 | ||
| 41 | return ( | |
| 42 | <EmojiProvider value={{ customs: list.emoji, usercontent, me: null }}> | |
| 43 | <div className="space-y-8"> | |
| 44 | <div> | |
| 45 | <p className="max-w-2xl text-sm text-muted"> | |
| 46 | Emoji everyone in {slug} can use in messages and reactions, as <code className="font-mono text-fg-soft">:name:</code>. Images are PNG, GIF or | |
| 47 | WebP, up to {MAX_EMOJI_BYTES / 1024} KB and {MAX_EMOJI_SIDE}×{MAX_EMOJI_SIDE} pixels; square ones look best. | |
| 48 | </p> | |
| 49 | </div> | |
| 50 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | |
| 51 | <label className="relative block sm:w-72"> | |
| 52 | <span className="sr-only">Find an emoji</span> | |
| 53 | <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" /> | |
| 54 | <input | |
| 55 | type="search" | |
| 56 | value={query} | |
| 57 | onChange={(event) => setQuery(event.target.value)} | |
| 58 | placeholder="Find an emoji" | |
| 59 | {...notACredential()} | |
| 60 | className="w-full rounded-md border border-line bg-bg py-1.5 pr-3 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim" | |
| 61 | /> | |
| 62 | </label> | |
| 63 | {list.can_upload ? ( | |
| 64 | <div className="flex gap-2"> | |
| 65 | {list.emoji.length > 0 && ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 66 | <Button variant="outline" onClick={() => setAliasing(true)}> |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 67 | Add an alias |
| 68 | </Button> | |
| 69 | )} | |
| 70 | <Button variant="accent" onClick={() => setAdding(true)}> | |
| 71 | <Plus size={15} /> | |
| 72 | Add emoji | |
| 73 | </Button> | |
| 74 | </div> | |
| 75 | ) : ( | |
| 76 | <p className="flex items-center gap-1.5 text-xs text-muted"> | |
| 77 | <Lock size={13} className="shrink-0 text-faint" /> | |
| 78 | Only owners can add emoji in this workspace. | |
| 79 | </p> | |
| 80 | )} | |
| 81 | </div> | |
| 82 | ||
| 83 | {list.emoji.length === 0 ? ( | |
| 84 | <EmptyState title="No emoji of your own yet"> | |
| 85 | <ImagePlus size={20} className="mx-auto mb-2 text-faint" /> | |
| 86 | Add your team’s logo, a mascot or an inside joke, and use it as <code className="font-mono">:name:</code> anywhere in chat. | |
| 87 | </EmptyState> | |
| 88 | ) : ( | |
| 89 | <section aria-label="Emoji"> | |
| 90 | <h2 className="mb-3 text-sm font-medium text-muted"> | |
| 91 | {query ? "Matching" : "All emoji"} <span className="text-faint">{shown.length}</span> | |
| 92 | </h2> | |
| 93 | {shown.length === 0 ? ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 94 | <Card asChild tone="plain" className="px-4 py-8 text-center text-sm text-muted"> |
| 95 | <p>No emoji match “{query}”.</p> | |
| 96 | </Card> | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 97 | ) : ( |
| 98 | <ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3"> | |
| 99 | {shown.map((emoji) => ( | |
| 100 | <EmojiCard key={emoji.name} slug={slug} emoji={emoji} usercontent={usercontent} removable={list.can_manage || (emoji.created_by.kind === "user" && emoji.created_by.id === meId)} /> | |
| 101 | ))} | |
| 102 | </ul> | |
| 103 | )} | |
| 104 | </section> | |
| 105 | )} | |
| 106 | ||
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 107 | {list.can_manage && <UploadSetting slug={slug} value={list.emoji_upload} />} |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 108 | </div> |
| 109 | <UploadDialog open={adding} onOpenChange={setAdding} taken={taken} /> | |
| 110 | <AliasDialog open={aliasing} onOpenChange={setAliasing} emoji={list.emoji} taken={taken} usercontent={usercontent} /> | |
| 111 | </EmojiProvider> | |
| 112 | ); | |
| 113 | } | |
| 114 | ||
| 115 | function EmojiCard({ slug, emoji, usercontent, removable }: { slug: string; emoji: CustomEmoji; usercontent: string; removable: boolean }) { | |
| 116 | const fetcher = useFetcher<EmojiActionResult>(); | |
| 117 | const [confirm, setConfirm] = useState(false); | |
| 118 | const busy = fetcher.state !== "idle"; | |
| 119 | useEffect(() => { | |
| 120 | if (fetcher.data?.ok) forgetCustomEmoji(slug); | |
| 121 | }, [fetcher.data, slug]); | |
| 122 | return ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 123 | <Card asChild className="group flex items-center gap-3 px-3 py-2.5"> |
| 124 | <li> | |
| 125 | <span className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-bg"> | |
| 126 | <CustomEmojiImage name={emoji.name} file={emoji.file} usercontent={usercontent} size={32} /> | |
| 127 | </span> | |
| 128 | <div className="min-w-0 grow"> | |
| 129 | <p className="truncate font-mono text-sm text-fg">:{emoji.name}:</p> | |
| 130 | <p className="flex min-w-0 items-center gap-1.5 truncate text-xs text-faint"> | |
| 131 | {emoji.alias_of ? ( | |
| 132 | <span className="truncate"> | |
| 133 | Alias of <span className="font-mono text-muted">:{emoji.alias_of}:</span> | |
| 134 | </span> | |
| 135 | ) : ( | |
| 136 | <> | |
| 137 | <MemberAvatar member={emoji.created_by} size={14} /> | |
| 138 | <span className="truncate">{emoji.created_by.display_name || emoji.created_by.name}</span> | |
| 139 | <span aria-hidden>·</span> | |
| 140 | <TimeAgo at={emoji.created_at} /> | |
| 141 | </> | |
| 142 | )} | |
| 143 | </p> | |
| 144 | {fetcher.data && !fetcher.data.ok && <p className="mt-0.5 text-xs text-danger">{fetcher.data.error}</p>} | |
| 145 | </div> | |
| 146 | {removable && | |
| 147 | (confirm ? ( | |
| 148 | <div className="flex shrink-0 items-center gap-1"> | |
| 149 | <Button type="button" onClick={() => setConfirm(false)} variant="ghost" size="inline" className="px-2 py-1 text-xs font-normal"> | |
| 150 | Keep | |
| 151 | </Button> | |
| 152 | <Button | |
| 153 | type="button" | |
| 154 | disabled={busy} | |
| 155 | onClick={() => fetcher.submit({ intent: "remove", name: emoji.name }, { method: "post" })} | |
| 156 | size="inline" | |
| 157 | className="bg-danger/15 px-2 py-1 text-xs text-danger hover:bg-danger/25 disabled:opacity-60" | |
| 158 | > | |
| 159 | {busy ? "Removing…" : emoji.alias_of ? "Remove" : "Remove with aliases"} | |
| 160 | </Button> | |
| 161 | </div> | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 162 | ) : ( |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 163 | <Hint label={`Remove :${emoji.name}:`}> |
| 164 | <Button | |
| 165 | type="button" | |
| 166 | aria-label={`Remove :${emoji.name}:`} | |
| 167 | onClick={() => setConfirm(true)} | |
| 168 | variant="ghost" | |
| 169 | size="icon-sm" | |
| 170 | className="text-faint opacity-100 transition hover:text-danger sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100" | |
| 171 | > | |
| 172 | <Trash2 size={15} /> | |
| 173 | </Button> | |
| 174 | </Hint> | |
| 175 | ))} | |
| 176 | </li> | |
| 177 | </Card> | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 178 | ); |
| 179 | } | |
| 180 | ||
| 181 | /** Owners: who may add emoji. */ | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 182 | /** Who may add emoji: one of the workspace's chat settings, changed in Settings, Chat. */ |
| 183 | function UploadSetting({ slug, value }: { slug: string; value: EmojiUpload }) { | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 184 | return ( |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 185 | <Card asChild tone="plain" className="flex flex-wrap items-center justify-between gap-3 p-4"> |
| 186 | <section aria-labelledby="emoji-upload"> | |
| 187 | <div> | |
| 188 | <h2 id="emoji-upload" className="text-sm font-medium text-fg"> | |
| 189 | Who can add emoji: {value === "admins" ? "owners only" : "any member"} | |
| 190 | </h2> | |
| 191 | <p className="mt-1 text-xs text-muted">Whoever added an emoji, and owners, can remove it. Who can add them is one of the workspace's chat settings.</p> | |
| 192 | </div> | |
| 193 | <Link | |
| 194 | to={`/${slug}/-/settings/chat`} | |
| 195 | className="inline-flex h-8 items-center rounded-md border border-line px-3 text-[0.8125rem] font-medium text-fg/90 transition-colors hover:border-line-strong hover:bg-surface" | |
| 196 | > | |
| 197 | Chat settings | |
| 198 | </Link> | |
| 199 | </section> | |
| 200 | </Card> | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 201 | ); |
| 202 | } | |
| 203 | ||
| 204 | /** What a chosen file is, read in the browser before sending it: the chat service checks again. */ | |
| 205 | type Chosen = { file: File; url: string; width: number; height: number; problem: string | null }; | |
| 206 | ||
| 207 | async function inspect(file: File): Promise<Chosen> { | |
| 208 | const url = URL.createObjectURL(file); | |
| 209 | const size = await new Promise<{ width: number; height: number }>((resolve) => { | |
| 210 | const image = new Image(); | |
| 211 | image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight }); | |
| 212 | image.onerror = () => resolve({ width: 0, height: 0 }); | |
| 213 | image.src = url; | |
| 214 | }); | |
| 215 | let problem: string | null = null; | |
| 216 | if (!["image/png", "image/gif", "image/webp"].includes(file.type)) problem = "Use a PNG, GIF or WebP image."; | |
| 217 | else if (file.size > MAX_EMOJI_BYTES) problem = `This image is ${Math.ceil(file.size / 1024)} KB; the most is ${MAX_EMOJI_BYTES / 1024} KB.`; | |
| 218 | else if (!size.width) problem = "This file couldn’t be read as an image."; | |
| 219 | else if (size.width > MAX_EMOJI_SIDE || size.height > MAX_EMOJI_SIDE) problem = `This image is ${size.width}×${size.height}; the most is ${MAX_EMOJI_SIDE}×${MAX_EMOJI_SIDE}.`; | |
| 220 | return { file, url, ...size, problem }; | |
| 221 | } | |
| 222 | ||
| 223 | /** Adding an emoji: an image, previewed as messages and reactions will show it, and its name. */ | |
| 224 | export function UploadDialog({ | |
| 225 | open, | |
| 226 | onOpenChange, | |
| 227 | taken, | |
| 228 | initial, | |
| 229 | }: { | |
| 230 | open: boolean; | |
| 231 | onOpenChange: (open: boolean) => void; | |
| 232 | taken: ReadonlySet<string>; | |
| 233 | /** For a preview without a real file (the screenshots harness). */ | |
| 234 | initial?: { url: string; name: string; width: number; height: number; bytes: number }; | |
| 235 | }) { | |
| 236 | const fetcher = useFetcher<EmojiActionResult>(); | |
| 237 | const input = useRef<HTMLInputElement>(null); | |
| 238 | const [chosen, setChosen] = useState<Chosen | null>(null); | |
| 239 | const [name, setName] = useState(initial?.name ?? ""); | |
| 240 | const [named, setNamed] = useState(Boolean(initial)); | |
| 241 | const [standard, setStandard] = useState<ReadonlySet<string> | null>(null); | |
| 242 | const busy = fetcher.state !== "idle"; | |
| 243 | ||
| 244 | useEffect(() => { | |
| 245 | if (!open) return; | |
| 246 | void loadEmojiData() | |
| 247 | .then((data) => setStandard(standardCodes(data))) | |
| 248 | .catch(() => {}); | |
| 249 | }, [open]); | |
| 250 | useEffect(() => { | |
| 251 | if (fetcher.state === "idle" && fetcher.data?.ok && fetcher.data.intent === "add") { | |
| 252 | onOpenChange(false); | |
| 253 | setChosen(null); | |
| 254 | setName(""); | |
| 255 | setNamed(false); | |
| 256 | } | |
| 257 | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 258 | }, [fetcher.state, fetcher.data]); | |
| 259 | useEffect(() => () => { | |
| 260 | if (chosen) URL.revokeObjectURL(chosen.url); | |
| 261 | }, [chosen]); | |
| 262 | ||
| 263 | const clean = cleanEmojiName(name); | |
| 264 | const nameProblem = name ? emojiNameProblem(clean, taken, standard) : null; | |
| 265 | const preview = chosen?.url ?? initial?.url ?? null; | |
| 266 | const problem = chosen?.problem ?? null; | |
| 267 | const ready = Boolean((chosen && !problem) || initial) && clean && !nameProblem && !busy; | |
| 268 | ||
| 269 | const onFile = async (event: ChangeEvent<HTMLInputElement>) => { | |
| 270 | const file = event.target.files?.[0]; | |
| 271 | if (!file) return; | |
| 272 | const seen = await inspect(file); | |
| 273 | setChosen(seen); | |
| 274 | // The file's name suggests the emoji's, until a name is typed. | |
| 275 | if (!named) setName(cleanEmojiName(file.name.replace(/\.[a-z0-9]+$/i, "")).replace(/[^a-z0-9_+-]/g, "").slice(0, 32)); | |
| 276 | }; | |
| 277 | ||
| 278 | const submit = () => { | |
| 279 | if (!chosen || !ready) return; | |
| 280 | const form = new FormData(); | |
| 281 | form.set("intent", "add"); | |
| 282 | form.set("name", clean); | |
| 283 | form.set("file", chosen.file); | |
| 284 | fetcher.submit(form, { method: "post", encType: "multipart/form-data" }); | |
| 285 | }; | |
| 286 | ||
| 287 | return ( | |
| 288 | <Dialog open={open} onOpenChange={onOpenChange}> | |
| 289 | <DialogContent className="max-w-md"> | |
| 290 | <DialogHeader> | |
| 291 | <DialogTitle>Add an emoji</DialogTitle> | |
| 292 | <DialogDescription>PNG, GIF or WebP, up to {MAX_EMOJI_BYTES / 1024} KB and {MAX_EMOJI_SIDE}×{MAX_EMOJI_SIDE}. Animated GIFs play.</DialogDescription> | |
| 293 | </DialogHeader> | |
| 294 | <input ref={input} type="file" accept={ACCEPT} onChange={onFile} className="sr-only" tabIndex={-1} aria-hidden /> | |
| 295 | <button | |
| 296 | type="button" | |
| 297 | onClick={() => input.current?.click()} | |
| 298 | className={`flex min-h-32 flex-col items-center justify-center gap-2 rounded-xl border border-dashed px-4 py-5 text-sm transition-colors ${ | |
| 299 | problem ? "border-danger/60 bg-danger/5" : "border-line-strong hover:border-accent/50 hover:bg-surface" | |
| 300 | }`} | |
| 301 | > | |
| 302 | {preview ? ( | |
| 303 | <> | |
| 304 | <img src={preview} alt="" className="size-16 object-contain" /> | |
| 305 | <span className="text-xs text-muted"> | |
| 306 | {chosen ? `${chosen.width}×${chosen.height} · ${Math.ceil(chosen.file.size / 1024)} KB` : initial ? `${initial.width}×${initial.height} · ${Math.ceil(initial.bytes / 1024)} KB` : ""} ·{" "} | |
| 307 | <span className="text-accent">Choose another</span> | |
| 308 | </span> | |
| 309 | </> | |
| 310 | ) : ( | |
| 311 | <> | |
| 312 | <Upload size={20} className="text-faint" /> | |
| 313 | <span className="font-medium text-fg-soft">Choose an image</span> | |
| 314 | </> | |
| 315 | )} | |
| 316 | </button> | |
| 317 | {problem && <ErrorText>{problem}</ErrorText>} | |
| 318 | ||
| 319 | <label className="block"> | |
| 320 | <span className="text-sm font-medium text-fg">Name</span> | |
| 321 | <span className="mt-1.5 flex items-center rounded-md border border-line-strong bg-bg focus-within:border-accent/50"> | |
| 322 | <span className="pl-2.5 font-mono text-sm text-faint">:</span> | |
| 323 | <input | |
| 324 | value={name} | |
| 325 | onChange={(event) => { | |
| 326 | setName(event.target.value); | |
| 327 | setNamed(true); | |
| 328 | }} | |
| 329 | placeholder="shipit" | |
| 330 | maxLength={40} | |
| 331 | {...notACredential()} | |
| 332 | aria-invalid={Boolean(nameProblem)} | |
| 333 | className="min-w-0 grow bg-transparent px-0.5 py-1.5 font-mono text-sm text-fg outline-none placeholder:text-faint" | |
| 334 | /> | |
| 335 | <span className="pr-2.5 font-mono text-sm text-faint">:</span> | |
| 336 | </span> | |
| 337 | <span className={`mt-1 block text-xs ${nameProblem ? "text-danger" : "text-faint"}`}> | |
| 338 | {nameProblem ?? "Lowercase letters, digits, -, _ and +; 2 to 32 characters."} | |
| 339 | </span> | |
| 340 | </label> | |
| 341 | ||
| 342 | {preview && ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 343 | <Card tone="bg" className="p-3" aria-label="Preview"> |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 344 | <p className="mb-2 text-[0.6875rem] font-semibold tracking-wide text-faint uppercase">Preview</p> |
| 345 | <p className="text-[0.9375rem] text-fg-soft"> | |
| 346 | Shipping it today <img src={preview} alt={`:${clean || "name"}:`} className="inline-block size-[1.375em] object-contain align-[-0.3em]" /> | |
| 347 | </p> | |
| 348 | <div className="mt-2 flex items-center gap-2"> | |
| 349 | <span className="flex h-6 items-center gap-1 rounded-full border border-accent/60 bg-accent/15 px-1.5 text-xs font-medium text-accent"> | |
| 350 | <img src={preview} alt="" className="size-[15px] object-contain" />3 | |
| 351 | </span> | |
| 352 | <img src={preview} alt="" className="size-9 object-contain" /> | |
| 353 | <span className="ml-auto font-mono text-xs text-faint">:{clean || "name"}:</span> | |
| 354 | </div> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 355 | </Card> |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 356 | )} |
| 357 | {fetcher.data && !fetcher.data.ok && fetcher.data.intent === "add" && <ErrorText>{fetcher.data.error}</ErrorText>} | |
| 358 | ||
| 359 | <DialogFooter> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 360 | <Button variant="outline" onClick={() => onOpenChange(false)}> |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 361 | Cancel |
| 362 | </Button> | |
| 363 | <Button variant="accent" onClick={submit} disabled={!ready}> | |
| 364 | {busy ? "Adding…" : "Add emoji"} | |
| 365 | </Button> | |
| 366 | </DialogFooter> | |
| 367 | </DialogContent> | |
| 368 | </Dialog> | |
| 369 | ); | |
| 370 | } | |
| 371 | ||
| 372 | /** Another name for an emoji the workspace has. */ | |
| 373 | function AliasDialog({ | |
| 374 | open, | |
| 375 | onOpenChange, | |
| 376 | emoji, | |
| 377 | taken, | |
| 378 | usercontent, | |
| 379 | }: { | |
| 380 | open: boolean; | |
| 381 | onOpenChange: (open: boolean) => void; | |
| 382 | emoji: CustomEmoji[]; | |
| 383 | taken: ReadonlySet<string>; | |
| 384 | usercontent: string; | |
| 385 | }) { | |
| 386 | const fetcher = useFetcher<EmojiActionResult>(); | |
| 387 | const originals = emoji.filter((e) => !e.alias_of); | |
| 388 | const [target, setTarget] = useState(originals[0]?.name ?? ""); | |
| 389 | const [name, setName] = useState(""); | |
| 390 | const clean = cleanEmojiName(name); | |
| 391 | const problem = name ? emojiNameProblem(clean, taken, null) : null; | |
| 392 | const busy = fetcher.state !== "idle"; | |
| 393 | useEffect(() => { | |
| 394 | if (fetcher.state === "idle" && fetcher.data?.ok && fetcher.data.intent === "alias") { | |
| 395 | onOpenChange(false); | |
| 396 | setName(""); | |
| 397 | } | |
| 398 | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 399 | }, [fetcher.state, fetcher.data]); | |
| 400 | return ( | |
| 401 | <Dialog open={open} onOpenChange={onOpenChange}> | |
| 402 | <DialogContent className="max-w-md"> | |
| 403 | <DialogHeader> | |
| 404 | <DialogTitle>Add an alias</DialogTitle> | |
| 405 | <DialogDescription>Another name for an emoji: both show the same image. Removing the emoji removes its aliases.</DialogDescription> | |
| 406 | </DialogHeader> | |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 407 | <div className="block"> |
| 408 | <span id="alias-target-label" className="text-sm font-medium text-fg"> | |
| 409 | Emoji | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 410 | </span> |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 411 | <SelectField |
| 412 | aria-labelledby="alias-target-label" | |
| 413 | value={target} | |
| 414 | onValueChange={setTarget} | |
| 415 | className="mt-1.5 border-line-strong font-mono" | |
| 416 | itemClassName="font-mono" | |
| 417 | options={originals.map((e) => ({ | |
| 418 | value: e.name, | |
| 419 | label: `:${e.name}:`, | |
| 420 | icon: <CustomEmojiImage name={e.name} file={e.file} usercontent={usercontent} size={18} />, | |
| 421 | }))} | |
| 422 | /> | |
| 423 | </div> | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 424 | <label className="block"> |
| 425 | <span className="text-sm font-medium text-fg">Alias</span> | |
| 426 | <input | |
| 427 | value={name} | |
| 428 | onChange={(event) => setName(event.target.value)} | |
| 429 | placeholder="ship-it" | |
| 430 | {...notACredential()} | |
| 431 | className="mt-1.5 w-full rounded-md border border-line-strong bg-bg px-2.5 py-1.5 font-mono text-sm text-fg outline-none placeholder:text-faint focus:border-accent/50" | |
| 432 | /> | |
| 433 | {problem && <span className="mt-1 block text-xs text-danger">{problem}</span>} | |
| 434 | </label> | |
| 435 | {fetcher.data && !fetcher.data.ok && fetcher.data.intent === "alias" && <ErrorText>{fetcher.data.error}</ErrorText>} | |
| 436 | <DialogFooter> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 437 | <Button variant="outline" onClick={() => onOpenChange(false)}> |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 438 | Cancel |
| 439 | </Button> | |
| 440 | <Button | |
| 441 | variant="accent" | |
| 442 | disabled={!clean || Boolean(problem) || !target || busy} | |
| 443 | onClick={() => fetcher.submit({ intent: "alias", name: clean, target }, { method: "post" })} | |
| 444 | > | |
| 445 | {busy ? "Adding…" : "Add alias"} | |
| 446 | </Button> | |
| 447 | </DialogFooter> | |
| 448 | </DialogContent> | |
| 449 | </Dialog> | |
| 450 | ); | |
| 451 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.