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%
14.8 KB · 331 lines tsx
Raw Blame History
1'use client';23import Link from 'next/link';4import { useRouter } from 'next/navigation';5import { useCallback, useEffect, useMemo, useRef, useState } from 'react';6import { ArrowUpRight, Clock, Search, TrendingUp, X } from 'lucide-react';7import { cn } from '@/lib/format';89export interface Suggestion {10  type: string;11  label: string;12  sublabel: string | null;13  href: string;14}1516const RECENT_KEY = 'ri-recent-searches';17const TRENDING = ['1999 Charizard PSA 10', 'Rolex Daytona 116500LN', 'LEGO 10179', 'Black Lotus Alpha', 'Blue-Eyes White Dragon LOB', 'Super Mario 64 sealed', 'Amazing Spider-Man 300', 'Patek Philippe Nautilus 5711'];18const GROUP_LABEL: Record<string, string> = { asset: 'Assets', set: 'Sets & releases', category: 'Categories', brand: 'Brands', source: 'Sources', index: 'Indices' };19const GROUP_ORDER = ['asset', 'set', 'category', 'brand', 'index', 'source'];2021function readRecent(): string[] {22  try {23    const raw = localStorage.getItem(RECENT_KEY);24    const arr = raw ? (JSON.parse(raw) as unknown) : [];25    return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === 'string').slice(0, 8) : [];26  } catch {27    return [];28  }29}30export function pushRecent(q: string) {31  try {32    const next = [q, ...readRecent().filter((x) => x.toLowerCase() !== q.toLowerCase())].slice(0, 8);33    localStorage.setItem(RECENT_KEY, JSON.stringify(next));34  } catch {35    /* ignore */36  }37}3839/** Highlight query tokens inside a label (case-insensitive, word-agnostic). */40export function Highlight({ text, q }: { text: string; q: string }) {41  const tokens = q.toLowerCase().split(/\s+/).filter((t) => t.length >= 2);42  if (!tokens.length) return <>{text}</>;43  const re = new RegExp(`(${tokens.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'ig');44  const parts = text.split(re);45  return (46    <>47      {parts.map((p, i) => (re.test(p) ? <mark key={i} className="rounded-[2px] bg-index-bg text-fg">{p}</mark> : <span key={i}>{p}</span>))}48    </>49  );50}5152/**53 * Full-screen search sheet (mobile) / command palette (desktop, ⌘K). Grouped instant results from54 * /api/search/suggest, recent searches (localStorage), trending prompts and keyboard navigation.55 */56export function SearchSheet({ open, onClose, initialQuery = '' }: { open: boolean; onClose: () => void; initialQuery?: string }) {57  const router = useRouter();58  const [q, setQ] = useState(initialQuery);59  const [items, setItems] = useState<Suggestion[]>([]);60  const [loading, setLoading] = useState(false);61  const [active, setActive] = useState(-1);62  const inputRef = useRef<HTMLInputElement>(null);63  // Only rendered client-side once opened (SSR renders nothing), so reading localStorage here is safe.64  const recent = useMemo(() => (open && typeof window !== 'undefined' ? readRecent() : []), [open]);6566  useEffect(() => {67    if (!open) return;68    const t = setTimeout(() => inputRef.current?.focus(), 30);69    document.body.style.overflow = 'hidden';70    const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();71    window.addEventListener('keydown', onKey);72    return () => {73      clearTimeout(t);74      document.body.style.overflow = '';75      window.removeEventListener('keydown', onKey);76    };77  }, [open, onClose]);7879  useEffect(() => {80    if (!open) return;81    const query = q.trim();82    if (query.length < 2) return;83    const ctrl = new AbortController();84    const t = setTimeout(() => {85      setLoading(true);86      fetch(`/api/search/suggest?q=${encodeURIComponent(query)}`, { signal: ctrl.signal })87        .then((r) => r.json())88        .then((d: { items: Suggestion[] }) => {89          setItems(d.items ?? []);90          setActive(-1);91        })92        .catch(() => {})93        .finally(() => setLoading(false));94    }, 110);95    return () => {96      clearTimeout(t);97      ctrl.abort();98    };99  }, [q, open]);100101  const groups = useMemo(() => {102    const m = new Map<string, Suggestion[]>();103    for (const it of items) (m.get(it.type) ?? m.set(it.type, []).get(it.type)!).push(it);104    return [...m.entries()].sort((a, b) => GROUP_ORDER.indexOf(a[0]) - GROUP_ORDER.indexOf(b[0]));105  }, [items]);106  const flat = useMemo(() => groups.flatMap(([, g]) => g), [groups]);107108  const go = useCallback(109    (href: string, label?: string) => {110      if (label) pushRecent(label);111      onClose();112      router.push(href);113    },114    [onClose, router],115  );116  const submit = () => {117    const query = q.trim();118    if (active >= 0 && flat[active]) return go(flat[active]!.href, query || flat[active]!.label);119    if (query) go(`/search?q=${encodeURIComponent(query)}`, query);120  };121122  if (!open) return null;123  const showIdle = q.trim().length < 2;124  return (125    <div className="fixed inset-0 z-[70]" role="dialog" aria-modal="true" aria-label="Search">126      <div className="absolute inset-0 bg-black/45 backdrop-blur-[2px]" onClick={onClose} />127      <div className="sheet-in absolute inset-x-0 top-0 mx-auto flex h-[100dvh] w-full flex-col bg-elevated shadow-pop md:top-[8vh] md:h-auto md:max-h-[76vh] md:max-w-2xl md:rounded-xl md:border md:border-border">128        <form129          role="search"130          className="flex items-center gap-2 border-b border-border px-3 py-2 md:px-4"131          onSubmit={(e) => {132            e.preventDefault();133            submit();134          }}135        >136          <Search className="h-5 w-5 shrink-0 text-subtle" />137          <input138            ref={inputRef}139            value={q}140            onChange={(e) => {141              setQ(e.target.value);142              if (e.target.value.trim().length < 2) {143                setItems([]);144                setActive(-1);145              }146            }}147            onKeyDown={(e) => {148              if (e.key === 'ArrowDown') {149                e.preventDefault();150                setActive((a) => Math.min(flat.length - 1, a + 1));151              } else if (e.key === 'ArrowUp') {152                e.preventDefault();153                setActive((a) => Math.max(-1, a - 1));154              }155            }}156            type="search"157            enterKeyHint="search"158            autoComplete="off"159            autoCapitalize="off"160            spellCheck={false}161            placeholder="Search cards, watches, games, sneakers, LEGO, art…"162            aria-label="Search collectibles"163            aria-autocomplete="list"164            aria-controls="search-sheet-list"165            className="h-12 min-w-0 flex-1 bg-transparent text-[16px] text-fg placeholder:text-subtle focus:outline-none md:h-11 md:text-[15px]"166          />167          {q ? (168            <button type="button" onClick={() => setQ('')} className="inline-flex h-9 w-9 items-center justify-center rounded-md text-muted hover:bg-inset" aria-label="Clear">169              <X className="h-4 w-4" />170            </button>171          ) : null}172          <button type="button" onClick={onClose} className="inline-flex h-10 items-center rounded-md border border-border px-2.5 text-[12px] font-medium text-muted hover:bg-inset md:h-8">173            <span className="md:hidden">Cancel</span>174            <span className="hidden md:inline">Esc</span>175          </button>176        </form>177178        <div id="search-sheet-list" className="flex-1 overflow-y-auto overscroll-contain safe-bottom" role="listbox">179          {showIdle ? (180            <div className="px-3 py-3 md:px-4">181              {recent.length ? (182                <section className="mb-4">183                  <p className="t-label mb-1.5 flex items-center gap-1.5">184                    <Clock className="h-3 w-3" /> Recent185                  </p>186                  <ul className="flex flex-wrap gap-1.5">187                    {recent.map((r) => (188                      <li key={r}>189                        <button type="button" onClick={() => go(`/search?q=${encodeURIComponent(r)}`, r)} className="chip">190                          {r}191                        </button>192                      </li>193                    ))}194                  </ul>195                </section>196              ) : null}197              <section>198                <p className="t-label mb-1.5 flex items-center gap-1.5">199                  <TrendingUp className="h-3 w-3" /> Try200                </p>201                <ul className="divide-y divide-border">202                  {TRENDING.map((t) => (203                    <li key={t}>204                      <button type="button" onClick={() => go(`/search?q=${encodeURIComponent(t)}`, t)} className="flex min-h-[44px] w-full items-center justify-between gap-3 px-1 text-left text-[14px] text-fg hover:bg-sunken">205                        <span>{t}</span>206                        <ArrowUpRight className="h-4 w-4 text-subtle" />207                      </button>208                    </li>209                  ))}210                </ul>211              </section>212              <p className="mt-4 text-[11px] leading-relaxed text-subtle">Understands graders and grades (“PSA 10”), years, price bounds (“under $5,000”), conditions (“sealed”, “CIB”) and set codes (“LOB-001”, “116500LN”).</p>213            </div>214          ) : (215            <div className="py-1">216              {groups.map(([type, g]) => (217                <section key={type} className="pb-1">218                  <p className="t-label px-4 pb-1 pt-2">{GROUP_LABEL[type] ?? type}</p>219                  <ul>220                    {g.map((it) => {221                      const idx = flat.indexOf(it);222                      return (223                        <li key={`${it.type}-${it.href}`} role="option" aria-selected={idx === active}>224                          <button type="button" onMouseEnter={() => setActive(idx)} onClick={() => go(it.href, q.trim())} className={cn('flex min-h-[44px] w-full items-center justify-between gap-3 px-4 py-1.5 text-left', idx === active ? 'bg-inset' : 'hover:bg-sunken')}>225                            <span className="min-w-0">226                              <span className="block truncate text-[14px] text-fg">227                                <Highlight text={it.label} q={q} />228                              </span>229                              {it.sublabel ? <span className="block truncate text-[11px] text-muted">{it.sublabel}</span> : null}230                            </span>231                            <ArrowUpRight className="h-4 w-4 shrink-0 text-subtle" />232                          </button>233                        </li>234                      );235                    })}236                  </ul>237                </section>238              ))}239              {!loading && !flat.length ? <p className="px-4 py-6 text-center text-[13px] text-muted">No instant matches. Press Enter to run a full search.</p> : null}240              <button type="button" onClick={submit} className="mt-1 flex min-h-[48px] w-full items-center justify-between border-t border-border px-4 text-left text-[13px] text-muted hover:bg-sunken">241                <span>242                  Search all results for <span className="font-medium text-fg">“{q.trim()}”</span>243                </span>244                <kbd className="rounded-sm border border-border px-1.5 text-[10px] text-subtle">↵</kbd>245              </button>246            </div>247          )}248        </div>249      </div>250    </div>251  );252}253254/** Icon/button trigger that opens the sheet; global ⌘K / Ctrl+K shortcut. */255export function SearchTrigger({ variant = 'icon', className, initialQuery }: { variant?: 'icon' | 'field' | 'hero'; className?: string; initialQuery?: string }) {256  const [open, setOpen] = useState(false);257  const close = useCallback(() => setOpen(false), []);258  useEffect(() => {259    const onKey = (e: KeyboardEvent) => {260      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {261        e.preventDefault();262        setOpen(true);263      }264    };265    window.addEventListener('keydown', onKey);266    return () => window.removeEventListener('keydown', onKey);267  }, []);268  return (269    <>270      {variant === 'icon' ? (271        <button type="button" onClick={() => setOpen(true)} className={cn('inline-flex h-10 w-10 items-center justify-center rounded-md text-fg hover:bg-inset md:h-8 md:w-8', className)} aria-label="Search">272          <Search className="h-5 w-5" />273        </button>274      ) : variant === 'field' ? (275        <button type="button" onClick={() => setOpen(true)} className={cn('group flex h-8 w-64 items-center gap-2 rounded-md border border-border bg-sunken px-2.5 text-left text-[13px] text-subtle hover:border-border-strong xl:w-80', className)} aria-label="Search (⌘K)">276          <Search className="h-3.5 w-3.5" />277          <span className="flex-1 truncate">Search cards, watches, games…</span>278          <kbd className="rounded-sm border border-border bg-elevated px-1 text-[10px] text-subtle">⌘K</kbd>279        </button>280      ) : (281        <button type="button" onClick={() => setOpen(true)} className={cn('flex h-13 w-full items-center gap-3 rounded-lg border border-border bg-elevated px-4 text-left shadow-card hover:border-border-strong', className)} aria-label="Search collectibles">282          <Search className="h-5 w-5 text-subtle" />283          <span className="flex-1 truncate text-[16px] text-subtle">Search cards, watches, games, sneakers, LEGO, art…</span>284          <span className="btn-primary btn-sm hidden h-8 sm:inline-flex">Search</span>285        </button>286      )}287      <SearchSheet open={open} onClose={close} initialQuery={initialQuery} />288    </>289  );290}291292/** Non-JS friendly hero search: a real form that submits to /search; enhanced with the sheet on focus. */293export function HeroSearch({ chips }: { chips: string[] }) {294  const [open, setOpen] = useState(false);295  const close = useCallback(() => setOpen(false), []);296  return (297    <div className="mx-auto w-full max-w-2xl">298      <form action="/search" role="search" className="relative flex items-center">299        <Search className="pointer-events-none absolute left-4 h-5 w-5 text-subtle" />300        <input301          name="q"302          type="search"303          autoComplete="off"304          enterKeyHint="search"305          placeholder="Search cards, watches, games, sneakers, LEGO, art…"306          aria-label="Search collectibles"307          onFocus={(e) => {308            // Use the rich sheet when JS is available; keep the plain form as the fallback.309            e.currentTarget.blur();310            setOpen(true);311          }}312          className="h-13 w-full rounded-lg border border-border bg-elevated pl-12 pr-24 text-[16px] text-fg shadow-card placeholder:text-subtle focus:border-border-strong focus:outline-none"313        />314        <button type="submit" className="btn-primary absolute right-1.5 h-10 px-4 text-[13px]">315          Search316        </button>317      </form>318      <ul className="mt-3 flex flex-wrap items-center justify-center gap-1.5">319        {chips.map((c) => (320          <li key={c}>321            <Link href={`/search?q=${encodeURIComponent(c)}`} className="chip" onClick={() => pushRecent(c)}>322              {c}323            </Link>324          </li>325        ))}326      </ul>327      <SearchSheet open={open} onClose={close} />328    </div>329  );330}331