spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { ChevronRight, Search, X } from 'lucide-react';3import Link from 'next/link';4import { useMemo, useState } from 'react';5import { t } from '@/i18n';6import { grouped } from '@/lib/format';7import { routes } from '@/lib/site';8import { TOPICS } from '@/lib/topics';9import type { RankingRow } from '@/lib/types';10import type { RankableIndicator } from '@/lib/types-compare';11import { Top3Preview } from './top3-preview';1213/**14 * /rankings directory: featured first, then every rankable indicator grouped by topic. Each entry shows15 * "n countries · year" and a top-3 preview. The search box filters client-side on name / unit / topic.16 */17export function RankingsDirectory({ items, previews }: { items: RankableIndicator[]; previews: Record<string, RankingRow[] | null> }) {18 const [q, setQ] = useState('');19 const ql = q.trim().toLowerCase();20 const filtered = useMemo(() => (ql ? items.filter((i) => (i.name ?? '').toLowerCase().includes(ql) || (i.short_name ?? '').toLowerCase().includes(ql) || (i.unit ?? '').toLowerCase().includes(ql) || (i.topic ?? '').includes(ql) || (i.subtopic ?? '').toLowerCase().includes(ql)) : items), [items, ql]);21 const featured = filtered.filter((i) => i.featured);22 const byTopic = TOPICS.map((tp) => ({ tp, items: filtered.filter((i) => i.topic === tp.id).sort((a, b) => Number(!!b.featured) - Number(!!a.featured) || (a.name ?? '').localeCompare(b.name ?? '')) })).filter((g) => g.items.length);23 const other = filtered.filter((i) => !TOPICS.some((tp) => tp.id === i.topic));2425 return (26 <div>27 <div className="sticky top-[52px] z-20 -mx-4 border-b border-rule bg-paper/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-paper/85 sm:-mx-6 sm:px-6 md:static md:mx-0 md:border-0 md:bg-transparent md:px-0 md:py-3">28 <div className="flex items-center gap-3">29 <div className="relative min-w-0 flex-1 md:max-w-md">30 <Search size={15} aria-hidden className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-3" />31 <input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('rankings.search')} aria-label={t('rankings.search')} className="h-11 w-full rounded-sm border border-rule bg-surface pl-9 pr-9 text-sm text-ink outline-none placeholder:text-ink-3 focus:border-accent md:h-10" />32 {q ? (33 <button type="button" onClick={() => setQ('')} className="absolute right-1 top-1/2 grid h-9 w-9 -translate-y-1/2 place-items-center text-ink-3 hover:text-ink" aria-label={t('search.clear')}>34 <X size={14} aria-hidden />35 </button>36 ) : null}37 </div>38 <span className="tnum shrink-0 text-xs text-ink-3">{ql ? t('rankings.countOf', { n: grouped(filtered.length), total: grouped(items.length) }) : t('rankings.count', { n: grouped(items.length) })}</span>39 </div>40 </div>4142 {filtered.length === 0 ? <p className="py-10 text-center text-sm text-ink-3">{t('rankings.noMatch', { q })}</p> : null}4344 {featured.length && !ql ? (45 <section className="pt-4 pb-6 md:pt-6 md:pb-8" aria-labelledby="featured-h">46 <h2 id="featured-h" className="display text-xl text-ink md:text-2xl">47 {t('rankings.featured')}48 </h2>49 <p className="mt-1 text-sm text-ink-2">{t('rankings.featured.sub')}</p>50 <ul className="mt-3 grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">51 {featured.map((i) => (52 <Entry key={i.slug} item={i} preview={previews[i.slug]} />53 ))}54 </ul>55 </section>56 ) : null}5758 {byTopic.map(({ tp, items: rows }) => (59 <section key={tp.id} id={`topic-${tp.id}`} className="hairline scroll-mt-28 pt-5 pb-6 md:pt-6 md:pb-8" aria-labelledby={`topic-${tp.id}-h`}>60 <div className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1">61 <h2 id={`topic-${tp.id}-h`} className="display text-xl text-ink md:text-2xl">62 {tp.name}63 </h2>64 <span className="tnum text-xs text-ink-3">{t('rankings.count', { n: rows.length })}</span>65 </div>66 <ul className="mt-2 grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">67 {rows.map((i) => (68 <Entry key={i.slug} item={i} preview={previews[i.slug]} />69 ))}70 </ul>71 </section>72 ))}7374 {other.length ? (75 <section className="hairline pt-5 pb-6">76 <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">77 {other.map((i) => (78 <Entry key={i.slug} item={i} preview={previews[i.slug]} />79 ))}80 </ul>81 </section>82 ) : null}83 </div>84 );85}8687function Entry({ item, preview }: { item: RankableIndicator; preview: RankingRow[] | null | undefined }) {88 const name = item.name ?? item.slug;89 return (90 <li className="border-t border-rule">91 <Link href={routes.ranking(item.slug)} className="group block min-h-[72px] py-2.5" aria-label={t('rankings.open', { name })}>92 <span className="flex items-start justify-between gap-2">93 <span className="min-w-0">94 <span className="block text-sm font-medium leading-snug text-ink group-hover:text-accent">{name}</span>95 <span className="tnum block text-2xs text-ink-3">96 {t('rankings.meta', { n: grouped(item.ranking_n ?? item.n_countries ?? 0), year: item.ranking_year ?? item.last_year ?? '' })}97 {item.unit ? ` · ${item.unit}` : ''}98 </span>99 </span>100 <ChevronRight size={14} aria-hidden className="mt-0.5 shrink-0 text-ink-3 group-hover:text-accent" />101 </span>102 <span className="mt-1 block">103 <Top3Preview slug={item.slug} spec={item} initial={preview} />104 </span>105 </Link>106 </li>107 );108}109