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%
6.3 KB · 136 lines tsx
Raw Blame History
1'use client';2/**3 * Selected-satellite panel: bottom sheet on mobile, right column on desktop. Metadata from `/satellites/{norad}`,4 * live altitude/velocity polled from `/satellites/{norad}/live` every 5 s while open.5 */6import { ArrowUpRight, X } from 'lucide-react';7import Link from 'next/link';8import { useEffect, useState } from 'react';9import { OrbitBadge, StatusBadge, TypeBadge } from '@/components/ui/badges';10import { clientApi } from '@/lib/client-api';11import { cn } from '@/lib/cn';12import { fmt2, fmtAgo, fmtKm, fmtMinutes } from '@/lib/format';13import { MISSION_LABELS, routes } from '@/lib/site';14import type { LivePosition, SatelliteRow } from '@/lib/types';1516const LIVE_MS = 5_000;1718export interface PanelSnapshot {19  altitudeKm: number | null;20  velocityKmS: number | null;21  orbitClass: string | null;22  active: boolean | null;23}2425function Row({ label, children }: { label: string; children: React.ReactNode }) {26  return (27    <div className="flex items-baseline justify-between gap-3 border-b border-rule py-1.5 text-sm last:border-0">28      <span className="text-ink-3">{label}</span>29      <span className="tnum min-w-0 truncate text-right text-ink">{children}</span>30    </div>31  );32}3334export function SatPanel({ norad, snapshot, onClose, className }: { norad: number; snapshot: PanelSnapshot; onClose: () => void; className?: string }) {35  const [sat, setSat] = useState<SatelliteRow | null>(null);36  const [live, setLive] = useState<LivePosition | null>(null);37  const [error, setError] = useState<string | null>(null);3839  useEffect(() => {40    setSat(null);41    setLive(null);42    setError(null);43    const ctrl = new AbortController();44    clientApi45      .satellite(String(norad), ctrl.signal)46      .then((r) => setSat(r.data))47      .catch((e: Error) => {48        if (e.name !== 'AbortError') setError('Metadata unavailable');49      });50    let timer: ReturnType<typeof setTimeout> | null = null;51    const poll = async () => {52      try {53        const r = await clientApi.live(String(norad), ctrl.signal);54        setLive(r.data);55      } catch {56        /* keep last */57      } finally {58        if (!ctrl.signal.aborted) timer = setTimeout(poll, LIVE_MS);59      }60    };61    void poll();62    return () => {63      ctrl.abort();64      if (timer) clearTimeout(timer);65    };66  }, [norad]);6768  const alt = live?.altitude_km ?? snapshot.altitudeKm;69  const vel = live?.velocity_km_s ?? snapshot.velocityKmS;70  const name = sat?.name ?? (error ? `NORAD ${norad}` : null);7172  return (73    <aside className={cn('panel flex max-h-[60%] flex-col overflow-hidden md:max-h-none', className)} aria-label="Selected satellite" aria-live="polite">74      <div className="flex items-start gap-3 border-b border-rule px-4 py-3">75        <div className="min-w-0 flex-1">76          <p className="eyebrow">Selected object</p>77          {name ? (78            <h3 className="mt-0.5 truncate text-base font-semibold leading-tight text-ink">{name}</h3>79          ) : (80            <div className="mt-1 h-5 w-40 animate-pulse rounded bg-plane-2" aria-hidden />81          )}82          <p className="mono mt-1 flex flex-wrap items-center gap-x-2 text-2xs text-ink-3">83            <span>NORAD {norad}</span>84            {sat?.cospar_id && <span>{sat.cospar_id}</span>}85          </p>86        </div>87        <button type="button" onClick={onClose} className="-mr-1 -mt-1 flex size-11 shrink-0 items-center justify-center rounded-md text-ink-3 hover:bg-plane-2 hover:text-ink" aria-label="Close panel">88          <X className="size-5" aria-hidden />89        </button>90      </div>91      <div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto px-4 py-3">92        <div className="flex flex-wrap items-center gap-1.5">93          <StatusBadge status={sat?.status ?? (snapshot.active === null ? undefined : snapshot.active ? 'ACTIVE' : 'INACTIVE')} />94          <OrbitBadge orbitClass={sat?.orbit_class ?? snapshot.orbitClass} />95          {sat && <TypeBadge type={sat.object_type} />}96        </div>97        <div className="mt-3 grid grid-cols-2 gap-3">98          <div>99            <p className="eyebrow">Altitude</p>100            <p className="tnum mt-0.5 text-xl font-semibold text-ink">{alt === null ? '—' : fmtKm(alt)}</p>101          </div>102          <div>103            <p className="eyebrow">Velocity</p>104            <p className="tnum mt-0.5 text-xl font-semibold text-ink">{vel === null ? '—' : `${fmt2(vel)} km/s`}</p>105          </div>106        </div>107        <p className="mono mt-1 text-2xs text-ink-3">{live ? `live · lat ${fmt2(live.lat)}° lon ${fmt2(live.lon)}° · epoch ${fmtAgo(live.source_epoch)}` : 'from snapshot · live position loading…'}</p>108        <div className="mt-3">109          {sat ? (110            <>111              <Row label="Operator">{sat.operator_slug ? <Link href={routes.operator(sat.operator_slug)} className="link">{sat.operator_name}</Link> : sat.operator_name ?? sat.owner_name ?? '—'}</Row>112              <Row label="Constellation">{sat.constellation_slug ? <Link href={routes.constellation(sat.constellation_slug)} className="link">{sat.constellation_name}</Link> : '—'}</Row>113              <Row label="Country">{sat.country_slug ? <Link href={routes.country(sat.country_slug)} className="link">{sat.country_name}</Link> : sat.country_name ?? '—'}</Row>114              <Row label="Mission (derived)">{MISSION_LABELS[sat.mission_type ?? 'unknown'] ?? sat.mission_type ?? '—'}</Row>115              <Row label="Perigee / apogee">{fmtKm(sat.perigee_km)} / {fmtKm(sat.apogee_km)}</Row>116              <Row label="Period">{fmtMinutes(sat.period_minutes)}</Row>117              <Row label="Launched">{sat.launch_date ?? '—'}</Row>118            </>119          ) : error ? (120            <p className="text-sm text-ink-3">{error}</p>121          ) : (122            <div className="space-y-2" aria-hidden>123              {[0, 1, 2, 3].map((i) => <div key={i} className="h-4 animate-pulse rounded bg-plane-2" />)}124            </div>125          )}126        </div>127      </div>128      <div className="border-t border-rule px-4 py-3">129        <Link href={sat ? routes.satellite(sat.slug) : routes.satellite(String(norad))} className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-md bg-accent text-sm font-medium text-accent-ink hover:brightness-110">130          Open satellite page <ArrowUpRight className="size-4" aria-hidden />131        </Link>132      </div>133    </aside>134  );135}136