mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-09 21:58:41 +02:00
release: prepare v0.9.7
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* useAgentActions — polls for display actions pushed by the OpenClaw agent.
|
||||
*
|
||||
* When the agent sends a `show_satellite` or `show_sentinel` command,
|
||||
* the backend queues a display action. This hook picks it up and
|
||||
* triggers the same full-screen image viewer as a right-click dossier.
|
||||
*
|
||||
* Actions are consumed on read (destructive poll) so they don't pile up.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
|
||||
interface AgentAction {
|
||||
action: string;
|
||||
source?: string;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
sentinel2?: Record<string, unknown>;
|
||||
preset?: string;
|
||||
caption?: string | null;
|
||||
ts?: number;
|
||||
// fly_to extras
|
||||
zoom?: number;
|
||||
aoi_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param onShowImage — called when the agent wants to display satellite imagery.
|
||||
* Receives {lat, lng} — the caller should trigger handleMapRightClick or
|
||||
* equivalent to open the RegionDossierPanel.
|
||||
* @param onFlyTo — called when the agent wants to center the map on a point
|
||||
* without opening imagery (e.g. sar_focus_aoi).
|
||||
*/
|
||||
export function useAgentActions(
|
||||
onShowImage: (coords: { lat: number; lng: number }) => void,
|
||||
onFlyTo?: (coords: { lat: number; lng: number; zoom?: number }) => void,
|
||||
) {
|
||||
const onShowImageRef = useRef(onShowImage);
|
||||
onShowImageRef.current = onShowImage;
|
||||
const onFlyToRef = useRef(onFlyTo);
|
||||
onFlyToRef.current = onFlyTo;
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/ai/agent-actions`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const actions: AgentAction[] = data.actions || [];
|
||||
|
||||
for (const action of actions) {
|
||||
if (action.action === 'show_image' && action.lat != null && action.lng != null) {
|
||||
onShowImageRef.current({ lat: action.lat, lng: action.lng });
|
||||
} else if (
|
||||
action.action === 'fly_to' &&
|
||||
action.lat != null &&
|
||||
action.lng != null
|
||||
) {
|
||||
onFlyToRef.current?.({
|
||||
lat: action.lat,
|
||||
lng: action.lng,
|
||||
zoom: action.zoom,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silent fail — agent actions are best-effort
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Poll every 3 seconds — lightweight endpoint, ~50 bytes when empty
|
||||
const interval = setInterval(poll, 3000);
|
||||
// Initial poll on mount
|
||||
poll();
|
||||
return () => clearInterval(interval);
|
||||
}, [poll]);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* useAlertToasts — watches for new high-severity news items and surfaces toast notifications.
|
||||
*
|
||||
* Monitors the `news` data key for articles with risk_score >= 8.
|
||||
* Maintains a seen-set to avoid duplicate toasts. Auto-dismisses after 5 seconds.
|
||||
*/
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useDataKey } from './useDataStore';
|
||||
import type { NewsArticle } from '@/types/dashboard';
|
||||
|
||||
export interface ToastItem {
|
||||
id: string;
|
||||
title: string;
|
||||
source: string;
|
||||
risk_score: number;
|
||||
lat: number;
|
||||
lng: number;
|
||||
timestamp: number; // when the toast was created
|
||||
}
|
||||
|
||||
const TOAST_THRESHOLD = 8; // minimum risk_score to trigger a toast
|
||||
const MAX_VISIBLE = 3;
|
||||
const AUTO_DISMISS_MS = 5_000;
|
||||
|
||||
export function useAlertToasts() {
|
||||
const news = useDataKey('news') as NewsArticle[] | undefined;
|
||||
const seenKeys = useRef(new Set<string>());
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
// Auto-dismiss scheduled toasts
|
||||
const scheduleDismiss = useCallback((id: string) => {
|
||||
const timer = setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
timersRef.current.delete(id);
|
||||
}, AUTO_DISMISS_MS);
|
||||
timersRef.current.set(id, timer);
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
const timer = timersRef.current.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timersRef.current.delete(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Watch for new high-severity articles
|
||||
useEffect(() => {
|
||||
if (!news || !Array.isArray(news)) return;
|
||||
|
||||
const newToasts: ToastItem[] = [];
|
||||
|
||||
for (const article of news) {
|
||||
if ((article.risk_score || 0) < TOAST_THRESHOLD) continue;
|
||||
|
||||
const key = `${article.title}|${article.source}`;
|
||||
if (seenKeys.current.has(key)) continue;
|
||||
seenKeys.current.add(key);
|
||||
|
||||
newToasts.push({
|
||||
id: key,
|
||||
title: article.title,
|
||||
source: article.source,
|
||||
risk_score: article.risk_score,
|
||||
lat: article.lat || article.coords?.[0] || 0,
|
||||
lng: article.lng || article.coords?.[1] || 0,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
if (newToasts.length > 0) {
|
||||
setToasts((prev) => {
|
||||
// Merge new toasts, keep only MAX_VISIBLE most recent
|
||||
const merged = [...newToasts, ...prev].slice(0, MAX_VISIBLE);
|
||||
return merged;
|
||||
});
|
||||
|
||||
// Schedule auto-dismiss for each new toast
|
||||
for (const t of newToasts) {
|
||||
scheduleDismiss(t.id);
|
||||
}
|
||||
}
|
||||
}, [news, scheduleDismiss]);
|
||||
|
||||
// Cleanup timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const timer of timersRef.current.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { toasts, dismiss };
|
||||
}
|
||||
@@ -3,6 +3,55 @@ import { API_BASE } from "@/lib/api";
|
||||
import { mergeData, setBackendStatus as setStoreBackendStatus } from "./useDataStore";
|
||||
|
||||
export type BackendStatus = 'connecting' | 'connected' | 'disconnected';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Polling pause/resume — used by Time Machine snapshot playback
|
||||
// ---------------------------------------------------------------------------
|
||||
let _pollingPaused = false;
|
||||
let _fastEtagRef: { current: string | null } | null = null;
|
||||
let _slowEtagRef: { current: string | null } | null = null;
|
||||
|
||||
/** Pause live data polling (snapshot mode). */
|
||||
export function pausePolling() {
|
||||
_pollingPaused = true;
|
||||
}
|
||||
|
||||
/** Resume live data polling and invalidate ETags for a full refresh. */
|
||||
export function resumePolling() {
|
||||
_pollingPaused = false;
|
||||
// Invalidate ETags so the next poll gets fresh data (not 304)
|
||||
if (_fastEtagRef) _fastEtagRef.current = null;
|
||||
if (_slowEtagRef) _slowEtagRef.current = null;
|
||||
}
|
||||
|
||||
/** Resume live mode and fetch both live tiers immediately instead of waiting for the next poll tick. */
|
||||
export async function forceRefreshLiveData(): Promise<void> {
|
||||
_pollingPaused = false;
|
||||
if (_fastEtagRef) _fastEtagRef.current = null;
|
||||
if (_slowEtagRef) _slowEtagRef.current = null;
|
||||
|
||||
try {
|
||||
const [fastRes, slowRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/live-data/fast`),
|
||||
fetch(`${API_BASE}/api/live-data/slow`),
|
||||
]);
|
||||
|
||||
if (fastRes.ok) {
|
||||
if (_fastEtagRef) _fastEtagRef.current = fastRes.headers.get('etag') || null;
|
||||
mergeData(await fastRes.json());
|
||||
}
|
||||
if (slowRes.ok) {
|
||||
if (_slowEtagRef) _slowEtagRef.current = slowRes.headers.get('etag') || null;
|
||||
mergeData(await slowRes.json());
|
||||
}
|
||||
if (fastRes.ok || slowRes.ok) {
|
||||
setStoreBackendStatus('connected');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed forcing live data refresh", e);
|
||||
setStoreBackendStatus('disconnected');
|
||||
}
|
||||
}
|
||||
type FastDataProbe = {
|
||||
commercial_flights?: unknown[];
|
||||
military_flights?: unknown[];
|
||||
@@ -46,6 +95,10 @@ export function useDataPolling() {
|
||||
const slowEtag = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Expose refs so pausePolling/resumePolling can invalidate ETags
|
||||
_fastEtagRef = fastEtag;
|
||||
_slowEtagRef = slowEtag;
|
||||
|
||||
let hasData = false;
|
||||
let fastTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
let slowTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -57,6 +110,8 @@ export function useDataPolling() {
|
||||
clearTimeout(fastTimerId);
|
||||
fastTimerId = null;
|
||||
}
|
||||
// Skip fetch when Time Machine snapshot mode is active
|
||||
if (_pollingPaused) { scheduleNext('fast'); return; }
|
||||
if (fastAbortRef.current) return;
|
||||
const controller = new AbortController();
|
||||
fastAbortRef.current = controller;
|
||||
@@ -98,6 +153,7 @@ export function useDataPolling() {
|
||||
};
|
||||
|
||||
const fetchSlowData = async () => {
|
||||
if (_pollingPaused) { scheduleNext('slow'); return; }
|
||||
if (slowAbortRef.current) return;
|
||||
const controller = new AbortController();
|
||||
slowAbortRef.current = controller;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* useFeedHealth — derives live feed health from the data store.
|
||||
*
|
||||
* Tracks how many entities are in each data category and how fresh the data is.
|
||||
* Returns compact stats for the bottom status bar.
|
||||
*/
|
||||
import { useRef, useMemo } from 'react';
|
||||
import { useDataKeys } from './useDataStore';
|
||||
import type { DashboardData, NewsArticle } from '@/types/dashboard';
|
||||
|
||||
type FeedStatus = 'healthy' | 'stale' | 'offline';
|
||||
|
||||
interface FeedInfo {
|
||||
label: string;
|
||||
count: string; // formatted count e.g. "12.4K"
|
||||
status: FeedStatus;
|
||||
}
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n >= 10000) return `${(n / 1000).toFixed(1)}K`;
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function arrayLen(v: unknown): number {
|
||||
return Array.isArray(v) ? v.length : 0;
|
||||
}
|
||||
|
||||
export function useFeedHealth(): FeedInfo[] {
|
||||
const data = useDataKeys([
|
||||
'commercial_flights',
|
||||
'private_flights',
|
||||
'military_flights',
|
||||
'private_jets',
|
||||
'tracked_flights',
|
||||
'ships',
|
||||
'news',
|
||||
'satellites',
|
||||
] as const satisfies readonly (keyof DashboardData)[]);
|
||||
|
||||
// Track last-seen timestamps per feed
|
||||
const timestamps = useRef<Record<string, number>>({});
|
||||
const now = Date.now();
|
||||
|
||||
// Update timestamps when data changes
|
||||
const feeds = useMemo(() => {
|
||||
const adsb =
|
||||
arrayLen(data.commercial_flights) +
|
||||
arrayLen(data.private_flights) +
|
||||
arrayLen(data.military_flights) +
|
||||
arrayLen(data.private_jets) +
|
||||
arrayLen(data.tracked_flights);
|
||||
|
||||
const ais = arrayLen(data.ships);
|
||||
|
||||
// Count unique news sources
|
||||
const newsArr = Array.isArray(data.news) ? data.news : [];
|
||||
const newsSources = new Set(newsArr.map((n: NewsArticle) => n.source).filter(Boolean));
|
||||
|
||||
const sats = arrayLen(data.satellites);
|
||||
|
||||
// Update timestamps
|
||||
if (adsb > 0) timestamps.current.adsb = now;
|
||||
if (ais > 0) timestamps.current.ais = now;
|
||||
if (newsArr.length > 0) timestamps.current.news = now;
|
||||
if (sats > 0) timestamps.current.sats = now;
|
||||
|
||||
function getStatus(key: string, count: number): FeedStatus {
|
||||
if (count === 0) return 'offline';
|
||||
const lastSeen = timestamps.current[key] || 0;
|
||||
const age = now - lastSeen;
|
||||
if (age > 120_000) return 'offline';
|
||||
if (age > 30_000) return 'stale';
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: 'ADS-B', count: formatCount(adsb), status: getStatus('adsb', adsb) },
|
||||
{ label: 'AIS', count: formatCount(ais), status: getStatus('ais', ais) },
|
||||
{ label: 'NEWS', count: String(newsSources.size), status: getStatus('news', newsArr.length) },
|
||||
{ label: 'SAT', count: formatCount(sats), status: getStatus('sats', sats) },
|
||||
];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data]);
|
||||
|
||||
return feeds;
|
||||
}
|
||||
@@ -1,33 +1,8 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* Subscribe to the backend SSE gate-event stream.
|
||||
* Delivers ALL gate events (encrypted blobs) — the client filters by gate_id locally.
|
||||
* The server never learns which gates a client cares about (privacy-preserving broadcast).
|
||||
*
|
||||
* Falls back gracefully: if the stream fails the browser's EventSource auto-reconnects.
|
||||
* DEPRECATED — Gate SSE stream removed in S3A.
|
||||
* The frontend now relies on the authenticated poll loop for gate refresh.
|
||||
* This stub is kept so stale imports compile without error.
|
||||
*/
|
||||
export function useGateSSE(onEvent: (gateId: string) => void) {
|
||||
const callbackRef = useRef(onEvent);
|
||||
callbackRef.current = onEvent;
|
||||
|
||||
useEffect(() => {
|
||||
const es = new EventSource(`${API_BASE}/api/mesh/gate/stream`);
|
||||
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.gate_id && typeof data.gate_id === 'string') {
|
||||
callbackRef.current(data.gate_id);
|
||||
}
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
}
|
||||
};
|
||||
|
||||
// Browser auto-reconnects EventSource on error — no manual retry needed.
|
||||
|
||||
return () => es.close();
|
||||
}, []);
|
||||
export function useGateSSE(_onEvent: (gateId: string) => void) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* useKeyboardShortcuts — global keyboard shortcut handler for ShadowBroker.
|
||||
*
|
||||
* Registers document-level keydown listener with guards for inputs/textareas.
|
||||
* Returns nothing — side-effect only hook.
|
||||
*/
|
||||
import { useEffect, useCallback } from 'react';
|
||||
|
||||
interface ShortcutActions {
|
||||
toggleLeft: () => void;
|
||||
toggleRight: () => void;
|
||||
toggleMarkets: () => void;
|
||||
openSettings: () => void;
|
||||
openLegend: () => void;
|
||||
openShortcuts: () => void;
|
||||
deselectEntity: () => void;
|
||||
focusSearch: () => void;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts(actions: ShortcutActions) {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
// Don't fire shortcuts when typing in inputs
|
||||
const tag = (e.target as HTMLElement)?.tagName?.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||||
|
||||
// Don't fire when contentEditable is active
|
||||
if ((e.target as HTMLElement)?.isContentEditable) return;
|
||||
|
||||
// Don't fire on modifier key combos (Ctrl+S, etc.)
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'l':
|
||||
e.preventDefault();
|
||||
actions.toggleLeft();
|
||||
break;
|
||||
|
||||
case 'r':
|
||||
e.preventDefault();
|
||||
actions.toggleRight();
|
||||
break;
|
||||
|
||||
case 'm':
|
||||
e.preventDefault();
|
||||
actions.toggleMarkets();
|
||||
break;
|
||||
|
||||
case 's':
|
||||
e.preventDefault();
|
||||
actions.openSettings();
|
||||
break;
|
||||
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
actions.openLegend();
|
||||
break;
|
||||
|
||||
case ' ': // Space bar
|
||||
e.preventDefault();
|
||||
actions.openShortcuts();
|
||||
break;
|
||||
|
||||
case 'escape':
|
||||
e.preventDefault();
|
||||
actions.deselectEntity();
|
||||
break;
|
||||
|
||||
case 'f':
|
||||
e.preventDefault();
|
||||
actions.focusSearch();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[actions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
}
|
||||
@@ -192,21 +192,23 @@ async function fetchSentinel2Direct(lat: number, lng: number) {
|
||||
const features = data.features || [];
|
||||
if (!features.length) return null; // No scenes — caller uses Esri fallback
|
||||
|
||||
const item = features[0];
|
||||
const assets = item.assets || {};
|
||||
const rendered = assets.rendered_preview || {};
|
||||
const thumbnail = assets.thumbnail || {};
|
||||
const scenes = features.map((item: any) => {
|
||||
const assets = item.assets || {};
|
||||
const rendered = assets.rendered_preview || {};
|
||||
const thumbnail = assets.thumbnail || {};
|
||||
return {
|
||||
found: true,
|
||||
scene_id: item.id,
|
||||
datetime: item.properties?.datetime,
|
||||
cloud_cover: item.properties?.['eo:cloud_cover'],
|
||||
thumbnail_url: thumbnail.href || rendered.href,
|
||||
fullres_url: rendered.href || thumbnail.href,
|
||||
bbox: item.bbox ? [...item.bbox] : null,
|
||||
platform: item.properties?.platform || 'Sentinel-2',
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
found: true,
|
||||
scene_id: item.id,
|
||||
datetime: item.properties?.datetime,
|
||||
cloud_cover: item.properties?.['eo:cloud_cover'],
|
||||
thumbnail_url: thumbnail.href || rendered.href,
|
||||
fullres_url: rendered.href || thumbnail.href,
|
||||
bbox: item.bbox ? [...item.bbox] : null,
|
||||
platform: item.properties?.platform || 'Sentinel-2',
|
||||
};
|
||||
return { ...scenes[0], scenes };
|
||||
}
|
||||
|
||||
// ─── MAIN HOOK ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* useSignAndAppend — shared submission state for any view that needs
|
||||
* to sign + post an Infonet economy event.
|
||||
*
|
||||
* Wraps ``signAndAppend`` from ``@/mesh/infonetEconomyClient`` with
|
||||
* React loading / result state. Each view tracks its own per-action
|
||||
* status independently — the hook returns ``submit(event_type,
|
||||
* payload)`` plus the latest ``result`` and ``state`` flags.
|
||||
*
|
||||
* Cross-cutting non-hostile UX rule: ``result.reason`` on failure
|
||||
* carries the verbatim diagnostic from the backend so the view
|
||||
* surfaces it directly. Never display "denied" with no detail.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
signAndAppend,
|
||||
type AppendResult,
|
||||
} from '@/mesh/infonetEconomyClient';
|
||||
|
||||
export type SubmitState = 'idle' | 'submitting' | 'success' | 'error';
|
||||
|
||||
export interface UseSignAndAppendReturn {
|
||||
state: SubmitState;
|
||||
result: AppendResult | null;
|
||||
submit: (
|
||||
event_type: string,
|
||||
payload: Record<string, unknown>,
|
||||
) => Promise<AppendResult>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useSignAndAppend(): UseSignAndAppendReturn {
|
||||
const [state, setState] = useState<SubmitState>('idle');
|
||||
const [result, setResult] = useState<AppendResult | null>(null);
|
||||
|
||||
const submit = useCallback(
|
||||
async (event_type: string, payload: Record<string, unknown>) => {
|
||||
setState('submitting');
|
||||
let res: AppendResult;
|
||||
try {
|
||||
res = await signAndAppend({ event_type, payload });
|
||||
} catch (err) {
|
||||
res = {
|
||||
ok: false,
|
||||
reason: err instanceof Error ? err.message : 'unknown_error',
|
||||
};
|
||||
}
|
||||
setResult(res);
|
||||
setState(res.ok ? 'success' : 'error');
|
||||
return res;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState('idle');
|
||||
setResult(null);
|
||||
}, []);
|
||||
|
||||
return { state, result, submit, reset };
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* useTimeMachine - snapshot playback state for the map.
|
||||
*
|
||||
* The UI uses this as a media-style transport: a straight timeline, explicit
|
||||
* snapshot mode, immediate live restore, and interpolated frames between
|
||||
* recorded snapshots for moving entities.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { mergeData } from './useDataStore';
|
||||
import { forceRefreshLiveData, pausePolling, resumePolling } from './useDataPolling';
|
||||
|
||||
export interface HourlyIndexEntry {
|
||||
count: number;
|
||||
latest_id: string;
|
||||
latest_ts: string;
|
||||
snapshot_ids: string[];
|
||||
}
|
||||
|
||||
export interface SnapshotMeta {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
unix_ts: number;
|
||||
format?: string;
|
||||
layers: string[];
|
||||
layer_counts: Record<string, number>;
|
||||
profile?: string | null;
|
||||
}
|
||||
|
||||
interface PlaybackSnapshot extends SnapshotMeta {
|
||||
snapshot_id: string;
|
||||
data: SnapshotData;
|
||||
}
|
||||
|
||||
type SnapshotData = Record<string, unknown>;
|
||||
type Entity = Record<string, unknown>;
|
||||
type Listener = () => void;
|
||||
|
||||
export interface TimeMachineState {
|
||||
mode: 'live' | 'snapshot';
|
||||
snapshotId: string | null;
|
||||
snapshotTimestamp: string | null;
|
||||
currentUnixTs: number | null;
|
||||
timelineStart: number | null;
|
||||
timelineEnd: number | null;
|
||||
snapshots: SnapshotMeta[];
|
||||
playing: boolean;
|
||||
playbackSpeed: number;
|
||||
hourlyIndex: Record<number, HourlyIndexEntry>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const MOVING_LAYER_KEYS = new Set([
|
||||
'commercial_flights',
|
||||
'private_flights',
|
||||
'private_jets',
|
||||
'military_flights',
|
||||
'tracked_flights',
|
||||
'uavs',
|
||||
'ships',
|
||||
'satellites',
|
||||
'tinygs_satellites',
|
||||
'sigint',
|
||||
]);
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
const playbackCache = new Map<string, PlaybackSnapshot>();
|
||||
const playbackFetches = new Map<string, Promise<PlaybackSnapshot | null>>();
|
||||
|
||||
let state: TimeMachineState = {
|
||||
mode: 'live',
|
||||
snapshotId: null,
|
||||
snapshotTimestamp: null,
|
||||
currentUnixTs: null,
|
||||
timelineStart: null,
|
||||
timelineEnd: null,
|
||||
snapshots: [],
|
||||
playing: false,
|
||||
playbackSpeed: 6,
|
||||
hourlyIndex: {},
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
let _playbackTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let _playbackLastTick = 0;
|
||||
let _seekSerial = 0;
|
||||
let _playbackSeeking = false;
|
||||
|
||||
function setState(patch: Partial<TimeMachineState>) {
|
||||
state = { ...state, ...patch };
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return state;
|
||||
}
|
||||
|
||||
function subscribe(onStoreChange: Listener) {
|
||||
listeners.add(onStoreChange);
|
||||
return () => {
|
||||
listeners.delete(onStoreChange);
|
||||
};
|
||||
}
|
||||
|
||||
function numericTs(meta: { unix_ts?: number | null; timestamp?: string | null }): number {
|
||||
if (typeof meta.unix_ts === 'number' && Number.isFinite(meta.unix_ts)) return meta.unix_ts;
|
||||
if (meta.timestamp) {
|
||||
const ms = Date.parse(meta.timestamp);
|
||||
if (Number.isFinite(ms)) return ms / 1000;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function sortSnapshots(snapshots: SnapshotMeta[]): SnapshotMeta[] {
|
||||
return [...snapshots]
|
||||
.map((snap) => ({ ...snap, unix_ts: numericTs(snap) }))
|
||||
.filter((snap) => snap.id && snap.unix_ts > 0)
|
||||
.sort((a, b) => a.unix_ts - b.unix_ts);
|
||||
}
|
||||
|
||||
function updateTimelineFromSnapshots(snapshots: SnapshotMeta[]) {
|
||||
setState({
|
||||
snapshots,
|
||||
timelineStart: snapshots[0]?.unix_ts ?? null,
|
||||
timelineEnd: snapshots[snapshots.length - 1]?.unix_ts ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function snapshotIndex(snapshotId: string): number {
|
||||
return state.snapshots.findIndex((snap) => snap.id === snapshotId);
|
||||
}
|
||||
|
||||
function prefetchPlaybackSnapshots(snapshotIds: Array<string | null | undefined>) {
|
||||
for (const snapshotId of snapshotIds) {
|
||||
if (!snapshotId || playbackCache.has(snapshotId) || playbackFetches.has(snapshotId)) continue;
|
||||
void fetchPlaybackSnapshot(snapshotId);
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotPatch(data: SnapshotData): SnapshotData {
|
||||
return { ...data };
|
||||
}
|
||||
|
||||
function stopPlaybackWithError(message: string) {
|
||||
setState({ loading: false, error: message, playing: false });
|
||||
_stopPlaybackTimer();
|
||||
}
|
||||
|
||||
function asEntityArray(value: unknown): Entity[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
return value.filter((item): item is Entity => typeof item === 'object' && item !== null);
|
||||
}
|
||||
|
||||
function stringField(entity: Entity, key: string): string {
|
||||
const value = entity[key];
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||
return '';
|
||||
}
|
||||
|
||||
function numberField(entity: Entity, key: string): number | null {
|
||||
const value = entity[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function entityKey(layer: string, entity: Entity): string {
|
||||
const candidates =
|
||||
layer === 'ships'
|
||||
? ['mmsi', 'imo', 'callsign', 'name']
|
||||
: layer.includes('satellite')
|
||||
? ['norad_id', 'norad', 'id', 'name']
|
||||
: layer === 'sigint'
|
||||
? ['id', 'callsign', 'long_name']
|
||||
: ['icao24', 'registration', 'callsign', 'id', 'name'];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const value = stringField(entity, candidate).toLowerCase();
|
||||
if (value) return `${layer}:${candidate}:${value}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function interpolateScalar(a: unknown, b: unknown, ratio: number): unknown {
|
||||
if (typeof a !== 'number' || typeof b !== 'number') return a;
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) return a;
|
||||
return a + (b - a) * ratio;
|
||||
}
|
||||
|
||||
function interpolateAngle(a: unknown, b: unknown, ratio: number): unknown {
|
||||
if (typeof a !== 'number' || typeof b !== 'number') return a;
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) return a;
|
||||
const delta = ((((b - a) % 360) + 540) % 360) - 180;
|
||||
return (a + delta * ratio + 360) % 360;
|
||||
}
|
||||
|
||||
function interpolateEntity(layer: string, prev: Entity, next: Entity, ratio: number): Entity | null {
|
||||
const prevLat = numberField(prev, 'lat');
|
||||
const prevLng = numberField(prev, 'lng');
|
||||
const nextLat = numberField(next, 'lat');
|
||||
const nextLng = numberField(next, 'lng');
|
||||
if (prevLat == null || prevLng == null || nextLat == null || nextLng == null) return null;
|
||||
|
||||
const frame: Entity = { ...prev };
|
||||
frame.lat = prevLat + (nextLat - prevLat) * ratio;
|
||||
frame.lng = prevLng + (nextLng - prevLng) * ratio;
|
||||
frame.alt = interpolateScalar(prev.alt, next.alt, ratio);
|
||||
frame.altitude = interpolateScalar(prev.altitude, next.altitude, ratio);
|
||||
frame.heading = interpolateAngle(prev.heading, next.heading, ratio);
|
||||
frame.true_track = interpolateAngle(prev.true_track, next.true_track, ratio);
|
||||
frame.cog = interpolateAngle(prev.cog, next.cog, ratio);
|
||||
frame.sog = interpolateScalar(prev.sog, next.sog, ratio);
|
||||
frame.speed_knots = interpolateScalar(prev.speed_knots, next.speed_knots, ratio);
|
||||
frame._snapshot_interpolated = true;
|
||||
frame._snapshot_interpolation_layer = layer;
|
||||
return frame;
|
||||
}
|
||||
|
||||
function interpolateSnapshotData(prev: SnapshotData, next: SnapshotData, ratio: number): SnapshotData {
|
||||
if (ratio <= 0) return snapshotPatch(prev);
|
||||
if (ratio >= 1) return snapshotPatch(next);
|
||||
|
||||
const out = snapshotPatch(prev);
|
||||
|
||||
for (const layer of MOVING_LAYER_KEYS) {
|
||||
const prevItems = asEntityArray(prev[layer]);
|
||||
const nextItems = asEntityArray(next[layer]);
|
||||
if (!prevItems?.length) continue;
|
||||
if (!nextItems?.length) {
|
||||
out[layer] = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextByKey = new Map<string, Entity>();
|
||||
for (const item of nextItems) {
|
||||
const key = entityKey(layer, item);
|
||||
if (key) nextByKey.set(key, item);
|
||||
}
|
||||
|
||||
const interpolated: Entity[] = [];
|
||||
for (const item of prevItems) {
|
||||
const key = entityKey(layer, item);
|
||||
if (!key) continue;
|
||||
const nextItem = nextByKey.get(key);
|
||||
if (!nextItem) continue;
|
||||
const frame = interpolateEntity(layer, item, nextItem, ratio);
|
||||
if (frame) interpolated.push(frame);
|
||||
}
|
||||
out[layer] = interpolated;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function findFramePair(unixTs: number): { prev: SnapshotMeta; next: SnapshotMeta | null } | null {
|
||||
const snapshots = state.snapshots;
|
||||
if (snapshots.length === 0) return null;
|
||||
if (unixTs <= snapshots[0].unix_ts) return { prev: snapshots[0], next: null };
|
||||
const last = snapshots[snapshots.length - 1];
|
||||
if (unixTs >= last.unix_ts) return { prev: last, next: null };
|
||||
|
||||
for (let i = 0; i < snapshots.length - 1; i += 1) {
|
||||
const prev = snapshots[i];
|
||||
const next = snapshots[i + 1];
|
||||
if (unixTs >= prev.unix_ts && unixTs <= next.unix_ts) {
|
||||
return { prev, next };
|
||||
}
|
||||
}
|
||||
return { prev: last, next: null };
|
||||
}
|
||||
|
||||
async function fetchPlaybackSnapshot(snapshotId: string): Promise<PlaybackSnapshot | null> {
|
||||
const cached = playbackCache.get(snapshotId);
|
||||
if (cached) return cached;
|
||||
const inflight = playbackFetches.get(snapshotId);
|
||||
if (inflight) return inflight;
|
||||
|
||||
const request = (async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/ai/timemachine/playback/${snapshotId}`);
|
||||
if (!res.ok) return null;
|
||||
const json = (await res.json()) as PlaybackSnapshot;
|
||||
const snap: PlaybackSnapshot = {
|
||||
...json,
|
||||
id: json.snapshot_id || json.id,
|
||||
unix_ts: numericTs(json),
|
||||
layer_counts: json.layer_counts || {},
|
||||
layers: json.layers || Object.keys(json.data || {}),
|
||||
data: json.data || {},
|
||||
};
|
||||
playbackCache.set(snapshotId, snap);
|
||||
return snap;
|
||||
} finally {
|
||||
playbackFetches.delete(snapshotId);
|
||||
}
|
||||
})();
|
||||
|
||||
playbackFetches.set(snapshotId, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async function loadExactSnapshot(snapshotId: string, pausePlayback: boolean): Promise<void> {
|
||||
setState({ loading: true, error: null });
|
||||
const serial = ++_seekSerial;
|
||||
try {
|
||||
const snap = await fetchPlaybackSnapshot(snapshotId);
|
||||
if (serial !== _seekSerial) return;
|
||||
if (!snap) {
|
||||
stopPlaybackWithError('Failed to load snapshot frame.');
|
||||
return;
|
||||
}
|
||||
pausePolling();
|
||||
mergeData(snapshotPatch(snap.data));
|
||||
setState({
|
||||
mode: 'snapshot',
|
||||
snapshotId: snap.snapshot_id || snap.id,
|
||||
snapshotTimestamp: snap.timestamp,
|
||||
currentUnixTs: snap.unix_ts,
|
||||
loading: false,
|
||||
error: null,
|
||||
playing: pausePlayback ? false : state.playing,
|
||||
});
|
||||
const idx = snapshotIndex(snap.id);
|
||||
prefetchPlaybackSnapshots([
|
||||
state.snapshots[idx + 1]?.id,
|
||||
state.snapshots[idx + 2]?.id,
|
||||
]);
|
||||
if (pausePlayback) _stopPlaybackTimer();
|
||||
} catch (e) {
|
||||
stopPlaybackWithError(`Network error: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function _stopPlaybackTimer() {
|
||||
if (_playbackTimer) {
|
||||
clearInterval(_playbackTimer);
|
||||
_playbackTimer = null;
|
||||
}
|
||||
_playbackSeeking = false;
|
||||
}
|
||||
|
||||
function _startPlaybackTimer() {
|
||||
_stopPlaybackTimer();
|
||||
_playbackLastTick = performance.now();
|
||||
_playbackTimer = setInterval(() => {
|
||||
if (state.mode !== 'snapshot' || !state.playing || state.snapshots.length === 0) {
|
||||
_stopPlaybackTimer();
|
||||
return;
|
||||
}
|
||||
if (_playbackSeeking) return;
|
||||
|
||||
const now = performance.now();
|
||||
const elapsedMs = Math.max(1, now - _playbackLastTick);
|
||||
_playbackLastTick = now;
|
||||
|
||||
const currentTs = state.currentUnixTs ?? state.snapshots[0].unix_ts;
|
||||
const pair = findFramePair(currentTs + 0.001);
|
||||
if (!pair?.next) {
|
||||
setState({ playing: false });
|
||||
_stopPlaybackTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
const segmentSeconds = Math.max(1, state.playbackSpeed);
|
||||
const segmentGap = Math.max(1, pair.next.unix_ts - pair.prev.unix_ts);
|
||||
const advance = segmentGap * (elapsedMs / (segmentSeconds * 1000));
|
||||
const nextTs = Math.min(pair.next.unix_ts, currentTs + advance);
|
||||
|
||||
_playbackSeeking = true;
|
||||
void seekToTime(nextTs, { keepPlaying: true }).finally(() => {
|
||||
_playbackSeeking = false;
|
||||
});
|
||||
}, 250);
|
||||
}
|
||||
|
||||
export async function refreshSnapshotList(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/ai/timemachine/snapshots?limit=100`);
|
||||
if (!res.ok) return;
|
||||
const json = await res.json();
|
||||
updateTimelineFromSnapshots(sortSnapshots(json.snapshots || []));
|
||||
} catch (e) {
|
||||
console.error('Time Machine: failed to fetch snapshots', e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshHourlyIndex(): Promise<void> {
|
||||
try {
|
||||
const [indexRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/ai/timemachine/hourly-index`),
|
||||
refreshSnapshotList(),
|
||||
]);
|
||||
if (indexRes.ok) {
|
||||
const json = await indexRes.json();
|
||||
setState({ hourlyIndex: json.hours || {} });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Time Machine: failed to fetch hourly index', e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function enterSnapshotMode(snapshotId: string): Promise<void> {
|
||||
await loadExactSnapshot(snapshotId, true);
|
||||
}
|
||||
|
||||
export function exitSnapshotMode(): void {
|
||||
_stopPlaybackTimer();
|
||||
resumePolling();
|
||||
setState({
|
||||
mode: 'live',
|
||||
snapshotId: null,
|
||||
snapshotTimestamp: null,
|
||||
currentUnixTs: null,
|
||||
playing: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
void forceRefreshLiveData();
|
||||
}
|
||||
|
||||
export async function seekToTime(
|
||||
unixTs: number,
|
||||
options: { keepPlaying?: boolean } = {},
|
||||
): Promise<void> {
|
||||
if (state.snapshots.length === 0) return;
|
||||
const pair = findFramePair(unixTs);
|
||||
if (!pair) return;
|
||||
|
||||
const serial = ++_seekSerial;
|
||||
const waitingOnUncachedFrame =
|
||||
!playbackCache.has(pair.prev.id) || Boolean(pair.next && !playbackCache.has(pair.next.id));
|
||||
setState({ loading: !options.keepPlaying || waitingOnUncachedFrame, error: null });
|
||||
|
||||
try {
|
||||
const prev = await fetchPlaybackSnapshot(pair.prev.id);
|
||||
const next = pair.next ? await fetchPlaybackSnapshot(pair.next.id) : null;
|
||||
if (serial !== _seekSerial) return;
|
||||
if (!prev) {
|
||||
stopPlaybackWithError('Failed to fetch playback frame.');
|
||||
return;
|
||||
}
|
||||
|
||||
const hasNext = Boolean(next && pair.next && pair.next.unix_ts > pair.prev.unix_ts);
|
||||
const ratio =
|
||||
hasNext && pair.next
|
||||
? Math.max(0, Math.min(1, (unixTs - pair.prev.unix_ts) / (pair.next.unix_ts - pair.prev.unix_ts)))
|
||||
: 0;
|
||||
const data = hasNext && next ? interpolateSnapshotData(prev.data, next.data, ratio) : snapshotPatch(prev.data);
|
||||
const timestamp = new Date(unixTs * 1000).toISOString();
|
||||
|
||||
pausePolling();
|
||||
mergeData(data);
|
||||
setState({
|
||||
mode: 'snapshot',
|
||||
snapshotId: prev.snapshot_id || prev.id,
|
||||
snapshotTimestamp: timestamp,
|
||||
currentUnixTs: unixTs,
|
||||
loading: false,
|
||||
error: null,
|
||||
playing: options.keepPlaying ? state.playing : false,
|
||||
});
|
||||
const prevIdx = snapshotIndex(prev.id);
|
||||
prefetchPlaybackSnapshots([
|
||||
pair.next?.id,
|
||||
state.snapshots[prevIdx + 2]?.id,
|
||||
state.snapshots[prevIdx + 3]?.id,
|
||||
]);
|
||||
if (!options.keepPlaying) _stopPlaybackTimer();
|
||||
} catch (e) {
|
||||
stopPlaybackWithError(`Network error: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stepForward(): Promise<void> {
|
||||
const snapshots = state.snapshots;
|
||||
if (snapshots.length === 0) return;
|
||||
const currentTs = state.currentUnixTs ?? snapshots[0].unix_ts;
|
||||
const next = snapshots.find((snap) => snap.unix_ts > currentTs + 0.001);
|
||||
if (!next) {
|
||||
setState({ playing: false });
|
||||
_stopPlaybackTimer();
|
||||
return;
|
||||
}
|
||||
await loadExactSnapshot(next.id, true);
|
||||
}
|
||||
|
||||
export async function stepBackward(): Promise<void> {
|
||||
const snapshots = state.snapshots;
|
||||
if (snapshots.length === 0) return;
|
||||
const currentTs = state.currentUnixTs ?? snapshots[snapshots.length - 1].unix_ts;
|
||||
const previous = [...snapshots].reverse().find((snap) => snap.unix_ts < currentTs - 0.001);
|
||||
if (!previous) return;
|
||||
await loadExactSnapshot(previous.id, true);
|
||||
}
|
||||
|
||||
export async function startPlayback(): Promise<void> {
|
||||
if (state.snapshots.length === 0) return;
|
||||
if (state.mode !== 'snapshot') {
|
||||
await seekToTime(state.currentUnixTs ?? state.snapshots[0].unix_ts, { keepPlaying: true });
|
||||
}
|
||||
setState({ playing: true });
|
||||
_startPlaybackTimer();
|
||||
}
|
||||
|
||||
export function togglePlayback(): void {
|
||||
if (state.playing) {
|
||||
setState({ playing: false });
|
||||
_stopPlaybackTimer();
|
||||
return;
|
||||
}
|
||||
void startPlayback();
|
||||
}
|
||||
|
||||
export function setPlaybackSpeed(secondsPerSegment: number): void {
|
||||
setState({ playbackSpeed: Math.max(1, secondsPerSegment) });
|
||||
if (state.playing) _startPlaybackTimer();
|
||||
}
|
||||
|
||||
export function useTimeMachine(): TimeMachineState {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
let _indexRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
if (typeof window !== 'undefined' && !_indexRefreshTimer) {
|
||||
setTimeout(refreshHourlyIndex, 1500);
|
||||
_indexRefreshTimer = setInterval(refreshHourlyIndex, 5 * 60 * 1000);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* useWatchlist — persistent entity watchlist with live data updates.
|
||||
*
|
||||
* Allows users to pin entities (flights, ships, news) for persistent tracking.
|
||||
* Persisted to localStorage. Max 10 items.
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
export interface WatchlistEntry {
|
||||
id: string;
|
||||
type: 'flight' | 'ship' | 'news' | 'satellite' | string;
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
addedAt: number;
|
||||
// Live stats (updated externally)
|
||||
altitude?: number;
|
||||
speed?: number;
|
||||
heading?: number;
|
||||
risk_score?: number;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'sb_watchlist';
|
||||
const MAX_ITEMS = 10;
|
||||
|
||||
function loadWatchlist(): WatchlistEntry[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as WatchlistEntry[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveWatchlist(items: WatchlistEntry[]) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
}
|
||||
|
||||
export function useWatchlist() {
|
||||
const [items, setItems] = useState<WatchlistEntry[]>(() => loadWatchlist());
|
||||
|
||||
// Persist on change
|
||||
useEffect(() => {
|
||||
saveWatchlist(items);
|
||||
}, [items]);
|
||||
|
||||
const addToWatchlist = useCallback((entry: WatchlistEntry) => {
|
||||
setItems((prev) => {
|
||||
// Don't add duplicates
|
||||
if (prev.some((e) => e.id === entry.id)) return prev;
|
||||
// FIFO overflow
|
||||
const next = [entry, ...prev];
|
||||
if (next.length > MAX_ITEMS) next.pop();
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeFromWatchlist = useCallback((id: string) => {
|
||||
setItems((prev) => prev.filter((e) => e.id !== id));
|
||||
}, []);
|
||||
|
||||
const isWatched = useCallback(
|
||||
(id: string) => items.some((e) => e.id === id),
|
||||
[items],
|
||||
);
|
||||
|
||||
const clearWatchlist = useCallback(() => {
|
||||
setItems([]);
|
||||
}, []);
|
||||
|
||||
return { items, addToWatchlist, removeFromWatchlist, isWatched, clearWatchlist };
|
||||
}
|
||||
Reference in New Issue
Block a user