spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
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