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