spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1import type { ReactNode } from 'react';2import { t } from '@/i18n';3import { cn } from '@/lib/cn';45/** Dense, plain admin primitives: heading row, key/value list, compact table, status pill. */6export function AdminSection({ title, sub, actions, children, className }: { title: string; sub?: ReactNode; actions?: ReactNode; children: ReactNode; className?: string }) {7 return (8 <section className={cn('border-t border-rule py-4 first:border-t-0', className)}>9 <div className="mb-2 flex flex-wrap items-end justify-between gap-2">10 <div>11 <h2 className="text-base font-semibold text-ink">{title}</h2>12 {sub ? <p className="text-xs text-ink-3">{sub}</p> : null}13 </div>14 {actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}15 </div>16 {children}17 </section>18 );19}2021export function Kv({ rows, className }: { rows: Array<[string, ReactNode]>; className?: string }) {22 return (23 <dl className={cn('grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] gap-x-4 gap-y-1 text-xs', className)}>24 {rows.map(([k, v]) => (25 <div key={k} className="contents">26 <dt className="text-ink-3">{k}</dt>27 <dd className="tnum break-all text-ink">{v ?? t('admin.na')}</dd>28 </div>29 ))}30 </dl>31 );32}3334export interface Col<T> {35 key: string;36 header: ReactNode;37 cell: (row: T) => ReactNode;38 numeric?: boolean;39 className?: string;40}4142/** Compact table with horizontal scroll on narrow screens (admin only — dense by design). */43export function AdminTable<T>({ rows, columns, rowKey, className, empty }: { rows: T[]; columns: Col<T>[]; rowKey: (r: T, i: number) => string; className?: string; empty?: ReactNode }) {44 if (rows.length === 0) return <p className="py-3 text-xs text-ink-3">{empty ?? t('common.noDataLong')}</p>;45 return (46 <div className={cn('-mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0', className)}>47 <table className="w-full min-w-[640px] border-collapse text-xs">48 <thead>49 <tr className="border-b border-rule text-left text-2xs uppercase tracking-wide text-ink-3">50 {columns.map((c) => (51 <th key={c.key} scope="col" className={cn('py-1.5 pr-3 font-medium', c.numeric && 'text-right', c.className)}>52 {c.header}53 </th>54 ))}55 </tr>56 </thead>57 <tbody className="divide-y divide-rule">58 {rows.map((r, i) => (59 <tr key={rowKey(r, i)} className="hover:bg-surface-2/60">60 {columns.map((c) => (61 <td key={c.key} className={cn('py-1.5 pr-3 align-top', c.numeric && 'tnum text-right', c.className)}>62 {c.cell(r)}63 </td>64 ))}65 </tr>66 ))}67 </tbody>68 </table>69 </div>70 );71}7273export function Pill({ tone, children }: { tone: 'ok' | 'warn' | 'bad' | 'muted'; children: ReactNode }) {74 return (75 <span className={cn('inline-flex items-center rounded-sm border px-1.5 py-px text-2xs font-medium', tone === 'ok' && 'border-up/40 text-up', tone === 'warn' && 'border-warn/40 text-warn', tone === 'bad' && 'border-down/40 text-down', tone === 'muted' && 'border-rule text-ink-3')}>76 {children}77 </span>78 );79}8081export function statusTone(s: string | null | undefined): 'ok' | 'warn' | 'bad' | 'muted' {82 const v = (s ?? '').toLowerCase();83 if (v === 'ok' || v === 'verified') return 'ok';84 if (v === 'partial' || v === 'warning' || v === 'stale') return 'warn';85 if (v === 'failed' || v === 'quarantined' || v === 'error') return 'bad';86 return 'muted';87}8889export function bytes(n: number | null | undefined): string {90 if (n == null) return t('admin.na');91 if (n >= 1e9) return `${(n / 1e9).toFixed(2)} GB`;92 if (n >= 1e6) return `${(n / 1e6).toFixed(1)} MB`;93 if (n >= 1e3) return `${(n / 1e3).toFixed(0)} kB`;94 return `${n} B`;95}9697export function ts(iso: string | null | undefined): string {98 if (!iso) return t('admin.na');99 return iso.replace('T', ' ').replace(/\.\d+/, '').replace(/\+00:00$/, 'Z');100}101