spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
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