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%
6.1 KB · 111 lines tsx
Raw Blame History
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