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

277 lines8,056 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/**
157 * A letter avatar whose colour is stable for a given name. People are
158 * round; a workspace is `square`.
159 */
160export function Avatar({
161 name,
162 size = 20,
163 square,
164}: {
165 name: string;
166 size?: number;
167 square?: boolean;
168}) {
169 // g1t's own agents wear the agent colour and mark everywhere they appear.
170 if (AGENT_NAMES.has(name)) {
171 return (
172 <span
173 aria-hidden="true"
174 className="inline-flex shrink-0 items-center justify-center bg-merged/20 text-merged ring-1 ring-merged/40 ring-inset"
175 style={{ width: size, height: size, borderRadius: size * 0.3 }}
176 >
177 <Sparkles size={Math.round(size * 0.58)} />
178 </span>
179 );
180 }
181 let hash = 0;
182 for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
183 const hue = AVATAR_HUES[Math.abs(hash) % AVATAR_HUES.length];
184 return (
185 <span
186 aria-hidden="true"
187 className="inline-flex shrink-0 items-center justify-center font-mono font-semibold uppercase"
188 style={{
189 width: size,
190 height: size,
191 borderRadius: square ? size * 0.24 : size,
192 fontSize: size * 0.5,
193 background: `oklch(0.4 0.09 ${hue})`,
194 color: `oklch(0.93 0.08 ${hue})`,
195 }}
196 >
197 {name[0]}
198 </span>
199 );
200}
201
202/** A line of text (usually a command) with a copy button. */
203export function CopyLine({
204 text,
205 prompt,
206}: {
207 text: string;
208 prompt?: boolean;
209}) {
210 const [copied, setCopied] = useState(false);
211 return (
212 <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]">
213 {/* Wraps rather than hides: a command or an address is no use half seen. */}
214 <code className="min-w-0 grow whitespace-pre-wrap [overflow-wrap:anywhere]">
215 {prompt && <span className="mr-2 text-faint select-none">$</span>}
216 {/* Short words with hyphens, such as `--transport`, stay whole when
217 the line wraps; long ones, such as addresses, may still break. */}
218 {text.split(/(\s+)/).map((part, index) =>
219 part.includes("-") && part.length <= 24 ? (
220 <span key={index} className="whitespace-nowrap">
221 {part}
222 </span>
223 ) : (
224 part
225 ),
226 )}
227 </code>
228 <button
229 type="button"
230 aria-label="Copy"
231 className="shrink-0 rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-fg"
232 onClick={() => {
233 void navigator.clipboard.writeText(text);
234 setCopied(true);
235 setTimeout(() => setCopied(false), 1500);
236 }}
237 >
238 {copied ? <Check size={14} className="text-accent" /> : <Copy size={14} />}
239 </button>
240 </div>
241 );
242}
243
244const UNITS: [string, number][] = [
245 ["d", 86_400_000],
246 ["h", 3_600_000],
247 ["m", 60_000],
248];
249
250/** Compact relative time such as "5m ago". */
251export function TimeAgo({ at }: { at: string | number }) {
252 // Timestamps are RFC 3339 strings; a few older ones are still numbers.
253 const elapsed = Date.now() - new Date(at).getTime();
254 const unit = UNITS.find(([, size]) => elapsed >= size);
255 const label = unit ? `${Math.floor(elapsed / unit[1])}${unit[0]} ago` : "just now";
256 return (
257 // The server and the browser render at slightly different times.
258 <time dateTime={new Date(at).toISOString()} suppressHydrationWarning>
259 {label}
260 </time>
261 );
262}
263
264export function EmptyState({
265 title,
266 children,
267}: {
268 title: string;
269 children?: ReactNode;
270}) {
271 return (
272 <div className="rounded-lg border border-dashed border-line px-6 py-12 text-center">
273 <p className="font-medium">{title}</p>
274 {children && <div className="mt-1.5 text-sm text-muted">{children}</div>}
275 </div>
276 );
277}