SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
7.5 KB · 149 lines tsx
Raw Blame History
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