TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1'use client';23import { useFormStatus } from 'react-dom';4import { useEffect, useMemo, useState, type InputHTMLAttributes, type ReactNode, type SelectHTMLAttributes, type TextareaHTMLAttributes } from 'react';5import { Eye, EyeOff, Loader2 } from 'lucide-react';6import { passwordStrength } from '@/lib/auth/password-strength';7import type { ActionState } from '@/lib/auth/state';8import { cn } from '@/lib/format';910export const inputClass = 'block w-full rounded-md border border-border bg-elevated px-3 py-2 text-base text-fg shadow-none placeholder:text-subtle focus:border-border-strong focus:outline-none focus:ring-2 focus:ring-[var(--ri-ring)] md:text-sm disabled:opacity-60';1112export function Field({ label, name, error, hint, children, className }: { label: ReactNode; name: string; error?: string | null; hint?: ReactNode; children: ReactNode; className?: string }) {13 return (14 <div className={cn('space-y-1.5', className)}>15 <label htmlFor={name} className="block text-xs font-medium text-muted">16 {label}17 </label>18 {children}19 {error ? (20 <p className="text-xs text-loss" role="alert">21 {error}22 </p>23 ) : hint ? (24 <p className="text-xs text-subtle">{hint}</p>25 ) : null}26 </div>27 );28}2930export function TextInput(props: InputHTMLAttributes<HTMLInputElement> & { invalid?: boolean }) {31 const { invalid, className, ...rest } = props;32 return <input {...rest} id={rest.id ?? rest.name} className={cn(inputClass, invalid && 'border-loss', className)} aria-invalid={invalid || undefined} />;33}3435export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {36 const { className, ...rest } = props;37 return <select {...rest} id={rest.id ?? rest.name} className={cn(inputClass, 'pr-8', className)} />;38}3940export function TextArea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {41 const { className, ...rest } = props;42 return <textarea {...rest} id={rest.id ?? rest.name} className={cn(inputClass, 'min-h-[88px] resize-y', className)} />;43}4445export function Checkbox({ name, label, defaultChecked, description }: { name: string; label: ReactNode; defaultChecked?: boolean; description?: ReactNode }) {46 return (47 <label className="flex cursor-pointer items-start gap-2.5 text-sm">48 <input type="checkbox" name={name} defaultChecked={defaultChecked} className="mt-0.5 h-4 w-4 rounded-sm border-border accent-[var(--ri-accent)]" />49 <span>50 <span className="block text-fg">{label}</span>51 {description ? <span className="block text-xs text-muted">{description}</span> : null}52 </span>53 </label>54 );55}5657export function SubmitButton({ children, variant = 'primary', className, pendingText, disabled }: { children: ReactNode; variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; className?: string; pendingText?: string; disabled?: boolean }) {58 const { pending } = useFormStatus();59 const styles = {60 primary: 'bg-accent text-accent-fg hover:opacity-90',61 secondary: 'border border-border bg-elevated text-fg hover:bg-inset',62 danger: 'bg-loss text-white hover:opacity-90',63 ghost: 'text-muted hover:bg-inset hover:text-fg',64 }[variant];65 return (66 <button type="submit" disabled={pending || disabled} className={cn('inline-flex h-10 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60 md:h-9', styles, className)}>67 {pending ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}68 {pending && pendingText ? pendingText : children}69 </button>70 );71}7273export function FormMessage({ state, className }: { state: ActionState; className?: string }) {74 if (state.error) {75 return (76 <div role="alert" className={cn('rounded-md border border-loss/30 bg-loss-bg px-3 py-2 text-sm text-loss', className)}>77 {state.error}78 </div>79 );80 }81 if (state.ok && state.message) {82 return (83 <div role="status" className={cn('rounded-md border border-gain/30 bg-gain-bg px-3 py-2 text-sm text-gain', className)}>84 {state.message}85 </div>86 );87 }88 return null;89}9091/** Password input with reveal toggle and live strength meter. */92export function PasswordField({ name = 'password', label = 'Password', autoComplete = 'new-password', error, email, showMeter = true, placeholder }: { name?: string; label?: string; autoComplete?: string; error?: string | null; email?: string; showMeter?: boolean; placeholder?: string }) {93 const [value, setValue] = useState('');94 const [show, setShow] = useState(false);95 const strength = useMemo(() => passwordStrength(value, email), [value, email]);96 const colors = ['bg-loss', 'bg-loss', 'bg-alert', 'bg-gain', 'bg-gain'];97 return (98 <Field label={label} name={name} error={error} hint={showMeter ? (value ? strength.hint : 'At least 10 characters. A passphrase works best.') : undefined}>99 <div className="relative">100 <TextInput name={name} type={show ? 'text' : 'password'} autoComplete={autoComplete} required minLength={showMeter ? 10 : 1} value={value} onChange={(e) => setValue(e.target.value)} invalid={Boolean(error)} className="pr-10" placeholder={placeholder} />101 <button type="button" aria-label={show ? 'Hide password' : 'Show password'} onClick={() => setShow((s) => !s)} className="absolute inset-y-0 right-0 flex w-10 items-center justify-center text-subtle hover:text-fg">102 {show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}103 </button>104 </div>105 {showMeter && value ? (106 <div className="flex items-center gap-2 pt-1" aria-live="polite">107 <div className="flex flex-1 gap-1">108 {[0, 1, 2, 3].map((i) => (109 <span key={i} className={cn('h-1 flex-1 rounded-full bg-inset', i < strength.score && colors[strength.score])} />110 ))}111 </div>112 <span className="text-[11px] text-muted">{strength.label}</span>113 </div>114 ) : null}115 </Field>116 );117}118119/** Six-digit code input: numeric keyboard, one-time-code autofill, auto-submit when complete. */120export function CodeInput({ name = 'code', autoSubmit = true, error, label = 'Code' }: { name?: string; autoSubmit?: boolean; error?: string | null; label?: string }) {121 const [v, setV] = useState('');122 useEffect(() => {123 if (autoSubmit && v.length === 6) {124 const el = document.getElementById(name) as HTMLInputElement | null;125 el?.form?.requestSubmit();126 }127 }, [v, autoSubmit, name]);128 return (129 <Field label={label} name={name} error={error}>130 <input id={name} name={name} inputMode="numeric" pattern="[0-9]*" autoComplete="one-time-code" maxLength={6} required value={v} onChange={(e) => setV(e.target.value.replace(/\D/g, '').slice(0, 6))} className={cn(inputClass, 'mono-num text-center text-2xl tracking-[0.5em] md:text-2xl', error && 'border-loss')} aria-invalid={Boolean(error) || undefined} autoFocus />131 </Field>132 );133}134135/** Countdown-enabled resend button (client-side cooldown mirrors the server's). */136export function Cooldown({ seconds, children }: { seconds: number; children: (ready: boolean, left: number) => ReactNode }) {137 return <CooldownInner key={seconds} seconds={seconds}>{children}</CooldownInner>;138}139140function CooldownInner({ seconds, children }: { seconds: number; children: (ready: boolean, left: number) => ReactNode }) {141 const [left, setLeft] = useState(seconds);142 useEffect(() => {143 if (seconds <= 0) return;144 const t = setInterval(() => setLeft((l) => (l <= 1 ? 0 : l - 1)), 1000);145 return () => clearInterval(t);146 }, [seconds]);147 return <>{children(left <= 0, left)}</>;148}149