flagon-io/g1t

public

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

g1t/apps/web/app/components/ui/index.tsx

303 lines8,908 bytesCodeBlame
1import { Check, Copy, Sparkles } from "lucide-react";
2import { type ComponentProps, type ReactNode, useState } from "react";
3import { Link, type LinkProps, NavLink, useLocation } from "react-router";
4
5export function Field({
6 label,
7 hint,
8 children,
9}: {
10 label: string;
11 hint?: string;
12 children: ReactNode;
13}) {
14 return (
15 <label className="block">
16 <span className="mb-1.5 block text-sm font-medium text-muted">
17 {label}
18 </span>
19 {children}
20 {hint && <span className="mt-1.5 block text-xs text-faint">{hint}</span>}
21 </label>
22 );
23}
24
25const CONTROL =
26 "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
27
28/**
29 * Attributes for a field that is not part of signing in. Password managers
30 * guess from names such as "name", "title" and "username" and offer to
31 * fill a login into them; these tell 1Password, LastPass, Bitwarden and
32 * Dashlane to leave the field alone. A field that states what it takes with
33 * `autoComplete` is left to them.
34 */
35export function notACredential(autoComplete?: string) {
36 if (autoComplete) return {};
37 return {
38 autoComplete: "off",
39 "data-1p-ignore": true,
40 "data-lpignore": "true",
41 "data-bwignore": true,
42 "data-form-type": "other",
43 };
44}
45
46export function Input(props: ComponentProps<"input">) {
47 return <input {...notACredential(props.autoComplete)} {...props} className={CONTROL} />;
48}
49
50export function Textarea(props: ComponentProps<"textarea">) {
51 return (
52 <textarea
53 {...notACredential(props.autoComplete)}
54 {...props}
55 className={`${CONTROL} font-mono`}
56 />
57 );
58}
59
60/** One tab in the row under a repository's or a workspace's header. */
61export function TabLink({
62 to,
63 also,
64 end,
65 icon,
66 count,
67 children,
68}: {
69 to: string;
70 /** Another path prefix under which this tab is the current one. */
71 also?: string;
72 end?: boolean;
73 icon: ReactNode;
74 count?: number;
75 children: ReactNode;
76}) {
77 const { pathname } = useLocation();
78 return (
79 <NavLink
80 to={to}
81 end={end}
82 className={({ isActive }) =>
83 `-mb-px flex items-center gap-2 border-b-2 px-1 pb-3 text-sm whitespace-nowrap transition-colors ${
84 isActive || (also && pathname.startsWith(also + "/"))
85 ? "border-accent font-medium text-fg"
86 : "border-transparent text-muted hover:text-fg"
87 }`
88 }
89 >
90 {icon}
91 {children}
92 {count != null && count > 0 && (
93 <span className="rounded-full bg-raised px-1.5 py-px text-xs text-muted">
94 {count}
95 </span>
96 )}
97 </NavLink>
98 );
99}
100
101type Variant = "primary" | "accent" | "quiet";
102
103const BUTTON_BASE =
104 "inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium transition-colors disabled:opacity-50";
105
106const BUTTON_VARIANTS: Record<Variant, string> = {
107 primary: "bg-fg text-bg hover:bg-white",
108 accent: "bg-accent text-bg hover:bg-[#aaf5d6]",
109 quiet:
110 "border border-line text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg",
111};
112
113export function Button({
114 variant = "primary",
115 ...props
116}: ComponentProps<"button"> & { variant?: Variant }) {
117 return (
118 <button {...props} className={`${BUTTON_BASE} ${BUTTON_VARIANTS[variant]}`} />
119 );
120}
121
122/** A link that looks like a button. */
123export function ButtonLink({
124 variant = "primary",
125 large,
126 ...props
127}: LinkProps & { variant?: Variant; large?: boolean }) {
128 return (
129 <Link
130 {...props}
131 className={`${BUTTON_BASE} ${BUTTON_VARIANTS[variant]} ${
132 large ? "rounded-full px-5 py-2.5" : ""
133 }`}
134 />
135 );
136}
137
138export function ErrorText({ children }: { children: ReactNode }) {
139 return children ? <p className="text-sm text-danger">{children}</p> : null;
140}
141
142/** A small outlined label. */
143export function Pill({ children }: { children: ReactNode }) {
144 return (
145 <span className="inline-flex items-center rounded-full border border-line px-2 py-0.5 text-xs text-muted">
146 {children}
147 </span>
148 );
149}
150
151const AVATAR_HUES = [82, 200, 262, 28, 330, 160];
152
153/** How g1t's agents are named: as reviewers and assignees, and as commit authors. */
154const AGENT_NAMES = new Set(["g1t-agent", "g1t agent", "g1t"]);
155
156/** Where an uploaded avatar is served, from the hash it is stored by. */
157export function avatarUrl(avatar: string): string {
158 return `/avatars/${avatar}`;
159}
160
161/**
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.
165 */
166export function Avatar({
167 name,
168 size = 20,
169 square,
170 image,
171}: {
172 name: string;
173 size?: number;
174 square?: boolean;
175 /** The uploaded avatar's hash, as identity returns it. */
176 image?: string | null;
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 }
195 // g1t's own agents wear the agent colour and mark everywhere they appear.
196 if (AGENT_NAMES.has(name)) {
197 return (
198 <span
199 aria-hidden="true"
200 className="inline-flex shrink-0 items-center justify-center bg-merged/20 text-merged ring-1 ring-merged/40 ring-inset"
201 style={{ width: size, height: size, borderRadius: size * 0.3 }}
202 >
203 <Sparkles size={Math.round(size * 0.58)} />
204 </span>
205 );
206 }
207 let hash = 0;
208 for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
209 const hue = AVATAR_HUES[Math.abs(hash) % AVATAR_HUES.length];
210 return (
211 <span
212 aria-hidden="true"
213 className="inline-flex shrink-0 items-center justify-center font-mono font-semibold uppercase"
214 style={{
215 width: size,
216 height: size,
217 borderRadius: square ? size * 0.24 : size,
218 fontSize: size * 0.5,
219 background: `oklch(0.4 0.09 ${hue})`,
220 color: `oklch(0.93 0.08 ${hue})`,
221 }}
222 >
223 {name[0]}
224 </span>
225 );
226}
227
228/** A line of text (usually a command) with a copy button. */
229export function CopyLine({
230 text,
231 prompt,
232}: {
233 text: string;
234 prompt?: boolean;
235}) {
236 const [copied, setCopied] = useState(false);
237 return (
238 <div className="group flex items-center gap-3 rounded-lg border border-line bg-surface py-2 pr-2 pl-3.5 font-mono text-[0.8125rem]">
239 {/* Wraps rather than hides: a command or an address is no use half seen. */}
240 <code className="min-w-0 grow whitespace-pre-wrap [overflow-wrap:anywhere]">
241 {prompt && <span className="mr-2 text-faint select-none">$</span>}
242 {/* Short words with hyphens, such as `--transport`, stay whole when
243 the line wraps; long ones, such as addresses, may still break. */}
244 {text.split(/(\s+)/).map((part, index) =>
245 part.includes("-") && part.length <= 24 ? (
246 <span key={index} className="whitespace-nowrap">
247 {part}
248 </span>
249 ) : (
250 part
251 ),
252 )}
253 </code>
254 <button
255 type="button"
256 aria-label="Copy"
257 className="shrink-0 rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-fg"
258 onClick={() => {
259 void navigator.clipboard.writeText(text);
260 setCopied(true);
261 setTimeout(() => setCopied(false), 1500);
262 }}
263 >
264 {copied ? <Check size={14} className="text-accent" /> : <Copy size={14} />}
265 </button>
266 </div>
267 );
268}
269
270const UNITS: [string, number][] = [
271 ["d", 86_400_000],
272 ["h", 3_600_000],
273 ["m", 60_000],
274];
275
276/** Compact relative time such as "5m ago". */
277export function TimeAgo({ at }: { at: string | number }) {
278 // Timestamps are RFC 3339 strings; a few older ones are still numbers.
279 const elapsed = Date.now() - new Date(at).getTime();
280 const unit = UNITS.find(([, size]) => elapsed >= size);
281 const label = unit ? `${Math.floor(elapsed / unit[1])}${unit[0]} ago` : "just now";
282 return (
283 // The server and the browser render at slightly different times.
284 <time dateTime={new Date(at).toISOString()} suppressHydrationWarning>
285 {label}
286 </time>
287 );
288}
289
290export function EmptyState({
291 title,
292 children,
293}: {
294 title: string;
295 children?: ReactNode;
296}) {
297 return (
298 <div className="rounded-lg border border-dashed border-line px-6 py-12 text-center">
299 <p className="font-medium">{title}</p>
300 {children && <div className="mt-1.5 text-sm text-muted">{children}</div>}
301 </div>
302 );
303}