SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
9.1 KB · 240 lines tsx
Raw Blame History
1'use client';2import { Clock, CornerDownLeft, Search, X } from 'lucide-react';3import { useRouter } from 'next/navigation';4import { useCallback, useEffect, useMemo, useRef, useState } from 'react';5import { t } from '@/i18n';6import { clientApi } from '@/lib/client-api';7import { cn } from '@/lib/cn';8import { routes } from '@/lib/site';9import type { SearchHit, SearchHitType } from '@/lib/types';10import { BottomSheet } from '@/components/data/bottom-sheet';11import { useSearch } from './search-context';1213const RECENT_KEY = 'ca-recent-searches';14const MAX_RECENT = 8;1516function hrefFor(h: SearchHit): string {17  if (h.url) return h.url;18  const slug = h.slug ?? h.id;19  switch (h.type) {20    case 'country':21      return routes.country(slug);22    case 'indicator':23      return routes.indicator(slug);24    case 'topic':25      return routes.indicators(slug);26    case 'region':27      return routes.region(slug);28    case 'source':29      return routes.source(h.id);30    default:31      return '/';32  }33}3435function loadRecent(): SearchHit[] {36  try {37    const raw = localStorage.getItem(RECENT_KEY);38    return raw ? (JSON.parse(raw) as SearchHit[]) : [];39  } catch {40    return [];41  }42}43function saveRecent(h: SearchHit) {44  try {45    const cur = loadRecent().filter((x) => !(x.type === h.type && x.id === h.id));46    localStorage.setItem(RECENT_KEY, JSON.stringify([h, ...cur].slice(0, MAX_RECENT)));47  } catch {48    /* ignore */49  }50}5152const TYPE_ORDER: string[] = ['action', 'country', 'country_topic', 'country_indicator', 'indicator', 'topic', 'region', 'source'];5354/**55 * Global search (⌘K / "/" / tab bar). Debounced `/api/v1/search?q=`, grouped hits with type chips, keyboard56 * navigation, recent searches in localStorage. Full-screen sheet on mobile, centred panel on desktop.57 */58export function SearchDialog() {59  const { open, setOpen } = useSearch();60  const router = useRouter();61  const [q, setQ] = useState('');62  const [hits, setHits] = useState<SearchHit[]>([]);63  const [state, setState] = useState<'idle' | 'loading' | 'error'>('idle');64  const [recent, setRecent] = useState<SearchHit[]>([]);65  const [active, setActive] = useState(0);66  const inputRef = useRef<HTMLInputElement>(null);67  const abortRef = useRef<AbortController | null>(null);6869  useEffect(() => {70    if (open) {71      setRecent(loadRecent());72      setTimeout(() => inputRef.current?.focus(), 30);73    } else {74      setQ('');75      setHits([]);76      setState('idle');77      setActive(0);78    }79  }, [open]);8081  useEffect(() => {82    const term = q.trim();83    abortRef.current?.abort();84    if (term.length < 1) {85      setHits([]);86      setState('idle');87      return;88    }89    const ctrl = new AbortController();90    abortRef.current = ctrl;91    setState('loading');92    const timer = setTimeout(async () => {93      try {94        const res = await clientApi.search(term, 14, ctrl.signal);95        if (ctrl.signal.aborted) return;96        setHits(res.hits);97        setActive(0);98        setState('idle');99      } catch (e) {100        if ((e as Error).name === 'AbortError') return;101        setState('error');102      }103    }, 160);104    return () => clearTimeout(timer);105  }, [q]);106107  const list = useMemo(() => {108    const src = q.trim() ? hits : recent;109    return [...src].sort((a, b) => Number(b.type === 'action') - Number(a.type === 'action') || (q.trim() ? b.score - a.score : 0) || TYPE_ORDER.indexOf(a.type) - TYPE_ORDER.indexOf(b.type));110  }, [hits, recent, q]);111112  const go = useCallback(113    (h: SearchHit) => {114      saveRecent(h);115      setOpen(false);116      router.push(hrefFor(h));117    },118    [router, setOpen],119  );120121  const onKey = (e: React.KeyboardEvent) => {122    if (e.key === 'ArrowDown') {123      e.preventDefault();124      setActive((a) => Math.min(list.length - 1, a + 1));125    } else if (e.key === 'ArrowUp') {126      e.preventDefault();127      setActive((a) => Math.max(0, a - 1));128    } else if (e.key === 'Enter') {129      const h = list[active];130      if (h) go(h);131    }132  };133134  const clearRecent = () => {135    try {136      localStorage.removeItem(RECENT_KEY);137    } catch {138      /* ignore */139    }140    setRecent([]);141  };142143  return (144    <BottomSheet145      open={open}146      onClose={() => setOpen(false)}147      side="full"148      labelledBy="search-title"149      title={150        <div className="relative flex items-center">151          <Search size={16} aria-hidden className="absolute left-2 text-ink-3" />152          <input153            ref={inputRef}154            id="search-title"155            type="search"156            value={q}157            onChange={(e) => setQ(e.target.value)}158            onKeyDown={onKey}159            placeholder={t('search.placeholder')}160            aria-label={t('nav.search')}161            aria-controls="search-results"162            aria-activedescendant={list[active] ? `hit-${list[active].type}-${list[active].id}` : undefined}163            autoComplete="off"164            enterKeyHint="go"165            className="h-10 w-full rounded-sm border border-rule bg-paper pl-8 pr-8 text-base outline-none placeholder:text-ink-3 focus:border-accent"166          />167          {q ? (168            <button type="button" onClick={() => setQ('')} className="absolute right-1 grid h-8 w-8 place-items-center text-ink-3 hover:text-ink" aria-label={t('search.clear')}>169              <X size={14} aria-hidden />170            </button>171          ) : null}172        </div>173      }174    >175      <div className="text-sm">176        {!q.trim() && recent.length > 0 ? (177          <div className="mb-1 flex items-center justify-between text-2xs text-ink-3">178            <span className="inline-flex items-center gap-1">179              <Clock size={11} aria-hidden /> {t('search.recent')}180            </span>181            <button type="button" onClick={clearRecent} className="min-h-[32px] px-1 hover:text-ink">182              {t('search.clear')}183            </button>184          </div>185        ) : null}186        {!q.trim() ? (187          <div className={cn('text-ink-3', recent.length === 0 ? 'py-6 text-center' : 'pb-3')}>188            {recent.length === 0 ? <p>{t('search.start')}</p> : null}189            <p className={cn('flex flex-wrap items-center gap-1.5 text-xs', recent.length === 0 ? 'mt-3 justify-center' : '')}>190              <span>{t('search.examples')}</span>191              {(['compare', 'rank', 'indicatorCountry', 'indicatorGroup'] as const).map((k) => {192                const ex = t(`search.example.${k}` as 'search.example.compare');193                return (194                  <button key={k} type="button" onClick={() => setQ(ex)} className="inline-flex min-h-[32px] items-center rounded-sm border border-rule px-2 font-mono text-2xs text-ink-2 hover:border-accent hover:text-accent">195                    {ex}196                  </button>197                );198              })}199            </p>200          </div>201        ) : null}202        {state === 'error' ? <p className="py-6 text-center text-down">{t('search.error')}</p> : null}203        {q.trim() && state !== 'error' && state !== 'loading' && list.length === 0 ? <p className="py-6 text-center text-ink-3">{t('search.empty', { q: q.trim() })}</p> : null}204        <ul id="search-results" role="listbox" aria-label={t('nav.search')} className="divide-y divide-rule">205          {list.map((h, i) => (206            <li key={`${h.type}-${h.id}`} id={`hit-${h.type}-${h.id}`} role="option" aria-selected={i === active}>207              <button208                type="button"209                onClick={() => go(h)}210                onMouseEnter={() => setActive(i)}211                className={cn('flex min-h-[48px] w-full items-center gap-3 rounded-sm px-2 py-2 text-left', i === active ? 'bg-surface-2' : 'hover:bg-surface-2/60')}212              >213                <span aria-hidden className="w-6 text-center text-lg leading-none">214                  {h.type === 'action' ? <span className="text-accent">→</span> : h.country?.flag ?? ''}215                </span>216                <span className="min-w-0 flex-1">217                  <span className="block truncate text-ink">{h.name}</span>218                  {h.hint ? <span className="block truncate text-xs text-ink-3">{h.hint}</span> : null}219                </span>220                <TypeChip type={h.type} />221                {i === active ? <CornerDownLeft size={14} aria-hidden className="hidden text-ink-3 md:block" /> : null}222              </button>223            </li>224          ))}225        </ul>226        <p className="mt-3 hidden text-2xs text-ink-3 md:block">{t('search.hint')}</p>227        <div aria-live="polite" className="sr-only">228          {state === 'loading' ? t('search.loading') : q.trim() ? t('search.results', { n: list.length }) : ''}229        </div>230      </div>231    </BottomSheet>232  );233}234235const CHIP_TYPE: Record<string, SearchHitType> = { country: 'country', indicator: 'indicator', topic: 'topic', region: 'region', source: 'source', country_topic: 'topic', country_indicator: 'indicator', action: 'action' };236export function TypeChip({ type }: { type: SearchHitType | string }) {237  const key = CHIP_TYPE[type] ?? 'country';238  return <span className={cn('shrink-0 rounded-xs border px-1.5 py-0.5 text-2xs uppercase tracking-wide', key === 'action' ? 'border-accent bg-accent-soft text-accent' : 'border-rule text-ink-3')}>{t(`search.type.${key}` as 'search.type.country')}</span>;239}240