'use client'; import { X } from 'lucide-react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useEffect, useMemo, useRef, useState } from 'react'; import { t } from '@/i18n'; import { cn } from '@/lib/cn'; import { formatDate, grouped } from '@/lib/format'; import { routes } from '@/lib/site'; import { TOPICS, topicById } from '@/lib/topics'; import type { IndicatorSummary } from '@/lib/types'; import { FreshnessBadge } from '@/components/data/freshness-badge'; /** * Indicator directory: ~260 rows server-rendered, filtered client-side. Topic anchor nav (sticky column on * desktop, horizontal chip bar on mobile) + text filter. `?topic=` selects one topic (membership from the API, * an indicator can belong to several topics); no topic = every topic as an anchored section. */ export function IndicatorDirectory({ items, topicMembers, initialTopic }: { items: IndicatorSummary[]; topicMembers: Record; initialTopic: string | null }) { const router = useRouter(); const [topic, setTopic] = useState(initialTopic); const [q, setQ] = useState(''); const navRef = useRef(null); useEffect(() => { const el = navRef.current?.querySelector('[aria-current="true"]'); el?.scrollIntoView({ block: 'nearest', inline: 'center', behavior: 'instant' as ScrollBehavior }); }, [topic]); const bySlug = useMemo(() => new Map(items.map((i) => [i.slug, i])), [items]); const ql = q.trim().toLowerCase(); const matches = (i: IndicatorSummary) => !ql || [i.name, i.short_name, i.slug, i.unit, i.subtopic, i.description].some((s) => (s ?? '').toLowerCase().includes(ql)); const sections = useMemo(() => { const order = topic ? TOPICS.filter((tp) => tp.id === topic) : TOPICS; return order .map((tp) => { const slugs = topicMembers[tp.id] ?? items.filter((i) => i.topic === tp.id).map((i) => i.slug); const rows = slugs.map((s) => bySlug.get(s)).filter((i): i is IndicatorSummary => !!i && matches(i)); return { tp, rows, total: slugs.length }; }) .filter((s) => s.rows.length > 0 || !ql); // eslint-disable-next-line react-hooks/exhaustive-deps }, [topic, ql, items, topicMembers, bySlug]); // Unique indicators shown: an indicator can belong to several topics, so summing section rows over-counts ("302 of 261"). const shown = new Set(sections.flatMap((s) => s.rows.map((r) => r.slug))).size; const choose = (id: string | null) => { setTopic(id); router.replace(routes.indicators(id ?? undefined), { scroll: false }); }; const nav = ( ); return (
{nav}
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" /> {q ? ( ) : null}
{t('indicators.count', { n: grouped(shown), total: grouped(items.length) })}
{shown === 0 ?

{t('indicators.noMatch')}

: null} {sections.map(({ tp, rows }) => rows.length === 0 ? null : (

{topic ? tp.name : }

{t('indicators.inTopic', { n: rows.length, topic: tp.short })}
{topic ?

{tp.blurb}

: null}
    {rows.map((i) => ( ))}
), )}
); } function IndicatorRowItem({ i }: { i: IndicatorSummary }) { 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'); const tp = topicById(i.topic ?? ''); return (
  • {i.name ?? i.slug} {i.featured ? {t('indicators.featured')} : null}
    {i.unit ? {i.unit} : null} {i.subtopic ? · {i.subtopic} : tp ? · {tp.short} : null} · {cov} {i.primary_source_id ? ( {i.primary_source_id} ) : null} {i.latest_source_updated_at ? : null} {i.latest_source_updated_at ? {t('indicators.freshness', { date: formatDate(i.latest_source_updated_at) })} : null}
    {t('indicators.openLink')} {i.ranking_eligible !== false ? ( {t('indicators.rankingLink')} ) : null} {t('indicators.compareLink')}
  • ); }