Skip to content

g1t/apps/sudo/app/components/ui.tsx

286 lines11,829 bytesCodeBlame

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.

Billing accounts, terms and enterprises; g1t is no longer free1/**
2 * sudo's building blocks, after apps/web/app/components/ui. None of them
3 * sets a `style` attribute: the content security policy allows no inline
4 * styles, so sizes and colours that vary are drawn as SVG attributes.
5 */
6import { AlertTriangle, CheckCircle2, Info } from "lucide-react";
7import type { ComponentProps, ReactNode } from "react";
Fast pages, required checks on the branch, self-hosted runners, honest incidents8import { Link, type LinkProps, useRouteLoaderData } from "react-router";
Billing accounts, terms and enterprises; g1t is no longer free9
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace10import type { Limit, Terms, Trust } from "@g1t/contracts";
Billing accounts, terms and enterprises; g1t is no longer free11
12import { usd } from "~/lib/money";
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging13import { fullDiscount, termsLabel } from "~/lib/terms";
Fast pages, required checks on the branch, self-hosted runners, honest incidents14import { when } from "~/lib/time";
Billing accounts, terms and enterprises; g1t is no longer free15
16export function Field({
17 label,
18 hint,
19 children,
20 className = "",
21}: {
22 label: string;
23 hint?: ReactNode;
24 children: ReactNode;
25 className?: string;
26}) {
27 return (
28 <label className={`block ${className}`}>
29 <span className="mb-1.5 block text-sm font-medium text-muted">{label}</span>
30 {children}
31 {hint && <span className="mt-1.5 block text-xs text-faint">{hint}</span>}
32 </label>
33 );
34}
35
36const CONTROL =
37 "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-merged/60";
38
39/** Tells password managers that a field is not a login. */
40const NOT_A_CREDENTIAL = {
41 autoComplete: "off",
42 "data-1p-ignore": true,
43 "data-lpignore": "true",
44 "data-bwignore": true,
45 "data-form-type": "other",
46};
47
48export function Input({ className = "", ...props }: ComponentProps<"input">) {
49 return <input {...NOT_A_CREDENTIAL} {...props} className={`${CONTROL} ${className}`} />;
50}
51
52export function Textarea({ className = "", ...props }: ComponentProps<"textarea">) {
53 return <textarea {...NOT_A_CREDENTIAL} {...props} className={`${CONTROL} ${className}`} />;
54}
55
56export function Select({ className = "", ...props }: ComponentProps<"select">) {
57 return <select {...props} className={`${CONTROL} ${className}`} />;
58}
59
60type Variant = "primary" | "quiet" | "danger" | "lavender";
61
62const BUTTON_BASE =
63 "inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium whitespace-nowrap transition-colors disabled:opacity-50";
64
65const BUTTON_VARIANTS: Record<Variant, string> = {
66 primary: "bg-fg text-bg hover:bg-white",
67 lavender: "bg-merged text-bg hover:bg-[#c8bdff]",
68 quiet: "border border-line text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg",
69 danger: "border border-danger/40 text-danger hover:border-danger/70 hover:bg-danger/10",
70};
71
72export function Button({ variant = "primary", className = "", ...props }: ComponentProps<"button"> & { variant?: Variant }) {
73 return <button {...props} className={`${BUTTON_BASE} ${BUTTON_VARIANTS[variant]} ${className}`} />;
74}
75
76export function ButtonLink({ variant = "primary", className = "", ...props }: LinkProps & { variant?: Variant }) {
77 return <Link {...props} className={`${BUTTON_BASE} ${BUTTON_VARIANTS[variant]} ${className}`} />;
78}
79
80/** A bordered panel with a heading. */
81export function Section({
82 id,
83 title,
84 description,
85 actions,
86 children,
87 className = "",
88}: {
89 id?: string;
90 title: string;
91 description?: ReactNode;
92 actions?: ReactNode;
93 children: ReactNode;
94 className?: string;
95}) {
96 return (
97 <section id={id} className={`scroll-mt-20 rounded-lg border border-line bg-surface ${className}`}>
98 <header className="flex flex-wrap items-start justify-between gap-3 border-b border-line px-4 py-3 sm:px-5">
99 <div>
100 <h2 className="text-[0.9375rem] font-semibold tracking-tight">{title}</h2>
101 {description && <p className="mt-0.5 text-sm text-muted">{description}</p>}
102 </div>
103 {actions}
104 </header>
105 <div className="p-4 sm:p-5">{children}</div>
106 </section>
107 );
108}
109
110export function EmptyState({ title, children }: { title: string; children?: ReactNode }) {
111 return (
112 <div className="rounded-lg border border-dashed border-line px-6 py-10 text-center">
113 <p className="font-medium">{title}</p>
114 {children && <div className="mt-1.5 text-sm text-muted">{children}</div>}
115 </div>
116 );
117}
118
119const NOTICE = {
120 ok: { icon: CheckCircle2, className: "border-accent/30 bg-accent/8 text-accent" },
121 error: { icon: AlertTriangle, className: "border-danger/30 bg-danger/8 text-danger" },
122 warn: { icon: AlertTriangle, className: "border-warn/30 bg-warn/8 text-warn" },
123 info: { icon: Info, className: "border-merged/30 bg-merged/8 text-merged" },
124} as const;
125
126export function Notice({ tone, children }: { tone: keyof typeof NOTICE; children: ReactNode }) {
127 const { icon: Icon, className } = NOTICE[tone];
128 return (
129 <div role={tone === "error" ? "alert" : "status"} className={`flex gap-2.5 rounded-md border px-3.5 py-2.5 text-sm ${className}`}>
130 <Icon size={16} className="mt-0.5 shrink-0" />
131 <div className="min-w-0 text-fg-soft">{children}</div>
132 </div>
133 );
134}
135
136/** A small label; `tone` colours it. */
137export function Badge({ tone = "plain", children }: { tone?: "plain" | "lavender" | "mint" | "warn" | "danger" | "info"; children: ReactNode }) {
138 const tones = {
139 plain: "border-line text-muted",
140 lavender: "border-merged/35 bg-merged/10 text-merged",
141 mint: "border-accent/30 bg-accent/8 text-accent",
142 warn: "border-warn/35 bg-warn/10 text-warn",
143 danger: "border-danger/35 bg-danger/10 text-danger",
144 info: "border-info/35 bg-info/10 text-info",
145 };
146 return (
147 <span className={`inline-flex items-center rounded-full border px-2 py-px text-xs font-medium whitespace-nowrap ${tones[tone]}`}>
148 {children}
149 </span>
150 );
151}
152
153export function TermsBadge({ terms }: { terms: Terms }) {
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging154 if (fullDiscount(terms)) return <Badge tone="mint">100% discount</Badge>;
155 if (terms.kind === "standard") return <Badge>Standard</Badge>;
156 return <Badge tone="info">{termsLabel(terms)}</Badge>;
Billing accounts, terms and enterprises; g1t is no longer free157}
158
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales159const TRUST: Record<Trust, { label: string; tone: "plain" | "lavender" | "mint" | "info"; about: string }> = {
160 new: { label: "New", tone: "plain", about: "New: has not paid g1t yet, so the limit is small." },
161 paid: { label: "Paid", tone: "info", about: "Paid: has paid g1t, and the limit grows with what it has paid." },
162 established: {
163 label: "Established",
164 tone: "mint",
165 about: "Established: the limit follows their monthly spend, up to $10,000.",
166 },
167 reviewed: { label: "Reviewed", tone: "mint", about: "Reviewed: g1t set the limit by hand, after talking to them." },
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging168 // A 100% discount: nothing is charged, so no limit on unpaid usage.
169 internal: { label: "100% discount", tone: "lavender", about: "A 100% discount: nothing is charged, so there is no limit on unpaid usage." },
Billing accounts, terms and enterprises; g1t is no longer free170};
171
172export function TrustBadge({ trust }: { trust: Trust }) {
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales173 const { label, tone, about } = TRUST[trust] ?? { label: trust, tone: "plain", about: "" };
174 return (
175 <span title={about || undefined}>
176 <Badge tone={tone}>{label}</Badge>
177 </span>
178 );
179}
180
181/** What a trust level means for the limit, in a sentence. */
182export function trustAbout(trust: Trust): string {
183 return TRUST[trust]?.about ?? "";
184}
185
186/** A page's title, a line about it, and anything to its right. */
187export function PageHeader({ title, description, actions }: { title: ReactNode; description?: ReactNode; actions?: ReactNode }) {
188 return (
189 <div className="flex flex-wrap items-start justify-between gap-4">
190 <div className="min-w-0">
191 <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
192 {description && <p className="mt-1 text-sm text-muted">{description}</p>}
193 </div>
194 {actions}
195 </div>
196 );
Billing accounts, terms and enterprises; g1t is no longer free197}
198
199const STATE = {
200 ok: { label: "OK", fill: "var(--g1t-accent)", text: "text-accent" },
201 warning: { label: "Warning", fill: "var(--g1t-warn)", text: "text-warn" },
202 stopped: { label: "Stopped", fill: "var(--g1t-danger)", text: "text-danger" },
203} as const;
204
205export function StateBadge({ state }: { state: Limit["state"] }) {
206 const tone = state === "stopped" ? "danger" : state === "warning" ? "warn" : "mint";
207 return <Badge tone={tone}>{STATE[state].label}</Badge>;
208}
209
210/**
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace211 * Usage this month that is not paid for yet, against the limit, as a bar
212 * coloured by where it stands. With no limit (comped) it shows the figure.
Billing accounts, terms and enterprises; g1t is no longer free213 */
214export function ExposureBar({ limit, wide = false }: { limit: Limit; wide?: boolean }) {
215 const { exposureMicros, ceilingMicros, state } = limit;
216 const share = ceilingMicros && ceilingMicros > 0 ? Math.min(1, Math.max(0, exposureMicros / ceilingMicros)) : 0;
217 const percent = ceilingMicros && ceilingMicros > 0 ? Math.round((exposureMicros / ceilingMicros) * 100) : null;
218 return (
219 <div className={wide ? "w-full" : "w-40 max-w-full"}>
220 <div className="flex items-baseline justify-between gap-2 text-xs">
221 <span className="tabular font-medium text-fg-soft">
222 {usd(exposureMicros)}
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace223 <span className="font-normal text-faint"> / {ceilingMicros == null ? "no limit" : usd(ceilingMicros)}</span>
Billing accounts, terms and enterprises; g1t is no longer free224 </span>
225 {percent != null && <span className={`tabular ${STATE[state].text}`}>{percent}%</span>}
226 </div>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace227 <svg viewBox="0 0 100 4" preserveAspectRatio="none" className="mt-1.5 block h-1.5 w-full" role="img" aria-label={`${STATE[state].label}: ${percent ?? 0}% of the limit`}>
Billing accounts, terms and enterprises; g1t is no longer free228 <rect x="0" y="0" width="100" height="4" rx="2" fill="var(--g1t-raised)" />
229 {ceilingMicros != null && share > 0 && (
230 <rect x="0" y="0" width={Math.max(2, share * 100)} height="4" rx="2" fill={STATE[state].fill} />
231 )}
232 </svg>
233 </div>
234 );
235}
236
237/** A label and a figure, for the strip of totals over a page. */
238export function Stat({ label, value, hint, tone }: { label: string; value: ReactNode; hint?: ReactNode; tone?: "danger" | "warn" | "mint" }) {
239 const color = tone === "danger" ? "text-danger" : tone === "warn" ? "text-warn" : tone === "mint" ? "text-accent" : "text-fg";
240 return (
241 <div className="rounded-lg border border-line bg-surface px-4 py-3">
242 <p className="text-xs text-muted">{label}</p>
243 <p className={`tabular mt-1 text-lg font-semibold tracking-tight ${color}`}>{value}</p>
244 {hint && <p className="mt-0.5 text-xs text-faint">{hint}</p>}
245 </div>
246 );
247}
248
Fast pages, required checks on the branch, self-hosted runners, honest incidents249/** The zone the pages say times in, from the root loader (lib/time.ts); UTC without one. */
250export function useZone(): string {
251 return useRouteLoaderData<{ zone?: string }>("root")?.zone ?? "UTC";
252}
Billing accounts, terms and enterprises; g1t is no longer free253
Fast pages, required checks on the branch, self-hosted runners, honest incidents254/**
255 * A timestamp in the staff member's zone, with the zone's abbreviation;
256 * the UTC instant in `datetime` and on hover, for comparing notes.
257 */
Billing accounts, terms and enterprises; g1t is no longer free258export function When({ at, time = false }: { at: string | null | undefined; time?: boolean }) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents259 const zone = useZone();
Billing accounts, terms and enterprises; g1t is no longer free260 if (!at) return <span className="text-faint">—</span>;
261 const date = new Date(at);
262 if (Number.isNaN(date.getTime())) return <span>{at}</span>;
Fast pages, required checks on the branch, self-hosted runners, honest incidents263 const shown = when(date, zone, time);
Billing accounts, terms and enterprises; g1t is no longer free264 return (
Fast pages, required checks on the branch, self-hosted runners, honest incidents265 <time dateTime={date.toISOString()} title={shown.utc}>
266 {shown.text}
267 {shown.zone && <span className="text-faint"> {shown.zone}</span>}
Billing accounts, terms and enterprises; g1t is no longer free268 </time>
269 );
270}
271
272/** A letter avatar, drawn as SVG so its colour needs no inline style. */
273export function Avatar({ name, size = 20, square = true }: { name: string; size?: number; square?: boolean }) {
274 const hues = [82, 200, 262, 28, 330, 160];
275 let hash = 0;
276 for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
277 const hue = hues[Math.abs(hash) % hues.length];
278 return (
279 <svg width={size} height={size} viewBox="0 0 20 20" aria-hidden="true" className="shrink-0">
280 <rect width="20" height="20" rx={square ? 5 : 10} fill={`oklch(0.4 0.09 ${hue})`} />
281 <text x="10" y="14.2" textAnchor="middle" fontSize="11" fontWeight="600" fontFamily="var(--g1t-font-mono)" fill={`oklch(0.93 0.08 ${hue})`}>
282 {(name[0] ?? "?").toUpperCase()}
283 </text>
284 </svg>
285 );
286}