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%
5.6 KB · 100 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { Suspense } from 'react';4import { ActiveFilters, FacetChips, FilterForm, href, parseQuery, titleFor, type SatQuery } from '@/components/satellite/explorer-filters';5import { Derived } from '@/components/satellite/primitives';6import { ResultsTable } from '@/components/satellite/results-table';7import { SearchBox } from '@/components/satellite/search-box';8import { Pagination } from '@/components/ui/pagination';9import { Container, PageHeader } from '@/components/ui/section';10import { Unavailable } from '@/components/ui/unavailable';11import { api, safe } from '@/lib/api';12import { fmtInt } from '@/lib/format';13import { routes, SITE_URL } from '@/lib/site';1415const PAGE_SIZE = 50;16const FACET_KEYS = ['status', 'object_type', 'orbit_class', 'mission_type', 'country', 'operator', 'constellation', 'on_orbit', 'q'] as const;1718type Props = { searchParams: Promise<Record<string, string | string[] | undefined>> };1920function apiQuery(q: SatQuery) {21  const page = Math.max(1, Number(q.page ?? '1') || 1);22  return { ...q, page, page_size: PAGE_SIZE, sort: q.sort ?? 'launch_date' };23}24function facetQuery(q: SatQuery) {25  return Object.fromEntries(FACET_KEYS.map((k) => [k, q[k]]));26}2728export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {29  const q = parseQuery(await searchParams);30  const filtered = Object.keys(q).filter((k) => k !== 'page' && k !== 'sort').length > 0;31  // Same request as the page body → deduplicated by the fetch cache, so labels (e.g. "Starlink") are real, not slugs.32  const facets = filtered ? ((await safe(api.satelliteFacets(facetQuery(q))))?.data ?? null) : null;33  const title = filtered ? `${titleFor(q, facets)} — Satellite explorer` : 'Satellite explorer — every catalogued object in Earth orbit';34  const description = 'Filter 70,000+ catalogued objects by status, object type, orbit class, mission, country, constellation and launch date. Real counts, live element sets, honest gaps.';35  const canonical = href(q, {}, false);36  return {37    title,38    description,39    alternates: { canonical },40    robots: q.page || q.q ? { index: false, follow: true } : undefined,41    openGraph: { title, description, url: `${SITE_URL}${canonical}` },42    twitter: { card: 'summary_large_image', title, description },43  };44}4546export default async function SatellitesPage({ searchParams }: Props) {47  const q = parseQuery(await searchParams);48  const [list, facetsRes] = await Promise.all([safe(api.satellites(apiQuery(q))), safe(api.satelliteFacets(facetQuery(q)))]);49  const facets = facetsRes?.data ?? null;50  const total = list?.pagination.total ?? null;51  const filtered = Object.keys(q).filter((k) => k !== 'page' && k !== 'sort').length > 0;5253  return (54    <Container wide>55      <PageHeader56        eyebrow={<>Catalogue · {total !== null ? <span className="tnum">{fmtInt(total)} objects</span> : 'count unavailable'}</>}57        title={filtered ? titleFor(q, facets) : 'Satellites & catalogued objects'}58        lede={filtered ? undefined : 'Every object in the public catalogue — payloads, rocket bodies, debris and stations — with live element sets where they exist. Filters are plain URL parameters: share, bookmark, script.'}59      >60        <div className="mt-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">61          <Suspense fallback={<div className="h-11 w-full max-w-md rounded-md border border-rule bg-plane-2" />}>62            <SearchBox initial={q.q ?? ''} />63          </Suspense>64          <p className="text-xs text-ink-3">65            Orbit class, mission and constellation are <Derived /> · <Link href={routes.methodology()} className="hover:text-accent">methodology</Link>66          </p>67        </div>68      </PageHeader>6970      <div className="space-y-5 pb-4">71        <FilterForm q={q} />72        <ActiveFilters q={q} />73        {facets ? <FacetChips facets={facets} q={q} /> : <Unavailable what="Facet counts" compact />}74      </div>7576      <section className="py-6" aria-label="Results">77        {list === null ? (78          <Unavailable what="Catalogue results" />79        ) : list.data.length === 0 ? (80          <div className="rounded-lg border border-dashed border-rule-strong px-5 py-10 text-center">81            <p className="text-sm text-ink-2">No catalogued object matches these filters.</p>82            <p className="mt-1 text-xs text-ink-3">The count is real — nothing is hidden. Try removing a filter{q.launched_after || q.launched_before ? ' (launch-date filters depend on SATCAT launch dates, which are missing for some objects)' : ''}.</p>83            <Link href={routes.satellites()} className="mt-4 inline-flex h-10 items-center rounded-md border border-rule px-4 text-sm text-ink hover:bg-plane-2">Clear all filters</Link>84          </div>85        ) : (86          <>87            <Pagination page={list.pagination.page} pages={list.pagination.pages} total={list.pagination.total} pageSize={list.pagination.page_size} makeHref={(p) => href(q, { page: String(p) }, false)} className="mb-3" />88            <ResultsTable rows={list.data} />89            <Pagination page={list.pagination.page} pages={list.pagination.pages} total={list.pagination.total} pageSize={list.pagination.page_size} makeHref={(p) => href(q, { page: String(p) }, false)} className="mt-4" />90          </>91        )}92      </section>9394      <p className="pb-10 text-2xs text-ink-3">95        Catalogue rows come from CelesTrak SATCAT; perigee, apogee and inclination shown here are catalogue values (the detail page shows the latest element set). Facet counts are computed live over the filtered set.96      </p>97    </Container>98  );99}100