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%
8.9 KB · 163 lines tsx
Raw Blame History
1'use client';2import { X } from 'lucide-react';3import Link from 'next/link';4import { useRouter } from 'next/navigation';5import { useEffect, useMemo, useRef, useState } from 'react';6import { t } from '@/i18n';7import { cn } from '@/lib/cn';8import { formatDate, grouped } from '@/lib/format';9import { routes } from '@/lib/site';10import { TOPICS, topicById } from '@/lib/topics';11import type { IndicatorSummary } from '@/lib/types';12import { FreshnessBadge } from '@/components/data/freshness-badge';1314/**15 * Indicator directory: ~260 rows server-rendered, filtered client-side. Topic anchor nav (sticky column on16 * desktop, horizontal chip bar on mobile) + text filter. `?topic=` selects one topic (membership from the API,17 * an indicator can belong to several topics); no topic = every topic as an anchored section.18 */19export function IndicatorDirectory({ items, topicMembers, initialTopic }: { items: IndicatorSummary[]; topicMembers: Record<string, string[]>; initialTopic: string | null }) {20  const router = useRouter();21  const [topic, setTopic] = useState<string | null>(initialTopic);22  const [q, setQ] = useState('');23  const navRef = useRef<HTMLElement>(null);2425  useEffect(() => {26    const el = navRef.current?.querySelector<HTMLElement>('[aria-current="true"]');27    el?.scrollIntoView({ block: 'nearest', inline: 'center', behavior: 'instant' as ScrollBehavior });28  }, [topic]);2930  const bySlug = useMemo(() => new Map(items.map((i) => [i.slug, i])), [items]);31  const ql = q.trim().toLowerCase();32  const matches = (i: IndicatorSummary) => !ql || [i.name, i.short_name, i.slug, i.unit, i.subtopic, i.description].some((s) => (s ?? '').toLowerCase().includes(ql));3334  const sections = useMemo(() => {35    const order = topic ? TOPICS.filter((tp) => tp.id === topic) : TOPICS;36    return order37      .map((tp) => {38        const slugs = topicMembers[tp.id] ?? items.filter((i) => i.topic === tp.id).map((i) => i.slug);39        const rows = slugs.map((s) => bySlug.get(s)).filter((i): i is IndicatorSummary => !!i && matches(i));40        return { tp, rows, total: slugs.length };41      })42      .filter((s) => s.rows.length > 0 || !ql);43    // eslint-disable-next-line react-hooks/exhaustive-deps44  }, [topic, ql, items, topicMembers, bySlug]);4546  // Unique indicators shown: an indicator can belong to several topics, so summing section rows over-counts ("302 of 261").47  const shown = new Set(sections.flatMap((s) => s.rows.map((r) => r.slug))).size;4849  const choose = (id: string | null) => {50    setTopic(id);51    router.replace(routes.indicators(id ?? undefined), { scroll: false });52  };5354  const nav = (55    <nav ref={navRef} aria-label={t('indicators.topics')} className="lg:sticky lg:top-20">56      <ul className="scrollbar-none -mx-4 flex gap-1 overflow-x-auto px-4 pb-1 lg:mx-0 lg:flex-col lg:gap-0 lg:overflow-visible lg:px-0">57        <li className="shrink-0">58          <button type="button" aria-current={topic == null} onClick={() => choose(null)} className={cn('inline-flex h-11 w-full items-center justify-between gap-2 whitespace-nowrap rounded-sm px-3 text-sm lg:h-9 lg:px-2', topic == null ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')}>59            {t('indicators.allTopics')}60            <span className={cn('tnum text-2xs', topic == null ? 'text-paper/70' : 'text-ink-3')}>{grouped(items.length)}</span>61          </button>62        </li>63        {TOPICS.map((tp) => {64          const n = (topicMembers[tp.id] ?? []).length || items.filter((i) => i.topic === tp.id).length;65          const active = topic === tp.id;66          return (67            <li key={tp.id} className="shrink-0">68              <button type="button" aria-current={active} onClick={() => choose(tp.id)} className={cn('inline-flex h-11 w-full items-center justify-between gap-2 whitespace-nowrap rounded-sm px-3 text-sm lg:h-9 lg:px-2', active ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')}>69                {tp.short}70                <span className={cn('tnum text-2xs', active ? 'text-paper/70' : 'text-ink-3')}>{n}</span>71              </button>72            </li>73          );74        })}75      </ul>76    </nav>77  );7879  return (80    <div className="lg:grid lg:grid-cols-[13rem_minmax(0,1fr)] lg:gap-10">81      <div className="lg:self-start">{nav}</div>82      <div className="min-w-0">83        <div className="flex flex-wrap items-center gap-2 py-3">84          <div className="relative min-w-0 flex-1 basis-56">85            <input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('indicators.search')} aria-label={t('indicators.search')} className="h-11 w-full rounded-sm border border-rule bg-surface px-3 text-sm outline-none placeholder:text-ink-3 focus:border-accent md:h-10" />86            {q ? (87              <button type="button" onClick={() => setQ('')} className="absolute right-0 top-0 grid h-11 w-10 place-items-center text-ink-3 hover:text-ink md:h-10" aria-label={t('search.clear')}>88                <X size={14} aria-hidden />89              </button>90            ) : null}91          </div>92          <span className="tnum ml-auto text-xs text-ink-3">{t('indicators.count', { n: grouped(shown), total: grouped(items.length) })}</span>93        </div>9495        {shown === 0 ? <p className="py-10 text-center text-sm text-ink-3">{t('indicators.noMatch')}</p> : null}9697        {sections.map(({ tp, rows }) =>98          rows.length === 0 ? null : (99            <section key={tp.id} id={`topic-${tp.id}`} className="scroll-mt-28 pt-4 pb-6">100              <div className="flex items-baseline justify-between gap-3 border-b border-rule pb-1.5">101                <h2 className="display text-xl text-ink md:text-2xl">102                  {topic ? tp.name : <button type="button" onClick={() => choose(tp.id)} className="link-quiet text-left">{tp.name}</button>}103                </h2>104                <span className="tnum text-xs text-ink-3">{t('indicators.inTopic', { n: rows.length, topic: tp.short })}</span>105              </div>106              {topic ? <p className="mt-1 text-sm text-ink-2">{tp.blurb}</p> : null}107              <ul className="divide-y divide-rule">108                {rows.map((i) => (109                  <IndicatorRowItem key={`${tp.id}-${i.slug}`} i={i} />110                ))}111              </ul>112            </section>113          ),114        )}115      </div>116    </div>117  );118}119120function IndicatorRowItem({ i }: { i: IndicatorSummary }) {121  const cov = i.n_countries != null ? (i.first_year && i.last_year ? t('indicators.coverage', { n: grouped(i.n_countries), y0: i.first_year, y1: i.last_year }) : t('indicators.coverageShort', { n: grouped(i.n_countries) })) : t('common.noData');122  const tp = topicById(i.topic ?? '');123  return (124    <li className="py-2.5">125      <div className="grid gap-x-6 gap-y-1 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">126        <div className="min-w-0">127          <Link href={routes.indicator(i.slug)} className="link-quiet -my-1 inline-flex min-h-[44px] items-center py-1 text-sm font-medium text-ink md:min-h-0">128            {i.name ?? i.slug}129            {i.featured ? <span className="ml-2 rounded-xs border border-rule px-1 text-2xs font-normal uppercase tracking-wide text-ink-3">{t('indicators.featured')}</span> : null}130          </Link>131          <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-3">132            {i.unit ? <span>{i.unit}</span> : null}133            {i.subtopic ? <span>· {i.subtopic}</span> : tp ? <span>· {tp.short}</span> : null}134            <span className="tnum">· {cov}</span>135            {i.primary_source_id ? (136              <Link href={routes.source(i.primary_source_id)} className="-my-3 inline-flex min-h-[44px] items-center md:-my-1 md:min-h-0">137                <span className="rounded-xs border border-rule px-1 py-px text-2xs uppercase tracking-wide hover:border-accent hover:text-accent">138                  {i.primary_source_id}139                </span>140              </Link>141            ) : null}142            {i.latest_source_updated_at ? <FreshnessBadge retrievedAt={i.latest_source_updated_at} /> : null}143            {i.latest_source_updated_at ? <span className="sr-only">{t('indicators.freshness', { date: formatDate(i.latest_source_updated_at) })}</span> : null}144          </div>145        </div>146        <div className="flex flex-wrap gap-x-3 text-xs">147          <Link href={routes.indicator(i.slug)} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">148            {t('indicators.openLink')}149          </Link>150          {i.ranking_eligible !== false ? (151            <Link href={routes.ranking(i.slug)} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">152              {t('indicators.rankingLink')}153            </Link>154          ) : null}155          <Link href={`${routes.compare()}?indicator=${encodeURIComponent(i.slug)}`} className="inline-flex min-h-[44px] items-center text-ink-2 hover:text-accent hover:underline md:min-h-[32px]">156            {t('indicators.compareLink')}157          </Link>158        </div>159      </div>160    </li>161  );162}163