spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { Plus, Search, X } from 'lucide-react';3import { usePathname, useRouter } from 'next/navigation';4import { useEffect, useMemo, useState } from 'react';5import { t } from '@/i18n';6import { clientCompare } from '@/lib/client-api-compare';7import { cn } from '@/lib/cn';8import { MAX_CUSTOM_INDICATORS, compareQuery, type CompareState } from '@/lib/compare-state';9import { TOPICS, topicById } from '@/lib/topics';10import type { IndicatorCard, IndicatorSummary } from '@/lib/types';11import { BottomSheet } from '@/components/data/bottom-sheet';1213/**14 * Custom tab header: selected indicator chips (remove) + a picker sheet searching `/api/v1/indicators`15 * (fetched once on first open). Selection is written to `?indicators=` in the URL; charts are server-rendered.16 */17export function CustomPanel({ selected, state }: { selected: IndicatorCard[]; state: CompareState }) {18 const router = useRouter();19 const pathname = usePathname();20 const [open, setOpen] = useState(false);21 const [q, setQ] = useState('');22 const [all, setAll] = useState<IndicatorSummary[] | null>(null);23 const [failed, setFailed] = useState(false);24 const slugs = state.indicators;25 const full = slugs.length >= MAX_CUSTOM_INDICATORS;2627 useEffect(() => {28 if (!open || all || failed) return;29 const ctrl = new AbortController();30 clientCompare31 .indicators(ctrl.signal)32 .then((r) => setAll(r.items.filter((i) => (i.n_countries ?? 0) > 0)))33 .catch((e) => {34 if ((e as Error).name !== 'AbortError') setFailed(true);35 });36 return () => ctrl.abort();37 }, [open, all, failed]);3839 const apply = (next: string[]) => router.replace(`${pathname}${compareQuery({ ...state, tab: 'custom', indicators: next, indicator: state.indicator && next.includes(state.indicator) ? state.indicator : null })}`, { scroll: false });4041 const results = useMemo(() => {42 if (!all) return [];43 const ql = q.trim().toLowerCase();44 const pool = all.filter((i) => !slugs.includes(i.slug));45 const list = ql ? pool.filter((i) => (i.name ?? '').toLowerCase().includes(ql) || (i.short_name ?? '').toLowerCase().includes(ql) || i.slug.includes(ql) || (i.topic ?? '').includes(ql)) : pool;46 const order = new Map(TOPICS.map((tp, i) => [tp.id as string, i]));47 return [...list].sort((a, b) => (order.get(a.topic ?? '') ?? 99) - (order.get(b.topic ?? '') ?? 99) || Number(!!b.featured) - Number(!!a.featured) || (a.name ?? '').localeCompare(b.name ?? '')).slice(0, 80);48 }, [all, q, slugs]);4950 return (51 <div>52 <div className="flex flex-wrap items-center gap-1.5">53 {selected.map((ind) => (54 <span key={ind.slug} className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface pl-2.5 text-sm md:h-9">55 <span className="max-w-[14rem] truncate text-ink">{ind.short_name ?? ind.name ?? ind.slug}</span>56 <button type="button" onClick={() => apply(slugs.filter((s) => s !== ind.slug))} className="grid h-11 w-9 place-items-center text-ink-3 hover:text-down md:h-9 md:w-7" aria-label={t('compare.custom.remove', { name: ind.short_name ?? ind.name ?? ind.slug })}>57 <X size={14} aria-hidden />58 </button>59 </span>60 ))}61 <button type="button" onClick={() => setOpen(true)} disabled={full} className={cn('inline-flex h-11 items-center gap-1 rounded-sm border border-dashed px-3 text-sm md:h-9', full ? 'border-rule text-ink-3' : 'border-rule-strong text-ink-2 hover:border-accent hover:text-accent')}>62 <Plus size={14} aria-hidden />63 {t('compare.custom.add')}64 </button>65 <span className="tnum ml-auto text-xs text-ink-3">{t('compare.custom.selected', { n: slugs.length, max: MAX_CUSTOM_INDICATORS })}</span>66 </div>67 {selected.length === 0 ? <p className="mt-4 border-t border-dashed border-rule-strong py-5 text-sm text-ink-3">{t('compare.custom.empty', { max: MAX_CUSTOM_INDICATORS })}</p> : null}6869 <BottomSheet open={open} onClose={() => setOpen(false)} side="center" title={t('compare.custom.pick')}>70 <div className="relative">71 <Search size={15} aria-hidden className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-3" />72 <input type="search" autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('compare.custom.search')} aria-label={t('compare.custom.search')} className="h-11 w-full rounded-sm border border-rule bg-surface pl-9 pr-3 text-sm text-ink outline-none placeholder:text-ink-3 focus:border-accent" />73 </div>74 <p className="tnum mt-2 text-xs text-ink-3">{full ? t('compare.custom.full', { max: MAX_CUSTOM_INDICATORS }) : t('compare.custom.selected', { n: slugs.length, max: MAX_CUSTOM_INDICATORS })}</p>75 {failed ? (76 <p className="py-6 text-sm text-ink-3">{t('common.errorHint')}</p>77 ) : !all ? (78 <p className="py-6 text-sm text-ink-3">{t('common.loading')}</p>79 ) : results.length === 0 ? (80 <p className="py-6 text-center text-sm text-ink-3">{t('compare.custom.noMatch', { q })}</p>81 ) : (82 <ul className="mt-2 divide-y divide-rule">83 {results.map((i) => (84 <li key={i.slug}>85 <button86 type="button"87 disabled={full}88 onClick={() => {89 apply([...slugs, i.slug]);90 if (slugs.length + 1 >= MAX_CUSTOM_INDICATORS) setOpen(false);91 }}92 className="flex min-h-[48px] w-full items-center gap-3 py-1.5 text-left disabled:opacity-40"93 >94 <span className="min-w-0 flex-1">95 <span className="block truncate text-sm text-ink">{i.name ?? i.slug}</span>96 <span className="block truncate text-2xs text-ink-3">97 {topicById(i.topic ?? '')?.short ?? i.topic}98 {i.unit ? ` · ${i.unit}` : ''}99 </span>100 </span>101 <Plus size={16} aria-hidden className="shrink-0 text-ink-3" />102 </button>103 </li>104 ))}105 </ul>106 )}107 </BottomSheet>108 </div>109 );110}111