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

196 lines5,873 bytesCodeBlame
1import { Check, Copy } from "lucide-react";
2import { type ComponentProps, type ReactNode, useState } from "react";
3import { Link, type LinkProps } 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
28export function Input(props: ComponentProps<"input">) {
29 return <input {...props} className={CONTROL} />;
30}
31
32export function Textarea(props: ComponentProps<"textarea">) {
33 return <textarea {...props} className={`${CONTROL} font-mono`} />;
34}
35
36type Variant = "primary" | "accent" | "quiet";
37
38const BUTTON_BASE =
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
41const BUTTON_VARIANTS: Record<Variant, string> = {
42 primary: "bg-fg text-bg hover:bg-white",
43 accent: "bg-accent text-bg hover:bg-[#c6ff5c]",
44 quiet:
45 "border border-line text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg",
46};
47
48export function Button({
49 variant = "primary",
50 ...props
51}: ComponentProps<"button"> & { variant?: Variant }) {
52 return (
53 <button {...props} className={`${BUTTON_BASE} ${BUTTON_VARIANTS[variant]}`} />
54 );
55}
56
57/** A link that looks like a button. */
58export function ButtonLink({
59 variant = "primary",
60 large,
61 ...props
62}: LinkProps & { variant?: Variant; large?: boolean }) {
63 return (
64 <Link
65 {...props}
66 className={`${BUTTON_BASE} ${BUTTON_VARIANTS[variant]} ${
67 large ? "rounded-full px-5 py-2.5" : ""
68 }`}
69 />
70 );
71}
72
73export function ErrorText({ children }: { children: ReactNode }) {
74 return children ? <p className="text-sm text-danger">{children}</p> : null;
75}
76
77const STATUS_STYLES: Record<string, string> = {
78 open: "border-accent/30 bg-accent/10 text-accent",
79 working: "border-accent/30 bg-accent/10 text-accent",
80 submitted: "border-info/30 bg-info/10 text-info",
81 shipped: "border-shipped/30 bg-shipped/10 text-shipped",
82};
83
84/** A status pill; unknown statuses render muted. */
85export function Status({ value }: { value: string }) {
86 const style = STATUS_STYLES[value] ?? "border-line bg-surface text-muted";
87 return (
88 <span
89 className={`inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${style}`}
90 >
91 {value === "working" && (
92 <span className="size-1.5 animate-pulse rounded-full bg-accent" />
93 )}
94 {value}
95 </span>
96 );
97}
98
99/** A small outlined label. */
100export function Pill({ children }: { children: ReactNode }) {
101 return (
102 <span className="inline-flex items-center rounded-full border border-line px-2 py-0.5 text-xs text-muted">
103 {children}
104 </span>
105 );
106}
107
108const AVATAR_HUES = [82, 200, 262, 28, 330, 160];
109
110/** A letter avatar whose colour is stable for a given name. */
111export function Avatar({ name, size = 20 }: { name: string; size?: number }) {
112 let hash = 0;
113 for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
114 const hue = AVATAR_HUES[Math.abs(hash) % AVATAR_HUES.length];
115 return (
116 <span
117 aria-hidden="true"
118 className="inline-flex shrink-0 items-center justify-center rounded-full font-mono font-semibold uppercase"
119 style={{
120 width: size,
121 height: size,
122 fontSize: size * 0.5,
123 background: `oklch(0.4 0.09 ${hue})`,
124 color: `oklch(0.93 0.08 ${hue})`,
125 }}
126 >
127 {name[0]}
128 </span>
129 );
130}
131
132/** A line of text (usually a command) with a copy button. */
133export function CopyLine({
134 text,
135 prompt,
136}: {
137 text: string;
138 prompt?: boolean;
139}) {
140 const [copied, setCopied] = useState(false);
141 return (
142 <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]">
143 <code className="min-w-0 grow overflow-x-auto whitespace-nowrap">
144 {prompt && <span className="mr-2 text-faint select-none">$</span>}
145 {text}
146 </code>
147 <button
148 type="button"
149 aria-label="Copy"
150 className="shrink-0 rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-fg"
151 onClick={() => {
152 void navigator.clipboard.writeText(text);
153 setCopied(true);
154 setTimeout(() => setCopied(false), 1500);
155 }}
156 >
157 {copied ? <Check size={14} className="text-accent" /> : <Copy size={14} />}
158 </button>
159 </div>
160 );
161}
162
163const UNITS: [string, number][] = [
164 ["d", 86_400_000],
165 ["h", 3_600_000],
166 ["m", 60_000],
167];
168
169/** Compact relative time such as "5m ago". */
170export function TimeAgo({ at }: { at: string | number }) {
171 // Timestamps are RFC 3339 strings; a few older ones are still numbers.
172 const elapsed = Date.now() - new Date(at).getTime();
173 const unit = UNITS.find(([, size]) => elapsed >= size);
174 const label = unit ? `${Math.floor(elapsed / unit[1])}${unit[0]} ago` : "just now";
175 return (
176 // The server and the browser render at slightly different times.
177 <time dateTime={new Date(at).toISOString()} suppressHydrationWarning>
178 {label}
179 </time>
180 );
181}
182
183export function EmptyState({
184 title,
185 children,
186}: {
187 title: string;
188 children?: ReactNode;
189}) {
190 return (
191 <div className="rounded-lg border border-dashed border-line px-6 py-12 text-center">
192 <p className="font-medium">{title}</p>
193 {children && <div className="mt-1.5 text-sm text-muted">{children}</div>}
194 </div>
195 );
196}