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