HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
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