SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
10.2 KB · 219 lines tsx
Raw Blame History
1'use client';2import { Search, X } from 'lucide-react';3import Link from 'next/link';4import { usePathname, useRouter } from 'next/navigation';5import { useEffect, useRef, useState } from 'react';6import { EntityBadge } from '@/components/ui/badges';7import { clientApi } from '@/lib/client-api';8import { cn } from '@/lib/cn';9import { typeLabel } from '@/lib/site';10import type { Suggestion } from '@/lib/types';11import { COMPARE_MAX, COMPARE_MIN, compareHref, type TrayItem, trayType, useCompareTray } from './compare-store';1213/**14 * Compare picker + tray for /compare. Suggestions come from /search/suggest; once the tray holds an item, only15 * entities of the same (normalised) type are selectable — the API compares 2–6 entities of one type.16 * `initial` (from `?ids=` resolved server-side) seeds the tray on load; tray changes are mirrored into `?ids=`.17 */18export function ComparePicker({ initial, exampleHref }: { initial: TrayItem[]; exampleHref?: string | null }) {19  const tray = useCompareTray();20  const router = useRouter();21  const pathname = usePathname();22  const [q, setQ] = useState('');23  const [items, setItems] = useState<Suggestion[]>([]);24  const [loading, setLoading] = useState(false);25  const [failed, setFailed] = useState(false);26  const seeded = useRef(false);27  /** Slugs the tray must show before we start mirroring it into the URL (avoids stripping ?ids= during the seed commit). */28  const awaiting = useRef<string | null>(null);29  const inputRef = useRef<HTMLInputElement>(null);3031  // Seed the tray from the URL once hydrated (URL wins over localStorage when present).32  useEffect(() => {33    if (!tray.ready || seeded.current) return;34    seeded.current = true;35    if (initial.length) {36      const same = initial.every((i) => trayType(i.entity_type) === trayType(initial[0]!.entity_type));37      const want = initial.map((i) => i.slug).join(',');38      const current = tray.items.map((i) => i.slug).join(',');39      if (same && current !== want) {40        awaiting.current = want;41        tray.replace(initial);42      }43    }44  }, [tray.ready, tray.items, tray.replace, initial]);4546  // Mirror the tray into ?ids= (no scroll, replace) — only once the seeded items are in state.47  useEffect(() => {48    if (!tray.ready || !seeded.current) return;49    const ids = tray.items.map((i) => i.slug);50    if (awaiting.current !== null) {51      if (ids.join(',') !== awaiting.current) return;52      awaiting.current = null;53    }54    const url = new URL(window.location.href);55    const cur = url.searchParams.get('ids') ?? '';56    const next = ids.length >= COMPARE_MIN ? ids.join(',') : '';57    if (cur === next) return;58    if (next) url.searchParams.set('ids', next);59    else url.searchParams.delete('ids');60    router.replace(`${pathname}${url.search}`, { scroll: false });61  }, [tray.items, tray.ready, router, pathname]);6263  useEffect(() => {64    const term = q.trim();65    if (term.length < 1) {66      setItems([]);67      return;68    }69    const ctrl = new AbortController();70    const t = setTimeout(async () => {71      setLoading(true);72      try {73        const res = await clientApi.suggest(term, ctrl.signal);74        setItems(res.items ?? []);75        setFailed(false);76      } catch (e) {77        if ((e as Error).name !== 'AbortError') setFailed(true);78      } finally {79        setLoading(false);80      }81    }, 120);82    return () => {83      clearTimeout(t);84      ctrl.abort();85    };86  }, [q]);8788  const type = tray.type;89  const full = tray.full;90  const pick = (s: Suggestion) => {91    tray.add({ slug: s.slug, name: s.name, entity_type: s.entity_type, organization: s.organization_name });92    setQ('');93    setItems([]);94    inputRef.current?.focus();95  };96  const term = q.trim();97  const selectable = items.filter((s) => !tray.has(s.slug));98  const matching = type ? selectable.filter((s) => trayType(s.entity_type) === type) : selectable;99  const others = type ? selectable.filter((s) => trayType(s.entity_type) !== type) : [];100101  return (102    <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">103      {/* ---------------------------------------------------------------------------------------------- search */}104      <div className="min-w-0">105        <label htmlFor="compare-q" className="eyebrow block pb-1">106          {type ? `Add another ${typeLabel(type).toLowerCase()}` : 'Add an entity'}107        </label>108        <div className={cn('flex items-center gap-2 border border-rule-strong bg-surface px-3 focus-within:border-accent', full && 'opacity-60')}>109          <Search className="size-4 shrink-0 text-ink-3" aria-hidden />110          <input111            id="compare-q"112            ref={inputRef}113            value={q}114            onChange={(e) => setQ(e.target.value)}115            disabled={full}116            placeholder={full ? `Tray full (${COMPARE_MAX}) — remove one to add another` : type ? `Search ${typeLabel(type, true).toLowerCase()}…` : 'Search models, providers, hardware, companies…'}117            className="h-11 min-w-0 flex-1 bg-transparent text-[16px] text-ink placeholder:text-ink-3 focus:outline-none"118            autoComplete="off"119            spellCheck={false}120            aria-autocomplete="list"121            aria-controls="compare-suggestions"122          />123          {q && (124            <button type="button" onClick={() => setQ('')} className="flex size-9 items-center justify-center text-ink-3 hover:text-ink" aria-label="Clear">125              <X className="size-4" aria-hidden />126            </button>127          )}128        </div>129        {type && <p className="mt-1.5 text-xs text-ink-3">Comparing {typeLabel(type, true).toLowerCase()} — pick another {typeLabel(type).toLowerCase()}. Entities of another type would start a new comparison.</p>}130        {term && (131          <ul id="compare-suggestions" role="listbox" className="mt-1 border border-rule bg-surface">132            {failed && <li className="px-3 py-2 text-xs text-warning">Suggestions unavailable — try again in a moment.</li>}133            {!loading && !failed && matching.length === 0 && others.length === 0 && <li className="px-3 py-3 text-sm text-ink-3">{selectable.length === 0 && items.length > 0 ? 'Already in the tray.' : 'No match.'}</li>}134            {matching.map((s) => (135              <li key={s.id} role="option" aria-selected={false}>136                <button type="button" onClick={() => pick(s)} className="flex min-h-[46px] w-full items-center gap-3 px-3 py-2 text-left hover:bg-surface-2">137                  <EntityBadge type={s.entity_type} small />138                  <span className="min-w-0 flex-1 truncate text-[15px] text-ink">{s.name}</span>139                  {s.organization_name && <span className="hidden truncate text-xs text-ink-3 sm:block">{s.organization_name}</span>}140                </button>141              </li>142            ))}143            {others.length > 0 && (144              <li className="border-t border-rule px-3 py-1.5 text-[11px] text-ink-3">145                Other types (selecting one starts a new {' '}comparison)146              </li>147            )}148            {others.map((s) => (149              <li key={s.id} role="option" aria-selected={false}>150                <button type="button" onClick={() => pick(s)} className="flex min-h-[46px] w-full items-center gap-3 px-3 py-2 text-left text-ink-3 hover:bg-surface-2">151                  <EntityBadge type={s.entity_type} small className="opacity-70" />152                  <span className="min-w-0 flex-1 truncate text-[15px]">{s.name}</span>153                  {s.organization_name && <span className="hidden truncate text-xs sm:block">{s.organization_name}</span>}154                </button>155              </li>156            ))}157          </ul>158        )}159      </div>160161      {/* ---------------------------------------------------------------------------------------------- tray */}162      <div className="min-w-0">163        <p className="eyebrow pb-1">164          Tray <span className="tnum text-ink-3">{tray.ready ? tray.items.length : initial.length} / {COMPARE_MAX}</span>165        </p>166        <CompareTray items={tray.ready ? tray.items : initial} onRemove={tray.remove} onClear={tray.clear} ready={tray.ready} exampleHref={exampleHref} />167      </div>168    </div>169  );170}171172export function CompareTray({ items, onRemove, onClear, ready, exampleHref }: { items: TrayItem[]; onRemove: (slug: string) => void; onClear: () => void; ready: boolean; exampleHref?: string | null }) {173  const can = items.length >= COMPARE_MIN;174  return (175    <div className="border-t border-rule">176      {items.length === 0 ? (177        <p className="py-3 text-sm text-ink-3">178          Nothing selected yet. Use the search on the left, or the “Compare” buttons on listings and entity pages.179          {exampleHref && (180            <>181              {' '}182              Example: <Link href={exampleHref} className="link">two well-documented models</Link>.183            </>184          )}185        </p>186      ) : (187        <ul className="divide-y divide-rule">188          {items.map((it) => (189            <li key={it.slug} className="flex min-h-11 items-center gap-2 py-1.5">190              <EntityBadge type={it.entity_type} small />191              <span className="min-w-0 flex-1 truncate text-sm text-ink">192                {it.name}193                {it.organization && <span className="ml-2 text-xs text-ink-3">{it.organization}</span>}194              </span>195              <button type="button" onClick={() => onRemove(it.slug)} disabled={!ready} className="flex size-9 shrink-0 items-center justify-center text-ink-3 hover:text-danger" aria-label={`Remove ${it.name}`}>196                <X className="size-4" aria-hidden />197              </button>198            </li>199          ))}200        </ul>201      )}202      <div className="mt-3 flex flex-wrap items-center gap-2">203        {can ? (204          <Link href={compareHref(items)} className="inline-flex h-10 items-center bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90">205            Compare {items.length} →206          </Link>207        ) : (208          <span className="inline-flex h-10 items-center border border-rule px-4 text-sm text-ink-3">Pick at least {COMPARE_MIN}</span>209        )}210        {items.length > 0 && (211          <button type="button" onClick={onClear} disabled={!ready} className="inline-flex h-10 items-center border border-rule px-3 text-sm text-ink-2 hover:text-ink">212            Clear213          </button>214        )}215      </div>216    </div>217  );218}219