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%
13.2 KB · 261 lines tsx
Raw Blame History
1'use client';2import { ArrowDownAZ, ArrowDownWideNarrow, SlidersHorizontal, X } from 'lucide-react';3import Link from 'next/link';4import { useRouter, useSearchParams } from 'next/navigation';5import { useCallback, useMemo, useState } from 'react';6import { t } from '@/i18n';7import { cn } from '@/lib/cn';8import { compact, formatPct, grouped, isNum } from '@/lib/format';9import { INCOME_GROUPS, WB_REGIONS } from '@/lib/regions';10import { regionShort } from '@/lib/regions';11import { routes } from '@/lib/site';12import type { CountrySummary } from '@/lib/types';13import { BottomSheet } from '@/components/data/bottom-sheet';1415type SortKey = 'name' | 'population' | 'gdp' | 'coverage';1617/**18 * Country directory: the 218 rows are server-rendered (this client component SSRs with the full list) and19 * filtered/sorted client-side. Region + income chips (bottom sheet on mobile), sort control, A–Z grouping20 * with a sticky letter index on desktop. URL `?region=&income=&q=&sort=` is kept in sync (shallow).21 */22export function CountryDirectory({ items }: { items: CountrySummary[] }) {23  const params = useSearchParams();24  const router = useRouter();25  const [q, setQ] = useState(params.get('q') ?? '');26  const [region, setRegion] = useState<string | null>(params.get('region'));27  const [income, setIncome] = useState<string | null>(params.get('income'));28  const [sort, setSort] = useState<SortKey>((params.get('sort') as SortKey) || 'name');29  const [sheet, setSheet] = useState(false);3031  const sync = useCallback(32    (next: { q?: string; region?: string | null; income?: string | null; sort?: SortKey }) => {33      const p = new URLSearchParams();34      const nq = next.q ?? q;35      const nr = next.region === undefined ? region : next.region;36      const ni = next.income === undefined ? income : next.income;37      const ns = next.sort ?? sort;38      if (nq) p.set('q', nq);39      if (nr) p.set('region', nr);40      if (ni) p.set('income', ni);41      if (ns !== 'name') p.set('sort', ns);42      const s = p.toString();43      router.replace(`${routes.countries()}${s ? `?${s}` : ''}`, { scroll: false });44    },45    [q, region, income, sort, router],46  );4748  const filtered = useMemo(() => {49    const ql = q.trim().toLowerCase();50    let rows = items.filter((c) => (!region || (c.region ?? '').toUpperCase() === region.toUpperCase()) && (!income || (c.income ?? '').toUpperCase() === income.toUpperCase()));51    if (ql) rows = rows.filter((c) => (c.name ?? '').toLowerCase().includes(ql) || c.id.toLowerCase() === ql || (c.capital ?? '').toLowerCase().includes(ql));52    const num = (v: number | null) => (isNum(v) ? v : -Infinity);53    rows.sort((a, b) => {54      if (sort === 'population') return num(b.population_latest) - num(a.population_latest) || (a.name ?? '').localeCompare(b.name ?? '');55      if (sort === 'gdp') return num(b.gdp_per_capita_latest) - num(a.gdp_per_capita_latest) || (a.name ?? '').localeCompare(b.name ?? '');56      if (sort === 'coverage') return num(b.coverage_pct) - num(a.coverage_pct) || (a.name ?? '').localeCompare(b.name ?? '');57      return (a.name ?? '').localeCompare(b.name ?? '');58    });59    return rows;60  }, [items, q, region, income, sort]);6162  const groupsAZ = useMemo(() => {63    if (sort !== 'name') return null;64    const m = new Map<string, CountrySummary[]>();65    for (const c of filtered) {66      const letter = (c.name ?? '#').charAt(0).toUpperCase();67      const key = /[A-Z]/.test(letter) ? letter : '#';68      if (!m.has(key)) m.set(key, []);69      m.get(key)!.push(c);70    }71    return Array.from(m.entries());72  }, [filtered, sort]);7374  const activeCount = (region ? 1 : 0) + (income ? 1 : 0);75  const letters = groupsAZ?.map(([l]) => l) ?? [];7677  const filters = (78    <div className="space-y-4">79      <ChipGroup label={t('common.region')} value={region} onChange={(v) => { setRegion(v); sync({ region: v }); }} options={WB_REGIONS.map((r) => ({ id: r.id, label: r.short }))} />80      <ChipGroup label={t('common.income')} value={income} onChange={(v) => { setIncome(v); sync({ income: v }); }} options={INCOME_GROUPS.map((g) => ({ id: g.id, label: g.name }))} />81    </div>82  );8384  return (85    <div className="lg:grid lg:grid-cols-[1fr_2.5rem] lg:gap-6">86      <div className="min-w-0">87        {/* Controls row */}88        <div className="flex flex-wrap items-center gap-2 py-3">89          <div className="relative min-w-0 flex-1 basis-56">90            <input91              type="search"92              value={q}93              onChange={(e) => { setQ(e.target.value); sync({ q: e.target.value }); }}94              placeholder={t('countries.search')}95              aria-label={t('countries.search')}96              className="h-10 w-full rounded-sm border border-rule bg-surface px-3 text-sm outline-none placeholder:text-ink-3 focus:border-accent"97            />98            {q ? (99              <button type="button" onClick={() => { setQ(''); sync({ q: '' }); }} className="absolute right-1 top-1 grid h-8 w-8 place-items-center text-ink-3 hover:text-ink" aria-label={t('search.clear')}>100                <X size={14} aria-hidden />101              </button>102            ) : null}103          </div>104          <label className="inline-flex h-10 items-center gap-1.5 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2">105            {sort === 'name' ? <ArrowDownAZ size={15} aria-hidden /> : <ArrowDownWideNarrow size={15} aria-hidden />}106            <span className="sr-only">{t('common.sortBy')}</span>107            <select value={sort} onChange={(e) => { const v = e.target.value as SortKey; setSort(v); sync({ sort: v }); }} className="bg-transparent pr-1 text-ink outline-none">108              <option value="name">{t('countries.sort.name')}</option>109              <option value="population">{t('countries.sort.population')}</option>110              <option value="gdp">{t('countries.sort.gdp')}</option>111              <option value="coverage">{t('countries.sort.coverage')}</option>112            </select>113          </label>114          <button type="button" onClick={() => setSheet(true)} className={cn('inline-flex h-10 items-center gap-1.5 rounded-sm border px-3 text-sm md:hidden', activeCount ? 'border-accent text-accent' : 'border-rule text-ink-2')}>115            <SlidersHorizontal size={15} aria-hidden />116            {t('countries.filters')}117            {activeCount ? <span className="tnum">({activeCount})</span> : null}118          </button>119          <span className="tnum ml-auto text-xs text-ink-3">{t('countries.count', { n: grouped(filtered.length), total: grouped(items.length) })}</span>120        </div>121        <div className="hidden pb-3 md:block">{filters}</div>122        {(region || income) && (123          <div className="flex flex-wrap items-center gap-2 pb-3 md:hidden">124            {region ? <ActiveChip label={WB_REGIONS.find((r) => r.id === region)?.short ?? region} onClear={() => { setRegion(null); sync({ region: null }); }} /> : null}125            {income ? <ActiveChip label={INCOME_GROUPS.find((g) => g.id === income)?.name ?? income} onClear={() => { setIncome(null); sync({ income: null }); }} /> : null}126          </div>127        )}128129        {/* Header row (sm+) */}130        <div className="hidden grid-cols-[minmax(0,1fr)_7rem_7rem_5rem] gap-3 border-b border-rule pb-1.5 text-2xs font-medium uppercase tracking-wide text-ink-3 sm:grid md:grid-cols-[minmax(0,1fr)_13rem_8rem_8rem_5rem]">131          <span>{t('common.country')}</span>132          <span className="hidden md:block">{t('common.region')}</span>133          <span className="text-right">{t('common.population')}</span>134          <span className="text-right">{t('common.gdpPerCapita')}</span>135          <span className="text-right">{t('common.coverage')}</span>136        </div>137138        {filtered.length === 0 ? <p className="py-10 text-center text-sm text-ink-3">{t('countries.noMatch')}</p> : null}139140        {groupsAZ ? (141          groupsAZ.map(([letter, rows]) => (142            <section key={letter} id={`letter-${letter}`} className="scroll-mt-28">143              <h2 className="display sticky top-[52px] z-10 -mx-4 border-b border-rule bg-paper/95 px-4 py-1 text-lg text-ink-2 backdrop-blur sm:mx-0 sm:px-0 md:top-14 md:static md:mt-4 md:border-0 md:bg-transparent md:py-0">144                {letter}145              </h2>146              <ul className="divide-y divide-rule">147                {rows.map((c) => (148                  <Row key={c.id} c={c} />149                ))}150              </ul>151            </section>152          ))153        ) : (154          <ol className="divide-y divide-rule">155            {filtered.map((c, i) => (156              <Row key={c.id} c={c} rank={i + 1} />157            ))}158          </ol>159        )}160      </div>161162      {/* Sticky letter index (desktop) */}163      {letters.length > 1 ? (164        <nav aria-label={t('countries.letterIndex')} className="hidden lg:block">165          <ol className="sticky top-20 flex flex-col items-center gap-0.5 text-2xs">166            {letters.map((l) => (167              <li key={l}>168                <a href={`#letter-${l}`} className="grid h-6 w-8 place-items-center rounded-xs text-ink-3 hover:bg-surface-2 hover:text-accent">169                  {l}170                </a>171              </li>172            ))}173          </ol>174        </nav>175      ) : null}176177      <BottomSheet open={sheet} onClose={() => setSheet(false)} side="center" title={t('countries.filters')}>178        {filters}179        <div className="mt-6 flex justify-between">180          <button type="button" className="tap rounded-sm px-3 text-sm text-ink-2 hover:bg-surface-2" onClick={() => { setRegion(null); setIncome(null); sync({ region: null, income: null }); }}>181            {t('common.reset')}182          </button>183          <button type="button" className="tap rounded-sm bg-ink px-4 text-sm font-medium text-paper" onClick={() => setSheet(false)}>184            {t('common.apply')}185          </button>186        </div>187      </BottomSheet>188    </div>189  );190}191192function Row({ c, rank }: { c: CountrySummary; rank?: number }) {193  return (194    <li>195      <Link href={routes.country(c.slug ?? c.id)} className="group grid min-h-[52px] grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 py-2 sm:grid-cols-[minmax(0,1fr)_7rem_7rem_5rem] md:grid-cols-[minmax(0,1fr)_13rem_8rem_8rem_5rem]">196        <span className="flex min-w-0 items-center gap-2.5">197          {rank ? <span className="tnum w-6 shrink-0 text-right text-xs text-ink-3">{rank}</span> : null}198          <span aria-hidden className="text-xl leading-none">199            {c.flag}200          </span>201          <span className="min-w-0">202            <span className="block truncate text-sm font-medium text-ink group-hover:text-accent">203              {c.name}204              {c.kind === 'territory' ? <span className="ml-1.5 rounded-xs border border-rule px-1 text-2xs font-normal text-ink-3">{t('countries.territory')}</span> : null}205            </span>206            <span className="block truncate text-xs text-ink-3 md:hidden">{regionShort(c.region) ?? c.region_name ?? ''}</span>207          </span>208        </span>209        <span className="hidden truncate text-xs text-ink-2 md:block">{c.region_name}</span>210        <span className="tnum text-right text-sm text-ink sm:text-sm">211          <span className="block">{isNum(c.population_latest) ? compact(c.population_latest) : t('common.na')}</span>212          <span className="block text-2xs text-ink-3 sm:hidden">{isNum(c.gdp_per_capita_latest) ? `US$${compact(c.gdp_per_capita_latest)} /cap` : ''}</span>213        </span>214        <span className="tnum hidden text-right text-sm text-ink sm:block">{isNum(c.gdp_per_capita_latest) ? `US$${compact(c.gdp_per_capita_latest)}` : t('common.na')}</span>215        <span className="tnum hidden text-right text-xs text-ink-2 sm:block">{formatPct(c.coverage_pct)}</span>216      </Link>217    </li>218  );219}220221function ChipGroup({ label, value, onChange, options }: { label: string; value: string | null; onChange: (v: string | null) => void; options: Array<{ id: string; label: string }> }) {222  return (223    <div>224      <div className="eyebrow mb-1.5">{label}</div>225      <ul className="flex flex-wrap gap-1.5" role="radiogroup" aria-label={label}>226        <li>227          <Chip active={!value} onClick={() => onChange(null)}>228            {t('common.all')}229          </Chip>230        </li>231        {options.map((o) => (232          <li key={o.id}>233            <Chip active={value?.toUpperCase() === o.id.toUpperCase()} onClick={() => onChange(value?.toUpperCase() === o.id.toUpperCase() ? null : o.id)}>234              {o.label}235            </Chip>236          </li>237        ))}238      </ul>239    </div>240  );241}242243function Chip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {244  return (245    <button type="button" role="radio" aria-checked={active} onClick={onClick} className={cn('inline-flex h-11 items-center rounded-sm border px-3 text-sm md:h-9 md:px-2.5', active ? 'border-ink bg-ink text-paper' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>246      {children}247    </button>248  );249}250251function ActiveChip({ label, onClear }: { label: string; onClear: () => void }) {252  return (253    <span className="inline-flex h-8 items-center gap-1 rounded-sm bg-accent-soft pl-2.5 pr-1 text-xs text-accent">254      {label}255      <button type="button" onClick={onClear} className="grid h-7 w-7 place-items-center" aria-label={t('common.reset')}>256        <X size={12} aria-hidden />257      </button>258    </span>259  );260}261