SPB Git forge
3commits 1branches 0releases
417.0 KBsize
maindefault branch
10 days agolast push
TypeScript 66.5% Python 30.9% JavaScript 1.4% CSS 0.7%
8.1 KB · 151 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import { Search } from 'lucide-react';3import Link from 'next/link';4import { ChipRow, EmptyRow, ScrollTable, entityMetadata, first, type Params } from '@/components/entities/shared';5import { Pagination } from '@/components/ui/pagination';6import { Container, PageHeader } from '@/components/ui/section';7import { Unavailable } from '@/components/ui/unavailable';8import { api, safe } from '@/lib/api';9import { fmtDate, fmtInt, num, titleCase } from '@/lib/format';10import { routes } from '@/lib/site';11import type { OperatorRow } from '@/lib/types';1213type SearchParams = Record<string, string | string[] | undefined>;1415const SORTS: { value: string | undefined; label: string }[] = [16  { value: undefined, label: 'Active payloads' },17  { value: 'total', label: 'Total payloads' },18  { value: 'growth', label: 'Growth 365 d' },19  { value: 'name', label: 'Name' },20];21const SORT_NOUN: Record<string, string> = { '': 'active payloads', total: 'total payloads launched', growth: 'payloads launched in the last 365 days', name: 'name' };22const KINDS = ['operator', 'agency', 'military', 'manufacturer', 'launch_provider'];2324function parse(sp: SearchParams): Params {25  return { sort: first(sp.sort), kind: first(sp.kind), country: first(sp.country), q: first(sp.q)?.trim() || undefined, page: first(sp.page) };26}2728export async function generateMetadata({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<Metadata> {29  const p = parse(await searchParams);30  const bits = [p.kind && titleCase(p.kind), p.country && titleCase(p.country), p.q && `“${p.q}”`].filter(Boolean).join(' · ');31  return entityMetadata({32    title: bits ? `Satellite operators — ${bits}` : 'Satellite operators, agencies and military programmes — ranked',33    description: 'Every organisation operating satellites, ranked by active payloads: commercial operators, space agencies and military programmes with fleet size, constellations, launches and 365-day growth.',34    path: routes.operators(),35  });36}3738export default async function OperatorsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {39  const params = parse(await searchParams);40  const page = Math.max(1, Number(params.page) || 1);41  const res = await safe(api.operators({ sort: params.sort, kind: params.kind, country: params.country, q: params.q, page }));42  const rows = res?.data ?? [];43  const total = res?.pagination.total ?? null;44  const base = routes.operators();45  const activeSum = rows.reduce((s, r) => s + (num(r.active_payloads) ?? 0), 0);4647  return (48    <Container wide>49      <PageHeader50        eyebrow="Operators"51        title="Satellite operators"52        lede={53          total !== null ? (54            <>55              {fmtInt(total)} organisations{params.q ? ` matching “${params.q}”` : params.kind || params.country ? ' in this selection' : ''} — commercial operators, agencies and military programmes — ranked by {SORT_NOUN[params.sort ?? ''] ?? 'active payloads'}.56              {rows.length > 0 && <> The {fmtInt(rows.length)} shown here operate {fmtInt(activeSum)} active payloads.</>}57            </>58          ) : (59            'Operator rankings are temporarily unavailable.'60          )61        }62      >63        <form action={base} method="get" role="search" className="mt-6 flex max-w-lg items-stretch gap-2">64          {params.sort && <input type="hidden" name="sort" value={params.sort} />}65          {params.kind && <input type="hidden" name="kind" value={params.kind} />}66          {params.country && <input type="hidden" name="country" value={params.country} />}67          <label className="relative flex-1">68            <span className="sr-only">Search operators</span>69            <Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-ink-3" aria-hidden />70            <input type="search" name="q" defaultValue={params.q ?? ''} placeholder="Search operators, agencies, aliases…" className="h-11 w-full rounded-md border border-rule bg-plane-2 pl-9 pr-3 text-sm text-ink placeholder:text-ink-3" />71          </label>72          <button type="submit" className="h-11 rounded-md bg-accent px-4 text-sm font-medium text-accent-ink">Search</button>73          {params.q && (74            <Link href={base} className="inline-flex h-11 items-center rounded-md border border-rule px-3 text-sm text-ink-2 hover:bg-plane-2">Clear</Link>75          )}76        </form>77      </PageHeader>7879      <div className="flex flex-col gap-2 border-y border-rule py-2">80        <ChipRow label="Sort" paramKey="sort" base={base} params={params} current={params.sort} options={SORTS} />81        <ChipRow label="Kind" paramKey="kind" base={base} params={params} current={params.kind} options={[{ value: undefined, label: 'All' }, ...KINDS.map((k) => ({ value: k, label: titleCase(k) }))]} />82        {params.country && <ChipRow label="Country" paramKey="country" base={base} params={params} current={params.country} options={[{ value: undefined, label: 'All countries' }, { value: params.country, label: titleCase(params.country) }]} />}83      </div>8485      <div className="py-6">86        {!res ? <Unavailable what="Operator rankings" /> : <OperatorTable rows={rows} offset={(page - 1) * (res.pagination.page_size || 100)} />}87        {res && (88          <Pagination89            className="mt-4"90            page={res.pagination.page}91            pages={res.pagination.pages}92            total={res.pagination.total}93            pageSize={res.pagination.page_size}94            makeHref={(p) => {95              const q = new URLSearchParams();96              for (const [k, v] of Object.entries(params)) if (v && k !== 'page') q.set(k, v);97              if (p > 1) q.set('page', String(p));98              const s = q.toString();99              return s ? `${base}?${s}` : base;100            }}101          />102        )}103      </div>104    </Container>105  );106}107108function OperatorTable({ rows, offset }: { rows: OperatorRow[]; offset: number }) {109  return (110    <ScrollTable>111      <table className="data-table stack">112        <thead>113          <tr>114            <th className="num max-md:hidden!">#</th>115            <th>Operator</th>116            <th>Kind</th>117            <th>Country</th>118            <th className="num">Active</th>119            <th className="num max-md:hidden!">On orbit</th>120            <th className="num">Total</th>121            <th className="num">Constellations</th>122            <th className="num">Launches</th>123            <th className="num">365 d</th>124            <th className="max-md:hidden!">First launch</th>125            <th>Last launch</th>126          </tr>127        </thead>128        <tbody>129          {rows.length === 0 && <EmptyRow colSpan={12}>No operator matches this query.</EmptyRow>}130          {rows.map((o, i) => (131            <tr key={o.id}>132              <td data-label="Rank" className="num mono text-xs text-ink-3 max-md:hidden!">{offset + i + 1}</td>133              <td className="primary" data-label="Operator"><Link href={routes.operator(o.slug)} className="link font-medium">{o.name}</Link></td>134              <td data-label="Kind" className="text-ink-2">{titleCase(o.kind)}</td>135              <td data-label="Country" className="text-ink-2">{o.country_slug ? <Link href={routes.country(o.country_slug)} className="link">{o.country_name}</Link> : o.country_name ?? '—'}</td>136              <td data-label="Active payloads" className="num tnum font-medium text-active">{fmtInt(o.active_payloads)}</td>137              <td data-label="On orbit" className="num tnum max-md:hidden!">{fmtInt(o.on_orbit_payloads)}</td>138              <td data-label="Total" className="num tnum">{fmtInt(o.total_payloads)}</td>139              <td data-label="Constellations" className="num tnum">{fmtInt(o.constellations)}</td>140              <td data-label="Launches" className="num tnum">{fmtInt(o.launches)}</td>141              <td data-label="Launched 365 d" className="num tnum">{fmtInt(o.payloads_last_365d)}</td>142              <td data-label="First launch" className="tnum text-ink-2 max-md:hidden!">{fmtDate(o.first_launch)}</td>143              <td data-label="Last launch" className="tnum text-ink-2">{fmtDate(o.last_launch)}</td>144            </tr>145          ))}146        </tbody>147      </table>148    </ScrollTable>149  );150}151