* Refactor aircraft photo and hero banner components to reset loading state on photo change - Updated Lightbox component to reset image loading state when navigating between photos. - Modified HeroBanner component to reset loading state when the photo changes. Clean up control panel search logic - Removed unnecessary hasResults variable in SearchContent component. Implement flight API client with fallback mechanism - Added flight-api-client to handle fetching flight data from multiple sources (airplanes.live, adsb.lol, OpenSky). - Introduced flight-api-parsing module to convert raw API responses into standardized FlightState objects. - Created flight-api-types for shared types between API responses. Refactor useFlights hook to utilize new flight API client - Updated useFlights hook to fetch flights using the new flight API client. - Removed credit management logic as it is no longer applicable with the new API structure. Fix useFlightMonitors to fetch flight data by hex address - Changed useFlightMonitors to use fetchFlightByHex instead of fetchFlightByIcao24. Update geo utility function for better readability - Refactored splitAtAntimeridian function to improve variable naming and clarity. Enhance OpenSky types with additional fields - Added typeCode and registration fields to FlightState type for better integration with readsb data. * fix: correct 6 files that diverged during rebase (iata code, globe mode ref, terrain attribution, cache eviction, opensky parsing) * fix: improve keyboard shortcuts help focus trapping feat: add showAirspace option to MapAttribution component fix: clear hideTimer on ScrollArea cleanup refactor: change pendingFpvRef to MutableRefObject in useFlightMonitors fix: handle sessionStorage availability in useFlightTrack refactor: increase POLL_INTERVAL_MS in useFlights for better performance fix: optimize keyboard shortcuts dialog check refactor: optimize useMergedTrails by caching selected flight position feat: extend Settings type with airspace options refactor: improve airline logo normalization functions refactor: enhance flight API client with serialized rate limiting refactor: optimize registration country lookup with pre-built maps refactor: enhance logo cache management with size limits feat: update map attribution to include airspace option fix: validate rawState in parseStateRow function refactor: improve utility functions with clamp implementation * feat: add ATC lookup functionality and GPU memory monitoring - Implemented ATC lookup functions in `atc-lookup.ts` for converting IATA to ICAO codes, finding nearby ATC feeds, and looking up ATC feeds by code. - Introduced `atc-types.ts` to define types and priorities for ATC feeds. - Added GPU memory monitoring in `gpu-memory-monitor.ts` to track WebGL resource allocations and provide memory reports. - Enhanced trail stitching logic in `trail-stitching.ts` by adding a function to clear the splined track cache and optimizing altitude checks. * feat: enhance flight data handling and improve API resilience - Implemented a maximum empty response streak guard in useFlights to prevent data loss during transient API failures. - Added immediate fetch on network reconnect in useFlights to ensure timely data retrieval. - Updated useMergedTrails to include timestamps for trail points. - Removed smoothAnimations setting from useSettings as it is no longer needed. - Enhanced useTrailHistory to preserve last-known trails during empty flight responses and added dynamic jump detection for tab resume scenarios. - Improved flight API client with a circuit breaker mechanism to handle provider failures and prevent excessive retries. - Updated flight API parsing to reject non-JSON responses from OpenSky and other providers. - Enhanced trail smoothing and stitching logic to ensure better continuity at junctions between historical and live data. * feat: migrate aircraft models to Cloudinary CDN and update mapping logic * fix: adjust UI component styles and improve trail smoothing parameters * fix: adjust base aircraft size for improved rendering * feat: update changelog with recent enhancements and modify data source attribution * fix: update model optimization details and remove Draco compression dependency * feat: update changelog with recent code review fixes and fallback provider adjustments
This commit is contained in:
340
src/components/ui/provider-panel.tsx
Normal file
340
src/components/ui/provider-panel.tsx
Normal file
@ -0,0 +1,340 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Satellite, X, ChevronUp, Circle } from "lucide-react";
|
||||
import {
|
||||
getCircuitState,
|
||||
getProviderOverride,
|
||||
type CircuitState,
|
||||
} from "@/lib/flight-api-client";
|
||||
import type { ProviderName } from "@/lib/flight-api";
|
||||
import { useDropdownDismiss } from "@/hooks/use-dropdown-dismiss";
|
||||
|
||||
// ── Provider definitions ───────────────────────────────────────────────
|
||||
|
||||
interface ProviderInfo {
|
||||
id: ProviderName;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PROVIDERS: ProviderInfo[] = [
|
||||
{ id: "adsb", label: "adsb.lol", description: "Primary — server proxy" },
|
||||
{
|
||||
id: "opensky",
|
||||
label: "OpenSky",
|
||||
description: "Fallback — limited credits",
|
||||
},
|
||||
{
|
||||
id: "airplanes",
|
||||
label: "Airplanes.live",
|
||||
description: "Direct — CORS restricted",
|
||||
},
|
||||
];
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
adsb: "adsb.lol",
|
||||
opensky: "OpenSky",
|
||||
airplanes: "Airplanes.live",
|
||||
none: "Unavailable",
|
||||
};
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
adsb: "rgb(52, 211, 153)", // emerald
|
||||
opensky: "rgb(251, 191, 36)", // amber
|
||||
airplanes: "rgb(96, 165, 250)", // blue
|
||||
none: "rgb(248, 113, 113)", // red
|
||||
};
|
||||
|
||||
function circuitBadge(
|
||||
state: CircuitState,
|
||||
cooldownMs: number,
|
||||
): { label: string; color: string } {
|
||||
switch (state) {
|
||||
case "closed":
|
||||
return { label: "OK", color: "rgb(52, 211, 153)" };
|
||||
case "open":
|
||||
return {
|
||||
label: `DOWN ${Math.ceil(cooldownMs / 1000)}s`,
|
||||
color: "rgb(248, 113, 113)",
|
||||
};
|
||||
case "half-open":
|
||||
return { label: "PROBING", color: "rgb(251, 191, 36)" };
|
||||
}
|
||||
}
|
||||
|
||||
function setProviderOverride(provider: ProviderName | "auto"): void {
|
||||
const url = new URL(window.location.href);
|
||||
if (provider === "auto") {
|
||||
url.searchParams.delete("provider");
|
||||
} else {
|
||||
url.searchParams.set("provider", provider);
|
||||
}
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
|
||||
// ── Provider Dropdown ──────────────────────────────────────────────────
|
||||
|
||||
export type ProviderDropdownProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
currentSource: string | null;
|
||||
};
|
||||
|
||||
export function ProviderDropdown({
|
||||
open,
|
||||
onClose,
|
||||
currentSource,
|
||||
}: ProviderDropdownProps) {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
useDropdownDismiss(dropdownRef, open, onClose);
|
||||
|
||||
const [override, setOverride] = useState(() => getProviderOverride());
|
||||
const isAutoMode = override === "auto";
|
||||
const isDev =
|
||||
typeof window !== "undefined" &&
|
||||
(window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1");
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(provider: ProviderName | "auto") => {
|
||||
setProviderOverride(provider);
|
||||
setOverride(provider === "auto" ? "auto" : provider);
|
||||
onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
ref={dropdownRef}
|
||||
initial={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="absolute bottom-full left-0 z-50 mb-2 w-[calc(100vw-2rem)] max-w-70 overflow-hidden rounded-xl border shadow-2xl shadow-black/60 backdrop-blur-2xl sm:w-70 sm:max-w-none"
|
||||
style={{
|
||||
borderColor: "rgb(var(--ui-fg) / 0.08)",
|
||||
backgroundColor: "rgb(var(--ui-bg) / 0.75)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-2.5"
|
||||
style={{ borderBottom: "1px solid rgb(var(--ui-fg) / 0.06)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Satellite className="h-3 w-3 text-emerald-400/70" />
|
||||
<span
|
||||
className="text-[10px] font-semibold tracking-widest uppercase"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.35)" }}
|
||||
>
|
||||
Providers
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md transition-colors hover:bg-white/5 active:bg-white/10"
|
||||
aria-label="Close provider selector"
|
||||
>
|
||||
<X
|
||||
className="h-3 w-3"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.3)" }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Provider list */}
|
||||
<div className="py-1">
|
||||
{/* Auto option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect("auto")}
|
||||
className={`group flex w-full items-center gap-2.5 px-3.5 py-2 transition-colors ${
|
||||
isAutoMode ? "bg-white/6" : "hover:bg-white/3 active:bg-white/6"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<Circle
|
||||
className="h-2.5 w-2.5"
|
||||
style={{
|
||||
color: isAutoMode
|
||||
? "rgb(52, 211, 153)"
|
||||
: "rgb(var(--ui-fg) / 0.2)",
|
||||
}}
|
||||
fill={isAutoMode ? "rgb(52, 211, 153)" : "transparent"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0 text-left">
|
||||
<span
|
||||
className="truncate text-[11px] font-medium leading-snug"
|
||||
style={{
|
||||
color: isAutoMode
|
||||
? "rgb(var(--ui-fg) / 0.85)"
|
||||
: "rgb(var(--ui-fg) / 0.55)",
|
||||
}}
|
||||
>
|
||||
Auto
|
||||
</span>
|
||||
<span
|
||||
className="text-[9px] leading-snug"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.25)" }}
|
||||
>
|
||||
Uses best available
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="shrink-0 rounded px-1.5 py-px text-[8px] font-bold tracking-wider"
|
||||
style={{
|
||||
backgroundColor: "rgb(52, 211, 153, 0.07)",
|
||||
color: "rgb(52, 211, 153)",
|
||||
}}
|
||||
>
|
||||
REC
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Individual providers */}
|
||||
{PROVIDERS.map((provider) => {
|
||||
const isSelected = override === provider.id;
|
||||
const isActive = currentSource === provider.id;
|
||||
const circuit = getCircuitState(provider.id);
|
||||
const badge = circuitBadge(
|
||||
circuit.state,
|
||||
circuit.cooldownRemaining,
|
||||
);
|
||||
const isAvailable = provider.id !== "airplanes" || isDev;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => isAvailable && handleSelect(provider.id)}
|
||||
disabled={!isAvailable}
|
||||
className={`group flex w-full items-center gap-2.5 px-3.5 py-2 transition-colors ${
|
||||
isSelected
|
||||
? "bg-white/6"
|
||||
: isAvailable
|
||||
? "hover:bg-white/3 active:bg-white/6"
|
||||
: "cursor-not-allowed opacity-40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<Circle
|
||||
className="h-2.5 w-2.5"
|
||||
style={{
|
||||
color: isActive
|
||||
? (SOURCE_COLORS[provider.id] ??
|
||||
"rgb(var(--ui-fg) / 0.2)")
|
||||
: isSelected
|
||||
? "rgb(var(--ui-fg) / 0.5)"
|
||||
: "rgb(var(--ui-fg) / 0.2)",
|
||||
}}
|
||||
fill={
|
||||
isActive
|
||||
? (SOURCE_COLORS[provider.id] ?? "transparent")
|
||||
: "transparent"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0 text-left">
|
||||
<span
|
||||
className="truncate text-[11px] font-medium leading-snug"
|
||||
style={{
|
||||
color: isActive
|
||||
? "rgb(var(--ui-fg) / 0.85)"
|
||||
: "rgb(var(--ui-fg) / 0.55)",
|
||||
}}
|
||||
>
|
||||
{provider.label}
|
||||
</span>
|
||||
<span
|
||||
className="text-[9px] leading-snug"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.25)" }}
|
||||
>
|
||||
{!isAvailable
|
||||
? "CORS restricted — dev only"
|
||||
: provider.description}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="shrink-0 rounded px-1.5 py-px text-[8px] font-bold tracking-wider"
|
||||
style={{
|
||||
backgroundColor: `${badge.color}12`,
|
||||
color: isAvailable
|
||||
? badge.color
|
||||
: "rgb(var(--ui-fg) / 0.25)",
|
||||
}}
|
||||
>
|
||||
{isAvailable ? badge.label : "CORS"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Provider Trigger (for status bar) ──────────────────────────────────
|
||||
|
||||
export type ProviderTriggerProps = {
|
||||
source: string | null;
|
||||
loading: boolean;
|
||||
rateLimited: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function ProviderTrigger({
|
||||
source,
|
||||
loading,
|
||||
rateLimited,
|
||||
onClick,
|
||||
}: ProviderTriggerProps) {
|
||||
const label = rateLimited
|
||||
? "Paused"
|
||||
: loading && !source
|
||||
? "Connecting…"
|
||||
: source
|
||||
? (SOURCE_LABELS[source] ?? source)
|
||||
: "Connecting…";
|
||||
|
||||
const dotColor = rateLimited
|
||||
? "text-amber-400/80"
|
||||
: source === "none"
|
||||
? "text-red-400/80"
|
||||
: source === "opensky"
|
||||
? "text-amber-400/80"
|
||||
: source === "airplanes"
|
||||
? "text-blue-400/80"
|
||||
: "text-emerald-400/80";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-2"
|
||||
aria-label="Select ADS-B provider"
|
||||
>
|
||||
<div className="relative">
|
||||
<Satellite className={`h-3 w-3 ${dotColor}`} />
|
||||
</div>
|
||||
<span
|
||||
className="text-[11px] font-medium tracking-wide"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.4)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<ChevronUp
|
||||
className="h-3 w-3 transition-colors"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.35)" }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user