mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-15 04:40:26 +02:00
Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34db99deaf | |||
| a0d0a449eb | |||
| 26a72f4f95 | |||
| 3eff24c6ed | |||
| bb345ed665 | |||
| dec5b0da9c | |||
| 68cacc0fed | |||
| 40e89ac30b | |||
| 350ec11725 | |||
| 5d4dd0560d | |||
| 345f3c7451 | |||
| dde527821c | |||
| 5bee764614 | |||
| c986de9e35 | |||
| d2fa45c6a6 | |||
| d78bf61256 | |||
| b10d6e6e00 | |||
| afdc626bdb | |||
| 5ab02e821f | |||
| ac62e4763f | |||
| cf68f1978d | |||
| beadce5dae | |||
| 10f376d4d7 | |||
| ff168150c9 | |||
| 782225ff99 | |||
| f99cc669f5 | |||
| 25262323f5 | |||
| bad50b8924 | |||
| 82715c79a6 | |||
| e2a9ef9bbf | |||
| 3c16071fcd | |||
| 2ae104fca2 | |||
| 12857a4b83 | |||
| c343084def | |||
| c085475110 | |||
| e0257d2419 | |||
| 5d221c3dc7 | |||
| dd8485d1b6 | |||
| f6aa5ccbc1 | |||
| 97208a01a2 | |||
| d4c725de6e | |||
| d756dd5bd3 | |||
| d96e8f5c21 | |||
| 8afcbca667 | |||
| b68de6a594 | |||
| 36dec1088d | |||
| a38f4cbaea | |||
| 8e7ef8e95e | |||
| e597147a16 | |||
| 71c085cdd5 | |||
| c9cec26309 | |||
| 03aae3216b | |||
| 31755b294e | |||
| 9c831e37ff | |||
| 686e304358 | |||
| 8cddf6794d | |||
| a98f46c708 | |||
| d6f97df336 | |||
| 91a63cf17a | |||
| 354ed37e1a | |||
| 3c18bef174 | |||
| 09c2d3d810 | |||
| 2e53d6d7af | |||
| bf0da2c434 | |||
| a57c9be0cb | |||
| e82a5ae3be | |||
| 3326c520a9 | |||
| 24e4d331fc | |||
| c96f6ad723 | |||
| 923c80368d | |||
| 30595843a0 | |||
| cef06ff809 | |||
| 502359fc30 | |||
| 19a0ef1c70 | |||
| 197d37ae5a | |||
| 0c9d047509 | |||
| 2147eee0a6 | |||
| 1298dd326b | |||
| ed5bc5a23b | |||
| fbd64b6038 | |||
| 8d4403c7e6 | |||
| 5e3eae0f00 | |||
| 9d58be6bbb | |||
| 45e6258ea4 | |||
| c1f89ae446 | |||
| ff19d2bc68 | |||
| 9c85e08839 | |||
| c8f3812fbf | |||
| ffb3041a2b | |||
| 775bc4adfe | |||
| f9a8a998c4 | |||
| 8c843393d1 | |||
| 27213cb74a | |||
| e3237dfba3 | |||
| fa9ce48782 | |||
| d36061976e |
@@ -0,0 +1,16 @@
|
||||
# ShadowBroker — Docker Compose Environment Variables
|
||||
# Copy this file to .env and fill in your keys:
|
||||
# cp .env.example .env
|
||||
|
||||
# ── Required for backend container ─────────────────────────────
|
||||
OPENSKY_CLIENT_ID=
|
||||
OPENSKY_CLIENT_SECRET=
|
||||
AIS_API_KEY=
|
||||
|
||||
# ── Optional ───────────────────────────────────────────────────
|
||||
|
||||
# LTA (Singapore traffic cameras) — leave blank to skip
|
||||
# LTA_ACCOUNT_KEY=
|
||||
|
||||
# Override the backend URL the frontend uses (leave blank for auto-detect)
|
||||
# NEXT_PUBLIC_API_URL=http://192.168.1.50:8000
|
||||
@@ -0,0 +1,242 @@
|
||||
name: Docker Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["v*.*.*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-frontend:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Lowercase image name
|
||||
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend
|
||||
|
||||
- name: Build and push Docker image by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v5.0.0
|
||||
with:
|
||||
context: ./frontend
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=frontend-${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=frontend-${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
|
||||
|
||||
- name: Export digest
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
mkdir -p /tmp/digests/frontend
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/frontend/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-frontend-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||
path: /tmp/digests/frontend/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
needs: build-frontend
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Lowercase image name
|
||||
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests/frontend
|
||||
pattern: digests-frontend-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@v3.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Create and push manifest
|
||||
working-directory: /tmp/digests/frontend
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend@sha256:%s ' *)
|
||||
|
||||
build-backend:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Lowercase image name
|
||||
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend
|
||||
|
||||
- name: Build and push Docker image by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v5.0.0
|
||||
with:
|
||||
context: ./backend
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=backend-${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=backend-${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
|
||||
|
||||
- name: Export digest
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
mkdir -p /tmp/digests/backend
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/backend/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-backend-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||
path: /tmp/digests/backend/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge-backend:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
needs: build-backend
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Lowercase image name
|
||||
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests/backend
|
||||
pattern: digests-backend-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@v3.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Create and push manifest
|
||||
working-directory: /tmp/digests/backend
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend@sha256:%s ' *)
|
||||
+33
@@ -64,3 +64,36 @@ rss_output.txt
|
||||
merged.txt
|
||||
tmp_fast.json
|
||||
TheAirTraffic Database.xlsx
|
||||
|
||||
# Debug dumps & release artifacts
|
||||
backend/dump.json
|
||||
backend/debug_fast.json
|
||||
backend/nyc_sample.json
|
||||
backend/nyc_full.json
|
||||
backend/liveua_test.html
|
||||
backend/out_liveua.json
|
||||
frontend/server_logs*.txt
|
||||
frontend/cctv.db
|
||||
*.zip
|
||||
.git_backup/
|
||||
|
||||
# Test files (may contain hardcoded keys)
|
||||
backend/test_*.py
|
||||
backend/services/test_*.py
|
||||
|
||||
# Local analysis & dev tools
|
||||
backend/analyze_xlsx.py
|
||||
backend/xlsx_analysis.txt
|
||||
backend/services/ais_cache.json
|
||||
|
||||
# Internal update tracking (not for repo)
|
||||
updatestuff.md
|
||||
|
||||
# Misc dev artifacts
|
||||
clean_zip.py
|
||||
zip_repo.py
|
||||
refactor_cesium.py
|
||||
jobs.json
|
||||
|
||||
.claude
|
||||
.mise.local.toml
|
||||
|
||||
@@ -2,114 +2,177 @@
|
||||
<h1 align="center">🛰️ S H A D O W B R O K E R</h1>
|
||||
<p align="center"><strong>Global Threat Intercept — Real-Time Geospatial Intelligence Platform</strong></p>
|
||||
<p align="center">
|
||||
<code>TOP SECRET // SI TK // NOFORN</code>
|
||||
|
||||
</p>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
**ShadowBroker** is a real-time, full-spectrum geospatial intelligence dashboard that aggregates live data from dozens of open-source intelligence (OSINT) feeds and renders them on a unified dark-ops map interface. It tracks aircraft, ships, satellites, earthquakes, conflict zones, CCTV networks, GPS jamming, and breaking geopolitical events — all updating in real time.
|
||||
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/248208ec-62f7-49d1-831d-4bd0a1fa6852
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
**ShadowBroker** is a real-time, multi-domain OSINT dashboard that aggregates live data from dozens of open-source intelligence feeds and renders them on a unified dark-ops map interface. It tracks aircraft, ships, satellites, earthquakes, conflict zones, CCTV networks, GPS jamming, and breaking geopolitical events — all updating in real time.
|
||||
|
||||
Built with **Next.js**, **MapLibre GL**, **FastAPI**, and **Python**, it's designed for analysts, researchers, and enthusiasts who want a single-pane-of-glass view of global activity.
|
||||
|
||||
---
|
||||
|
||||
## Interesting Use Cases
|
||||
|
||||
* Track everything from Air Force One to the private jets of billionaires, dictators, and corporations
|
||||
* Monitor satellites passing overhead and see high-resolution satellite imagery
|
||||
* Nose around local emergency scanners
|
||||
* Watch naval traffic worldwide
|
||||
* Detect GPS jamming zones
|
||||
* Follow earthquakes and disasters in real time
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Quick Start (Docker or Podman)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/BigBodyCobain/Shadowbroker.git
|
||||
cd Shadowbroker
|
||||
./compose.sh up -d
|
||||
```
|
||||
|
||||
Open `http://localhost:3000` to view the dashboard! *(Requires Docker or Podman)*
|
||||
|
||||
`compose.sh` auto-detects `docker compose`, `docker-compose`, `podman compose`, and `podman-compose`.
|
||||
If both runtimes are installed, you can force Podman with `./compose.sh --engine podman up -d`.
|
||||
Do not append a trailing `.` to that command; Compose treats it as a service name.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### 🛩️ Aviation Tracking
|
||||
|
||||
- **Commercial Flights** — Real-time positions via OpenSky Network (~5,000+ aircraft)
|
||||
- **Private Aircraft** — Light GA, turboprops, bizjets tracked separately
|
||||
- **Private Jets** — High-net-worth individual aircraft with owner identification
|
||||
- **Military Flights** — Tankers, ISR, fighters, transports via adsb.lol military endpoint
|
||||
- **Flight Trail Accumulation** — Persistent breadcrumb trails for all tracked aircraft
|
||||
- **Holding Pattern Detection** — Automatically flags aircraft circling (>300° total turn)
|
||||
- **Aircraft Classification** — Shape-accurate SVG icons: airliners, turboprops, bizjets, helicopters
|
||||
- **Grounded Detection** — Aircraft below 100ft AGL rendered with grey icons
|
||||
* **Commercial Flights** — Real-time positions via OpenSky Network (~5,000+ aircraft)
|
||||
* **Private Aircraft** — Light GA, turboprops, bizjets tracked separately
|
||||
* **Private Jets** — High-net-worth individual aircraft with owner identification
|
||||
* **Military Flights** — Tankers, ISR, fighters, transports via adsb.lol military endpoint
|
||||
* **Flight Trail Accumulation** — Persistent breadcrumb trails for all tracked aircraft
|
||||
* **Holding Pattern Detection** — Automatically flags aircraft circling (>300° total turn)
|
||||
* **Aircraft Classification** — Shape-accurate SVG icons: airliners, turboprops, bizjets, helicopters
|
||||
* **Grounded Detection** — Aircraft below 100ft AGL rendered with grey icons
|
||||
|
||||
### 🚢 Maritime Tracking
|
||||
|
||||
- **AIS Vessel Stream** — 25,000+ vessels via aisstream.io WebSocket (real-time)
|
||||
- **Ship Classification** — Cargo, tanker, passenger, yacht, military vessel types with color-coded icons
|
||||
- **Carrier Strike Group Tracker** — All 11 active US Navy aircraft carriers with OSINT-estimated positions
|
||||
- Automated GDELT news scraping for carrier movement intelligence
|
||||
- 50+ geographic region-to-coordinate mappings
|
||||
- Disk-cached positions, auto-updates at 00:00 & 12:00 UTC
|
||||
- **Cruise & Passenger Ships** — Dedicated layer for cruise liners and ferries
|
||||
- **Clustered Display** — Ships cluster at low zoom with count labels, decluster on zoom-in
|
||||
* **AIS Vessel Stream** — 25,000+ vessels via aisstream.io WebSocket (real-time)
|
||||
* **Ship Classification** — Cargo, tanker, passenger, yacht, military vessel types with color-coded icons
|
||||
* **Carrier Strike Group Tracker** — All 11 active US Navy aircraft carriers with OSINT-estimated positions
|
||||
* Automated GDELT news scraping for carrier movement intelligence
|
||||
* 50+ geographic region-to-coordinate mappings
|
||||
* Disk-cached positions, auto-updates at 00:00 & 12:00 UTC
|
||||
* **Cruise & Passenger Ships** — Dedicated layer for cruise liners and ferries
|
||||
* **Clustered Display** — Ships cluster at low zoom with count labels, decluster on zoom-in
|
||||
|
||||
### 🛰️ Space & Satellites
|
||||
|
||||
- **Orbital Tracking** — Real-time satellite positions from N2YO API
|
||||
- **Mission-Type Classification** — Color-coded by mission: military recon (red), SAR (cyan), SIGINT (white), navigation (blue), early warning (magenta), commercial imaging (green), space station (gold)
|
||||
* **Orbital Tracking** — Real-time satellite positions via CelesTrak TLE data + SGP4 propagation (2,000+ active satellites, no API key required)
|
||||
* **Mission-Type Classification** — Color-coded by mission: military recon (red), SAR (cyan), SIGINT (white), navigation (blue), early warning (magenta), commercial imaging (green), space station (gold)
|
||||
|
||||
### 🌍 Geopolitics & Conflict
|
||||
|
||||
- **Global Incidents** — GDELT-powered conflict event aggregation (last 8 hours, ~1,000 events)
|
||||
- **Ukraine Frontline** — Live warfront GeoJSON from DeepState Map
|
||||
- **SIGINT/RISINT News Feed** — Real-time RSS aggregation from multiple intelligence-focused sources
|
||||
- **Region Dossier** — Right-click anywhere on the map for:
|
||||
- Country profile (population, capital, languages, currencies, area)
|
||||
- Head of state & government type (Wikidata SPARQL)
|
||||
- Local Wikipedia summary with thumbnail
|
||||
* **Global Incidents** — GDELT-powered conflict event aggregation (last 8 hours, ~1,000 events)
|
||||
* **Ukraine Frontline** — Live warfront GeoJSON from DeepState Map
|
||||
* **SIGINT/RISINT News Feed** — Real-time RSS aggregation from multiple intelligence-focused sources with user-customizable feeds (up to 20 sources, configurable priority weights 1-5)
|
||||
* **Region Dossier** — Right-click anywhere on the map for:
|
||||
* Country profile (population, capital, languages, currencies, area)
|
||||
* Head of state & government type (Wikidata SPARQL)
|
||||
* Local Wikipedia summary with thumbnail
|
||||
|
||||
### 🛰️ Satellite Imagery
|
||||
|
||||
* **NASA GIBS (MODIS Terra)** — Daily true-color satellite imagery overlay with 30-day time slider, play/pause animation, and opacity control (~250m/pixel)
|
||||
* **High-Res Satellite (Esri)** — Sub-meter resolution imagery via Esri World Imagery — zoom into buildings and terrain detail (zoom 18+)
|
||||
* **Sentinel-2 Intel Card** — Right-click anywhere on the map for a floating intel card showing the latest Sentinel-2 satellite photo with capture date, cloud cover %, and clickable full-resolution image (10m resolution, updated every ~5 days)
|
||||
* **SATELLITE Style Preset** — Quick-toggle high-res imagery via the STYLE button (DEFAULT → SATELLITE → FLIR → NVG → CRT)
|
||||
|
||||
### 📻 Software-Defined Radio (SDR)
|
||||
|
||||
* **KiwiSDR Receivers** — 500+ public SDR receivers plotted worldwide with clustered amber markers
|
||||
* **Live Radio Tuner** — Click any KiwiSDR node to open an embedded SDR tuner directly in the SIGINT panel
|
||||
* **Metadata Display** — Node name, location, antenna type, frequency bands, active users
|
||||
|
||||
### 📷 Surveillance
|
||||
|
||||
- **CCTV Mesh** — 2,000+ live traffic cameras from:
|
||||
- 🇬🇧 Transport for London JamCams
|
||||
- 🇺🇸 Austin, TX TxDOT
|
||||
- 🇺🇸 NYC DOT
|
||||
- 🇸🇬 Singapore LTA
|
||||
- Custom URL ingestion
|
||||
- **Feed Rendering** — Automatic detection & rendering of video, MJPEG, HLS, embed, satellite tile, and image feeds
|
||||
- **Clustered Map Display** — Green dots cluster with count labels, decluster on zoom
|
||||
* **CCTV Mesh** — 2,000+ live traffic cameras from:
|
||||
* 🇬🇧 Transport for London JamCams
|
||||
* 🇺🇸 Austin, TX TxDOT
|
||||
* 🇺🇸 NYC DOT
|
||||
* 🇸🇬 Singapore LTA
|
||||
* Custom URL ingestion
|
||||
* **Feed Rendering** — Automatic detection & rendering of video, MJPEG, HLS, embed, satellite tile, and image feeds
|
||||
* **Clustered Map Display** — Green dots cluster with count labels, decluster on zoom
|
||||
|
||||
### 📡 Signal Intelligence
|
||||
|
||||
- **GPS Jamming Detection** — Real-time analysis of aircraft NAC-P (Navigation Accuracy Category) values
|
||||
- Grid-based aggregation identifies interference zones
|
||||
- Red overlay squares with "GPS JAM XX%" severity labels
|
||||
- **Radio Intercept Panel** — Scanner-style UI for monitoring communications
|
||||
* **GPS Jamming Detection** — Real-time analysis of aircraft NAC-P (Navigation Accuracy Category) values
|
||||
* Grid-based aggregation identifies interference zones
|
||||
* Red overlay squares with "GPS JAM XX%" severity labels
|
||||
* **Radio Intercept Panel** — Scanner-style UI for monitoring communications
|
||||
|
||||
### 🔥 Environmental & Infrastructure Monitoring
|
||||
|
||||
* **NASA FIRMS Fire Hotspots (24h)** — 5,000+ global thermal anomalies from NOAA-20 VIIRS satellite, updated every cycle. Flame-shaped icons color-coded by fire radiative power (FRP): yellow (low), orange, red, dark red (intense). Clustered at low zoom with fire-shaped cluster markers.
|
||||
* **Space Weather Badge** — Live NOAA geomagnetic storm indicator in the bottom status bar. Color-coded Kp index: green (quiet), yellow (active), red (storm G1–G5). Data from SWPC planetary K-index 1-minute feed.
|
||||
* **Internet Outage Monitoring** — Regional internet connectivity alerts from Georgia Tech IODA. Grey markers at affected regions with severity percentage. Uses only reliable datasources (BGP routing tables, active ping probing) — no telescope or interpolated data.
|
||||
* **Data Center Mapping** — 2,000+ global data centers plotted from a curated dataset. Clustered purple markers with server-rack icons. Click for operator, location, and automatic internet outage cross-referencing by country.
|
||||
|
||||
### 🌐 Additional Layers
|
||||
|
||||
- **Earthquakes (24h)** — USGS real-time earthquake feed with magnitude-scaled markers
|
||||
- **Day/Night Cycle** — Solar terminator overlay showing global daylight/darkness
|
||||
- **Global Markets Ticker** — Live financial market indices (minimizable)
|
||||
- **Measurement Tool** — Point-to-point distance & bearing measurement on the map
|
||||
* **Earthquakes (24h)** — USGS real-time earthquake feed with magnitude-scaled markers
|
||||
* **Day/Night Cycle** — Solar terminator overlay showing global daylight/darkness
|
||||
* **Global Markets Ticker** — Live financial market indices (minimizable)
|
||||
* **Measurement Tool** — Point-to-point distance & bearing measurement on the map
|
||||
* **LOCATE Bar** — Search by coordinates (31.8, 34.8) or place name (Tehran, Strait of Hormuz) to fly directly to any location — geocoded via OpenStreetMap Nominatim
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ FRONTEND (Next.js) │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────┐ ┌─────────────────┐ │
|
||||
│ │ MapLibre GL │ │ NewsFeed │ │ Control Panels │ │
|
||||
│ │ 2D WebGL │ │ SIGINT │ │ Layers/Filters │ │
|
||||
│ │ Map Render │ │ Intel │ │ Markets/Radio │ │
|
||||
│ └──────┬──────┘ └────┬─────┘ └────────┬────────┘ │
|
||||
│ └──────────────┼─────────────────┘ │
|
||||
│ │ REST API (15s fast / 60s slow│
|
||||
├────────────────────────┼─────────────────────────────┤
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ FRONTEND (Next.js) │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────┐ ┌───────────────┐ │
|
||||
│ │ MapLibre GL │ │ NewsFeed │ │ Control Panels│ │
|
||||
│ │ 2D WebGL │ │ SIGINT │ │ Layers/Filters│ │
|
||||
│ │ Map Render │ │ Intel │ │ Markets/Radio │ │
|
||||
│ └──────┬──────┘ └────┬─────┘ └───────┬───────┘ │
|
||||
│ └────────────────┼──────────────────┘ │
|
||||
│ │ REST API (60s / 120s) │
|
||||
├──────────────────────────┼─────────────────────────────┤
|
||||
│ BACKEND (FastAPI) │
|
||||
│ │ │
|
||||
│ ┌─────────────────────┼─────────────────────────┐ │
|
||||
│ │ Data Fetcher (Scheduler) │ │
|
||||
│ │ ┌──────────┬──────────┬──────────┬─────────┐ │ │
|
||||
│ │ │ OpenSky │ adsb.lol │ N2YO │ USGS │ │ │
|
||||
│ │ │ Flights │ Military │ Sats │ Quakes │ │ │
|
||||
│ │ ├──────────┼──────────┼──────────┼─────────┤ │ │
|
||||
│ │ │ AIS WS │ Carrier │ GDELT │ CCTV │ │ │
|
||||
│ │ │ Ships │ Tracker │ Conflict │ Cameras │ │ │
|
||||
│ │ ├──────────┼──────────┼──────────┼─────────┤ │ │
|
||||
│ │ │ DeepState│ RSS │ Region │ GPS │ │ │
|
||||
│ │ │ Frontline│ Intel │ Dossier │ Jamming │ │ │
|
||||
│ │ └──────────┴──────────┴──────────┴─────────┘ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
│ │ │
|
||||
│ ┌───────────────────────┼──────────────────────────┐ │
|
||||
│ │ Data Fetcher (Scheduler) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────┬──────────┬──────────┬───────────┐ │ │
|
||||
│ │ │ OpenSky │ adsb.lol │CelesTrak │ USGS │ │ │
|
||||
│ │ │ Flights │ Military │ Sats │ Quakes │ │ │
|
||||
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
||||
│ │ │ AIS WS │ Carrier │ GDELT │ CCTV │ │ │
|
||||
│ │ │ Ships │ Tracker │ Conflict │ Cameras │ │ │
|
||||
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
||||
│ │ │ DeepState│ RSS │ Region │ GPS │ │ │
|
||||
│ │ │ Frontline│ Intel │ Dossier │ Jamming │ │ │
|
||||
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
||||
│ │ │ NASA │ NOAA │ IODA │ KiwiSDR │ │ │
|
||||
│ │ │ FIRMS │ Space Wx│ Outages │ Radios │ │ │
|
||||
│ │ └──────────┴──────────┴──────────┴───────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
@@ -121,7 +184,7 @@ Built with **Next.js**, **MapLibre GL**, **FastAPI**, and **Python**, it's desig
|
||||
| [OpenSky Network](https://opensky-network.org) | Commercial & private flights | ~60s | Optional (anonymous limited) |
|
||||
| [adsb.lol](https://adsb.lol) | Military aircraft | ~60s | No |
|
||||
| [aisstream.io](https://aisstream.io) | AIS vessel positions | Real-time WebSocket | **Yes** |
|
||||
| [N2YO](https://www.n2yo.com) | Satellite orbital positions | ~60s | **Yes** |
|
||||
| [CelesTrak](https://celestrak.org) | Satellite orbital positions (TLE + SGP4) | ~60s | No |
|
||||
| [USGS Earthquake](https://earthquake.usgs.gov) | Global seismic events | ~60s | No |
|
||||
| [GDELT Project](https://www.gdeltproject.org) | Global conflict events | ~6h | No |
|
||||
| [DeepState Map](https://deepstatemap.live) | Ukraine frontline | ~30min | No |
|
||||
@@ -132,18 +195,110 @@ Built with **Next.js**, **MapLibre GL**, **FastAPI**, and **Python**, it's desig
|
||||
| [RestCountries](https://restcountries.com) | Country profile data | On-demand (cached 24h) | No |
|
||||
| [Wikidata SPARQL](https://query.wikidata.org) | Head of state data | On-demand (cached 24h) | No |
|
||||
| [Wikipedia API](https://en.wikipedia.org/api) | Location summaries & aircraft images | On-demand (cached) | No |
|
||||
| [NASA GIBS](https://gibs.earthdata.nasa.gov) | MODIS Terra daily satellite imagery | Daily (24-48h delay) | No |
|
||||
| [Esri World Imagery](https://www.arcgis.com) | High-res satellite basemap | Static (periodically updated) | No |
|
||||
| [MS Planetary Computer](https://planetarycomputer.microsoft.com) | Sentinel-2 L2A scenes (right-click) | On-demand | No |
|
||||
| [KiwiSDR](https://kiwisdr.com) | Public SDR receiver locations | ~30min | No |
|
||||
| [OSM Nominatim](https://nominatim.openstreetmap.org) | Place name geocoding (LOCATE bar) | On-demand | No |
|
||||
| [NASA FIRMS](https://firms.modaps.eosdis.nasa.gov) | NOAA-20 VIIRS fire/thermal hotspots | ~120s | No |
|
||||
| [NOAA SWPC](https://services.swpc.noaa.gov) | Space weather Kp index & solar events | ~120s | No |
|
||||
| [IODA (Georgia Tech)](https://ioda.inetintel.cc.gatech.edu) | Regional internet outage alerts | ~120s | No |
|
||||
| [DC Map (GitHub)](https://github.com/Ringmast4r/Data-Center-Map---Global) | Global data center locations | Static (cached 7d) | No |
|
||||
| [CARTO Basemaps](https://carto.com) | Dark map tiles | Continuous | No |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### 🐳 Docker / Podman Setup (Recommended for Self-Hosting)
|
||||
|
||||
The repo includes a `docker-compose.yml` that builds both images locally.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/BigBodyCobain/Shadowbroker.git
|
||||
cd Shadowbroker
|
||||
# Add your API keys in a repo-root .env file (optional — see Environment Variables below)
|
||||
./compose.sh up -d
|
||||
```
|
||||
|
||||
Open `http://localhost:3000` to view the dashboard.
|
||||
|
||||
> **Deploying publicly or on a LAN?** No configuration needed for most setups.
|
||||
> The frontend proxies all API calls through the Next.js server to `BACKEND_URL`,
|
||||
> which defaults to `http://backend:8000` (Docker internal networking).
|
||||
> Port 8000 does not need to be exposed externally.
|
||||
>
|
||||
> If your backend runs on a **different host or port**, set `BACKEND_URL` at runtime — no rebuild required:
|
||||
>
|
||||
> ```bash
|
||||
> # Linux / macOS
|
||||
> BACKEND_URL=http://myserver.com:9096 docker-compose up -d
|
||||
>
|
||||
> # Podman (via compose.sh wrapper)
|
||||
> BACKEND_URL=http://192.168.1.50:9096 ./compose.sh up -d
|
||||
>
|
||||
> # Windows (PowerShell)
|
||||
> $env:BACKEND_URL="http://myserver.com:9096"; docker-compose up -d
|
||||
>
|
||||
> # Or add to a .env file next to docker-compose.yml:
|
||||
> # BACKEND_URL=http://myserver.com:9096
|
||||
> ```
|
||||
|
||||
If you prefer to call the container engine directly, Podman users can run `podman compose up -d`, or force the wrapper to use Podman with `./compose.sh --engine podman up -d`.
|
||||
Depending on your local Podman configuration, `podman compose` may still delegate to an external compose provider while talking to the Podman socket.
|
||||
|
||||
---
|
||||
|
||||
### 🐋 Standalone Deploy (Portainer, Uncloud, NAS, etc.)
|
||||
|
||||
No need to clone the repo. Use the pre-built images published to the GitHub Container Registry.
|
||||
|
||||
Create a `docker-compose.yml` with the following content and deploy it directly — paste it into Portainer's stack editor, `uncloud deploy`, or any Docker host:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
image: ghcr.io/bigbodycobain/shadowbroker-backend:latest
|
||||
container_name: shadowbroker-backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- AIS_API_KEY=your_aisstream_key # Required — get one free at aisstream.io
|
||||
- OPENSKY_CLIENT_ID= # Optional — higher flight data rate limits
|
||||
- OPENSKY_CLIENT_SECRET= # Optional — paired with Client ID above
|
||||
- LTA_ACCOUNT_KEY= # Optional — Singapore CCTV cameras
|
||||
- CORS_ORIGINS= # Optional — comma-separated allowed origins
|
||||
volumes:
|
||||
- backend_data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
image: ghcr.io/bigbodycobain/shadowbroker-frontend:latest
|
||||
container_name: shadowbroker-frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- BACKEND_URL=http://backend:8000 # Docker internal networking — no rebuild needed
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
backend_data:
|
||||
```
|
||||
|
||||
> **How it works:** The frontend container proxies all `/api/*` requests through the Next.js server to `BACKEND_URL` using Docker's internal networking. The browser only ever talks to port 3000 — port 8000 does not need to be exposed externally.
|
||||
>
|
||||
> `BACKEND_URL` is a plain runtime environment variable (not a build-time `NEXT_PUBLIC_*`), so you can change it in Portainer, Uncloud, or any compose editor without rebuilding the image. Set it to the address where your backend is reachable from inside the Docker network (e.g. `http://backend:8000`, `http://192.168.1.50:8000`).
|
||||
|
||||
---
|
||||
|
||||
### 📦 Quick Start (No Code Required)
|
||||
|
||||
If you just want to run the dashboard without dealing with terminal commands:
|
||||
|
||||
1. Go to the **[Releases](../../releases)** tab on the right side of this GitHub page.
|
||||
2. Download the `ShadowBroker_v0.1.zip` file.
|
||||
2. Download the latest `.zip` file from the release.
|
||||
3. Extract the folder to your computer.
|
||||
4. **Windows:** Double-click `start.bat`.
|
||||
**Mac/Linux:** Open terminal, type `chmod +x start.sh`, and run `./start.sh`.
|
||||
@@ -157,9 +312,10 @@ If you want to modify the code or run from source:
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- **Node.js** 18+ and **npm**
|
||||
- **Python** 3.10+ with `pip`
|
||||
- API keys for: `aisstream.io`, `n2yo.com` (and optionally `opensky-network.org`, `lta.gov.sg`)
|
||||
* **Node.js** 18+ and **npm** — [nodejs.org](https://nodejs.org/)
|
||||
* **Python** 3.10, 3.11, or 3.12 with `pip` — [python.org](https://www.python.org/downloads/) (**check "Add to PATH"** during install)
|
||||
* ⚠️ Python 3.13+ may have compatibility issues with some dependencies. **3.11 or 3.12 is recommended.**
|
||||
* API keys for: `aisstream.io` (required), and optionally `opensky-network.org` (OAuth2), `lta.gov.sg`
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -173,13 +329,12 @@ cd backend
|
||||
python -m venv venv
|
||||
venv\Scripts\activate # Windows
|
||||
# source venv/bin/activate # macOS/Linux
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements.txt # includes pystac-client for Sentinel-2
|
||||
|
||||
# Create .env with your API keys
|
||||
echo "AISSTREAM_API_KEY=your_key_here" >> .env
|
||||
echo "N2YO_API_KEY=your_key_here" >> .env
|
||||
echo "OPENSKY_USERNAME=your_user" >> .env
|
||||
echo "OPENSKY_PASSWORD=your_pass" >> .env
|
||||
echo "AIS_API_KEY=your_aisstream_key" >> .env
|
||||
echo "OPENSKY_CLIENT_ID=your_opensky_client_id" >> .env
|
||||
echo "OPENSKY_CLIENT_SECRET=your_opensky_secret" >> .env
|
||||
|
||||
# Frontend setup
|
||||
cd ../frontend
|
||||
@@ -195,8 +350,8 @@ npm run dev
|
||||
|
||||
This starts:
|
||||
|
||||
- **Next.js** frontend on `http://localhost:3000`
|
||||
- **FastAPI** backend on `http://localhost:8000`
|
||||
* **Next.js** frontend on `http://localhost:3000`
|
||||
* **FastAPI** backend on `http://localhost:8000`
|
||||
|
||||
---
|
||||
|
||||
@@ -220,6 +375,12 @@ All layers are independently toggleable from the left panel:
|
||||
| Ukraine Frontline | ✅ ON | Live warfront positions |
|
||||
| Global Incidents | ✅ ON | GDELT conflict events |
|
||||
| GPS Jamming | ✅ ON | NAC-P degradation zones |
|
||||
| MODIS Terra (Daily) | ❌ OFF | NASA GIBS daily satellite imagery |
|
||||
| High-Res Satellite | ❌ OFF | Esri sub-meter satellite imagery |
|
||||
| KiwiSDR Receivers | ❌ OFF | Public SDR radio receivers |
|
||||
| Fire Hotspots (24h) | ❌ OFF | NASA FIRMS VIIRS thermal anomalies |
|
||||
| Internet Outages | ❌ OFF | IODA regional connectivity alerts |
|
||||
| Data Centers | ❌ OFF | Global data center locations (2,000+) |
|
||||
| Day / Night Cycle | ✅ ON | Solar terminator overlay |
|
||||
|
||||
---
|
||||
@@ -228,14 +389,15 @@ All layers are independently toggleable from the left panel:
|
||||
|
||||
The platform is optimized for handling massive real-time datasets:
|
||||
|
||||
- **Gzip Compression** — API payloads compressed ~92% (11.6 MB → 915 KB)
|
||||
- **ETag Caching** — `304 Not Modified` responses skip redundant JSON parsing
|
||||
- **Viewport Culling** — Only features within the visible map bounds (+20% buffer) are rendered
|
||||
- **Clustered Rendering** — Ships, CCTV, and earthquakes use MapLibre clustering to reduce feature count
|
||||
- **Debounced Viewport Updates** — 300ms debounce prevents GeoJSON rebuild thrash during pan/zoom
|
||||
- **Position Interpolation** — Smooth 10s tick animation between data refreshes
|
||||
- **React.memo** — Heavy components wrapped to prevent unnecessary re-renders
|
||||
- **Coordinate Precision** — Lat/lng rounded to 5 decimals (~1m) to reduce JSON size
|
||||
* **Gzip Compression** — API payloads compressed ~92% (11.6 MB → 915 KB)
|
||||
* **ETag Caching** — `304 Not Modified` responses skip redundant JSON parsing
|
||||
* **Viewport Culling** — Only features within the visible map bounds (+20% buffer) are rendered
|
||||
* **Imperative Map Updates** — High-volume layers (flights, satellites, fires) bypass React reconciliation via direct `setData()` calls
|
||||
* **Clustered Rendering** — Ships, CCTV, earthquakes, and data centers use MapLibre clustering to reduce feature count
|
||||
* **Debounced Viewport Updates** — 300ms debounce prevents GeoJSON rebuild thrash during pan/zoom; 2s debounce on dense layers (satellites, fires)
|
||||
* **Position Interpolation** — Smooth 10s tick animation between data refreshes
|
||||
* **React.memo** — Heavy components wrapped to prevent unnecessary re-renders
|
||||
* **Coordinate Precision** — Lat/lng rounded to 5 decimals (~1m) to reduce JSON size
|
||||
|
||||
---
|
||||
|
||||
@@ -247,6 +409,8 @@ live-risk-dashboard/
|
||||
│ ├── main.py # FastAPI app, middleware, API routes
|
||||
│ ├── carrier_cache.json # Persisted carrier OSINT positions
|
||||
│ ├── cctv.db # SQLite CCTV camera database
|
||||
│ ├── config/
|
||||
│ │ └── news_feeds.json # User-customizable RSS feed list (persists across restarts)
|
||||
│ └── services/
|
||||
│ ├── data_fetcher.py # Core scheduler — fetches all data sources
|
||||
│ ├── ais_stream.py # AIS WebSocket client (25K+ vessels)
|
||||
@@ -255,8 +419,11 @@ live-risk-dashboard/
|
||||
│ ├── geopolitics.py # GDELT + Ukraine frontline fetcher
|
||||
│ ├── region_dossier.py # Right-click country/city intelligence
|
||||
│ ├── radio_intercept.py # Scanner radio feed integration
|
||||
│ ├── kiwisdr_fetcher.py # KiwiSDR receiver scraper
|
||||
│ ├── sentinel_search.py # Sentinel-2 STAC imagery search
|
||||
│ ├── network_utils.py # HTTP client with curl fallback
|
||||
│ └── api_settings.py # API key management
|
||||
│ ├── api_settings.py # API key management
|
||||
│ └── news_feed_config.py # RSS feed config manager (add/remove/weight feeds)
|
||||
│
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
@@ -273,7 +440,8 @@ live-risk-dashboard/
|
||||
│ │ ├── MarketsPanel.tsx # Global financial markets ticker
|
||||
│ │ ├── RadioInterceptPanel.tsx # Scanner-style radio panel
|
||||
│ │ ├── FindLocateBar.tsx # Search/locate bar
|
||||
│ │ ├── SettingsPanel.tsx # App settings
|
||||
│ │ ├── ChangelogModal.tsx # Version changelog popup
|
||||
│ │ ├── SettingsPanel.tsx # App settings (API Keys + News Feed manager)
|
||||
│ │ ├── ScaleBar.tsx # Map scale indicator
|
||||
│ │ ├── WikiImage.tsx # Wikipedia image fetcher
|
||||
│ │ └── ErrorBoundary.tsx # Crash recovery wrapper
|
||||
@@ -284,19 +452,26 @@ live-risk-dashboard/
|
||||
|
||||
## 🔑 Environment Variables
|
||||
|
||||
Create a `.env` file in the `backend/` directory:
|
||||
### Backend (`backend/.env`)
|
||||
|
||||
```env
|
||||
# Required
|
||||
AISSTREAM_API_KEY=your_aisstream_key # Maritime vessel tracking
|
||||
N2YO_API_KEY=your_n2yo_key # Satellite position data
|
||||
AIS_API_KEY=your_aisstream_key # Maritime vessel tracking (aisstream.io)
|
||||
|
||||
# Optional (enhances data quality)
|
||||
OPENSKY_CLIENT_ID=your_opensky_client_id # Higher rate limits for flight data
|
||||
OPENSKY_CLIENT_SECRET=your_opensky_secret
|
||||
LTA_ACCOUNT_KEY=your_lta_key # Singapore CCTV cameras
|
||||
OPENSKY_CLIENT_ID=your_opensky_client_id # OAuth2 — higher rate limits for flight data
|
||||
OPENSKY_CLIENT_SECRET=your_opensky_secret # OAuth2 — paired with Client ID above
|
||||
LTA_ACCOUNT_KEY=your_lta_key # Singapore CCTV cameras
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
| Variable | Where to set | Purpose |
|
||||
|---|---|---|
|
||||
| `BACKEND_URL` | `environment` in `docker-compose.yml`, or shell env | URL the Next.js server uses to proxy API calls to the backend. Defaults to `http://backend:8000`. **Runtime variable — no rebuild needed.** |
|
||||
|
||||
**How it works:** The frontend proxies all `/api/*` requests through the Next.js server to `BACKEND_URL` using Docker's internal networking. Browsers only talk to port 3000; port 8000 never needs to be exposed externally. For local dev without Docker, `BACKEND_URL` defaults to `http://localhost:8000`.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ba57965389036194d6dd60e6de33d2e1e1bbf20b
|
||||
@@ -0,0 +1,16 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
cctv.db
|
||||
*.txt
|
||||
!requirements.txt
|
||||
# Exclude debug/cache JSON but keep package.json and tracked_names
|
||||
ais_cache.json
|
||||
carrier_positions.json
|
||||
dump.json
|
||||
debug_fast.json
|
||||
nyc_full.json
|
||||
nyc_sample.json
|
||||
@@ -0,0 +1,15 @@
|
||||
# ShadowBroker Backend — Environment Variables
|
||||
# Copy this file to .env and fill in your keys:
|
||||
# cp .env.example .env
|
||||
|
||||
# ── Required Keys ──────────────────────────────────────────────
|
||||
# Without these, the corresponding data layers will be empty.
|
||||
|
||||
OPENSKY_CLIENT_ID= # https://opensky-network.org/ — free account, OAuth2 client ID
|
||||
OPENSKY_CLIENT_SECRET= # OAuth2 client secret from your OpenSky dashboard
|
||||
AIS_API_KEY= # https://aisstream.io/ — free tier WebSocket key
|
||||
|
||||
# ── Optional ───────────────────────────────────────────────────
|
||||
|
||||
# Override allowed CORS origins (comma-separated). Defaults to localhost + LAN auto-detect.
|
||||
# CORS_ORIGINS=http://192.168.1.50:3000,https://my-domain.com
|
||||
+20
-1
@@ -2,13 +2,32 @@ FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
# Install Node.js (for AIS WebSocket proxy) and curl (for network fallback)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Install Node.js dependencies (ws module for AIS WebSocket proxy)
|
||||
# Copy manifests first so this layer is cached unless deps change
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Create a non-root user for security
|
||||
RUN adduser --system --uid 1001 backenduser \
|
||||
&& chown -R backenduser /app
|
||||
|
||||
# Switch to the non-root user
|
||||
USER backenduser
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const API_KEY = args[0] || '75cc39af03c9cc23c90e8a7b3c3bc2b2a507c5fb';
|
||||
const API_KEY = args[0] || process.env.AIS_API_KEY;
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error("FATAL: AIS_API_KEY is not set. WebSocket proxy cannot start.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const FILTER = [
|
||||
// US Aircraft Carriers and major naval groups
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
import re
|
||||
import csv
|
||||
import os
|
||||
|
||||
xlsx_path = r"f:\Codebase\Oracle\live-risk-dashboard\TheAirTraffic Database.xlsx"
|
||||
output_path = r"f:\Codebase\Oracle\live-risk-dashboard\backend\xlsx_analysis.txt"
|
||||
|
||||
def parse_xlsx_sheet(z, shared_strings, sheet_num):
|
||||
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
||||
sheet_file = f'xl/worksheets/sheet{sheet_num}.xml'
|
||||
if sheet_file not in z.namelist():
|
||||
return []
|
||||
ws_xml = z.read(sheet_file)
|
||||
ws_root = ET.fromstring(ws_xml)
|
||||
rows = []
|
||||
for row in ws_root.findall('.//s:sheetData/s:row', ns):
|
||||
cells = {}
|
||||
for cell in row.findall('s:c', ns):
|
||||
cell_ref = cell.get('r', '')
|
||||
cell_type = cell.get('t', '')
|
||||
val_elem = cell.find('s:v', ns)
|
||||
val = val_elem.text if val_elem is not None else ''
|
||||
if cell_type == 's' and val:
|
||||
val = shared_strings[int(val)]
|
||||
col = re.match(r'([A-Z]+)', cell_ref).group(1) if re.match(r'([A-Z]+)', cell_ref) else ''
|
||||
cells[col] = val
|
||||
rows.append(cells)
|
||||
return rows
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as out:
|
||||
with zipfile.ZipFile(xlsx_path, 'r') as z:
|
||||
shared_strings = []
|
||||
if 'xl/sharedStrings.xml' in z.namelist():
|
||||
ss_xml = z.read('xl/sharedStrings.xml')
|
||||
root = ET.fromstring(ss_xml)
|
||||
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
||||
for si in root.findall('.//s:si', ns):
|
||||
texts = si.findall('.//s:t', ns)
|
||||
val = ''.join(t.text or '' for t in texts)
|
||||
shared_strings.append(val)
|
||||
|
||||
all_entries = []
|
||||
for sheet_idx in range(1, 5):
|
||||
rows = parse_xlsx_sheet(z, shared_strings, sheet_idx)
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
out.write(f"\n=== SHEET {sheet_idx}: {len(rows)} rows ===\n")
|
||||
# Print first 5 rows
|
||||
for i in range(min(5, len(rows))):
|
||||
for col in sorted(rows[i].keys(), key=lambda x: (len(x), x)):
|
||||
val = rows[i][col]
|
||||
if val:
|
||||
out.write(f" Row{i} {col}: '{val[:80]}'\n")
|
||||
out.write("\n")
|
||||
|
||||
for r in rows[1:]:
|
||||
for col, val in r.items():
|
||||
val = str(val).strip()
|
||||
n_regs = re.findall(r'N\d{1,5}[A-Z]{0,2}', val)
|
||||
owner = r.get('B', r.get('A', '')).strip()
|
||||
aircraft_type = r.get('C', r.get('D', '')).strip()
|
||||
for reg in n_regs:
|
||||
all_entries.append({
|
||||
'registration': reg.upper(),
|
||||
'owner': owner,
|
||||
'type': aircraft_type,
|
||||
'sheet': sheet_idx
|
||||
})
|
||||
|
||||
unique_regs = set(e['registration'] for e in all_entries)
|
||||
out.write(f"\nTOTAL ENTRIES: {len(all_entries)}\n")
|
||||
out.write(f"UNIQUE REGISTRATIONS: {len(unique_regs)}\n")
|
||||
|
||||
csv_path = r"f:\Codebase\Oracle\live-risk-dashboard\PLANEALERTLIST\plane-alert-db-main\plane-alert-db.csv"
|
||||
existing = {}
|
||||
with open(csv_path, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
icao = row.get('$ICAO', '').strip().upper()
|
||||
reg = row.get('$Registration', '').strip().upper()
|
||||
if reg:
|
||||
existing[reg] = {
|
||||
'icao': icao,
|
||||
'category': row.get('Category', ''),
|
||||
'operator': row.get('$Operator', ''),
|
||||
}
|
||||
|
||||
already_in = unique_regs & set(existing.keys())
|
||||
missing = unique_regs - set(existing.keys())
|
||||
out.write(f"\nplane-alert-db: {len(existing)} registrations\n")
|
||||
out.write(f"Already covered: {len(already_in)}\n")
|
||||
out.write(f"MISSING: {len(missing)}\n")
|
||||
|
||||
out.write(f"\n--- ALREADY TRACKED ---\n")
|
||||
seen = set()
|
||||
for e in all_entries:
|
||||
if e['registration'] in already_in and e['registration'] not in seen:
|
||||
info = existing[e['registration']]
|
||||
out.write(f" {e['owner'][:40]:40s} {e['registration']:10s} DB_CAT: {info['category'][:25]:25s} DB_OP: {info['operator'][:40]}\n")
|
||||
seen.add(e['registration'])
|
||||
|
||||
out.write(f"\n--- MISSING (NEED TO ADD) ---\n")
|
||||
seen = set()
|
||||
for e in all_entries:
|
||||
if e['registration'] in missing and e['registration'] not in seen:
|
||||
out.write(f" {e['owner'][:40]:40s} {e['registration']:10s} TYPE: {e['type'][:30]}\n")
|
||||
seen.add(e['registration'])
|
||||
|
||||
print(f"Analysis written to {output_path}")
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"feeds": [
|
||||
{
|
||||
"name": "NPR",
|
||||
"url": "https://feeds.npr.org/1004/rss.xml",
|
||||
"weight": 4
|
||||
},
|
||||
{
|
||||
"name": "BBC",
|
||||
"url": "http://feeds.bbci.co.uk/news/world/rss.xml",
|
||||
"weight": 3
|
||||
},
|
||||
{
|
||||
"name": "AlJazeera",
|
||||
"url": "https://www.aljazeera.com/xml/rss/all.xml",
|
||||
"weight": 2
|
||||
},
|
||||
{
|
||||
"name": "NYT",
|
||||
"url": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml",
|
||||
"weight": 1
|
||||
},
|
||||
{
|
||||
"name": "GDACS",
|
||||
"url": "https://www.gdacs.org/xml/rss.xml",
|
||||
"weight": 5
|
||||
},
|
||||
{
|
||||
"name": "NHK",
|
||||
"url": "https://www3.nhk.or.jp/nhkworld/rss/world.xml",
|
||||
"weight": 3
|
||||
},
|
||||
{
|
||||
"name": "CNA",
|
||||
"url": "https://www.channelnewsasia.com/rssfeed/8395986",
|
||||
"weight": 3
|
||||
},
|
||||
{
|
||||
"name": "Mercopress",
|
||||
"url": "https://en.mercopress.com/rss/",
|
||||
"weight": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
430ac93c4f7c4fb5a3e596ec38e3b7794c731cc1
|
||||
@@ -0,0 +1 @@
|
||||
476b691be156eb4fe6a6ad80f882c1dbaded8c33
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
38a18cbbf1acbec5eb9266b809c28d31e2941c53
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
5c3b1c768973ca54e9a1befee8dc075f38e8cc56
|
||||
@@ -1 +0,0 @@
|
||||
2b64633521ffb6f06da36e19f5c8eb86979e2187
|
||||
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Geocode data center street addresses via Nominatim (OpenStreetMap).
|
||||
Rate limit: 1 request/second (Nominatim policy).
|
||||
Resumable: caches results in geocode_cache.json so interrupted runs can continue.
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Fix Windows console encoding + force unbuffered output
|
||||
if sys.platform == "win32":
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
# Force line-buffered stdout for detached processes
|
||||
class Unbuffered:
|
||||
def __init__(self, stream):
|
||||
self.stream = stream
|
||||
def write(self, data):
|
||||
self.stream.write(data)
|
||||
self.stream.flush()
|
||||
def writelines(self, datas):
|
||||
self.stream.writelines(datas)
|
||||
self.stream.flush()
|
||||
def __getattr__(self, attr):
|
||||
return getattr(self.stream, attr)
|
||||
|
||||
sys.stdout = Unbuffered(sys.stdout)
|
||||
|
||||
DATA_FILE = os.path.join(os.path.dirname(__file__), "data", "datacenters.json")
|
||||
CACHE_FILE = os.path.join(os.path.dirname(__file__), "data", "geocode_cache.json")
|
||||
OUTPUT_FILE = os.path.join(os.path.dirname(__file__), "data", "datacenters_geocoded.json")
|
||||
|
||||
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
|
||||
USER_AGENT = "ShadowBroker-DataCenterGeocoder/1.0"
|
||||
|
||||
|
||||
def geocode_address(address: str, retries: int = 3) -> tuple[float, float] | None:
|
||||
"""Geocode a single address via Nominatim. Returns (lat, lng) or None."""
|
||||
params = urllib.parse.urlencode({"q": address, "format": "json", "limit": 1})
|
||||
url = f"{NOMINATIM_URL}?{params}"
|
||||
for attempt in range(retries):
|
||||
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=15)
|
||||
data = json.loads(resp.read())
|
||||
if data:
|
||||
return float(data[0]["lat"]), float(data[0]["lon"])
|
||||
return None # Valid response but no results
|
||||
except Exception as e:
|
||||
if attempt < retries - 1:
|
||||
wait = 2 ** (attempt + 1)
|
||||
print(f" RETRY ({attempt+1}/{retries}): {e} — waiting {wait}s")
|
||||
time.sleep(wait)
|
||||
else:
|
||||
print(f" ERROR (gave up after {retries} attempts): {e}")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
with open(DATA_FILE, "r", encoding="utf-8") as f:
|
||||
dcs = json.load(f)
|
||||
|
||||
# Load cache
|
||||
cache = {}
|
||||
if os.path.exists(CACHE_FILE):
|
||||
with open(CACHE_FILE, "r", encoding="utf-8") as f:
|
||||
cache = json.load(f)
|
||||
print(f"Loaded {len(cache)} cached geocode results")
|
||||
|
||||
# Filter to DCs with real street addresses
|
||||
to_geocode = []
|
||||
skipped = 0
|
||||
for i, dc in enumerate(dcs):
|
||||
street = (dc.get("street") or "").strip()
|
||||
if not street or len(street) <= 3 or street.lower() in ("tbc", "n/a", "na", "-"):
|
||||
skipped += 1
|
||||
continue
|
||||
to_geocode.append((i, dc))
|
||||
|
||||
print(f"Total DCs: {len(dcs)}")
|
||||
print(f"Skipped (no real address): {skipped}")
|
||||
print(f"To geocode: {len(to_geocode)}")
|
||||
|
||||
# Count how many already cached
|
||||
already_cached = sum(1 for _, dc in to_geocode if dc.get("address", "") in cache)
|
||||
need_api = len(to_geocode) - already_cached
|
||||
print(f"Already cached: {already_cached}")
|
||||
print(f"Need API calls: {need_api}")
|
||||
if need_api > 0:
|
||||
print(f"Estimated time: {need_api // 60}m {need_api % 60}s")
|
||||
print()
|
||||
|
||||
geocoded = 0
|
||||
failed = 0
|
||||
api_calls = 0
|
||||
save_interval = 50 # Save cache every 50 API calls
|
||||
|
||||
for idx, (i, dc) in enumerate(to_geocode):
|
||||
address = dc.get("address", "").strip()
|
||||
if not address:
|
||||
# Build address from parts
|
||||
parts = [dc.get("street", ""), dc.get("zip", ""), dc.get("city", ""), dc.get("country", "")]
|
||||
address = " ".join(p.strip() for p in parts if p and p.strip())
|
||||
|
||||
if not address:
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
# Check cache first
|
||||
if address in cache:
|
||||
result = cache[address]
|
||||
if result:
|
||||
dcs[i]["lat"] = result[0]
|
||||
dcs[i]["lng"] = result[1]
|
||||
dcs[i]["geocode_source"] = "nominatim"
|
||||
geocoded += 1
|
||||
else:
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
# API call — Nominatim requires 1 req/s, use 1.5s to avoid 429s after heavy use
|
||||
time.sleep(1.5)
|
||||
coords = geocode_address(address)
|
||||
api_calls += 1
|
||||
|
||||
if coords:
|
||||
cache[address] = coords
|
||||
dcs[i]["lat"] = coords[0]
|
||||
dcs[i]["lng"] = coords[1]
|
||||
dcs[i]["geocode_source"] = "nominatim"
|
||||
geocoded += 1
|
||||
print(f"[{api_calls}/{need_api}] OK: {dc.get('name', '?')} -> ({coords[0]:.4f}, {coords[1]:.4f})")
|
||||
else:
|
||||
cache[address] = None
|
||||
failed += 1
|
||||
print(f"[{api_calls}/{need_api}] FAIL: {dc.get('name', '?')} | {address}")
|
||||
|
||||
# Periodic cache save
|
||||
if api_calls % save_interval == 0:
|
||||
with open(CACHE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(cache, f)
|
||||
print(f" -- Cache saved ({len(cache)} entries) --")
|
||||
|
||||
# Final save
|
||||
with open(CACHE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(cache, f)
|
||||
|
||||
# Write output - only DCs with real coordinates
|
||||
output = [dc for dc in dcs if dc.get("lat") is not None and dc.get("lng") is not None]
|
||||
|
||||
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(output, f, indent=2)
|
||||
|
||||
print(f"\nDone!")
|
||||
print(f"Geocoded: {geocoded}")
|
||||
print(f"Failed: {failed}")
|
||||
print(f"API calls made: {api_calls}")
|
||||
print(f"Output: {len(output)} DCs with coordinates -> {OUTPUT_FILE}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because one or more lines are too long
+154
-34
@@ -1,15 +1,76 @@
|
||||
import os
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Docker Swarm Secrets support
|
||||
# For each VAR below, if VAR_FILE is set (e.g. AIS_API_KEY_FILE=/run/secrets/AIS_API_KEY),
|
||||
# the file is read and its trimmed content is placed into VAR.
|
||||
# This MUST run before service imports — modules read os.environ at import time.
|
||||
# ---------------------------------------------------------------------------
|
||||
_SECRET_VARS = [
|
||||
"AIS_API_KEY",
|
||||
"OPENSKY_CLIENT_ID",
|
||||
"OPENSKY_CLIENT_SECRET",
|
||||
"LTA_ACCOUNT_KEY",
|
||||
"CORS_ORIGINS",
|
||||
]
|
||||
|
||||
for _var in _SECRET_VARS:
|
||||
_file_var = f"{_var}_FILE"
|
||||
_file_path = os.environ.get(_file_var)
|
||||
if _file_path:
|
||||
try:
|
||||
with open(_file_path, "r") as _f:
|
||||
_value = _f.read().strip()
|
||||
if _value:
|
||||
os.environ[_var] = _value
|
||||
logger.info(f"Loaded secret {_var} from {_file_path}")
|
||||
else:
|
||||
logger.warning(f"Secret file {_file_path} for {_var} is empty")
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Secret file {_file_path} for {_var} not found")
|
||||
except Exception as _e:
|
||||
logger.error(f"Failed to read secret file {_file_path} for {_var}: {_e}")
|
||||
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from services.data_fetcher import start_scheduler, stop_scheduler, get_latest_data
|
||||
from services.data_fetcher import start_scheduler, stop_scheduler, get_latest_data, source_timestamps
|
||||
from services.ais_stream import start_ais_stream, stop_ais_stream
|
||||
from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker
|
||||
import uvicorn
|
||||
import logging
|
||||
import hashlib
|
||||
import json as json_mod
|
||||
import socket
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
def _build_cors_origins():
|
||||
"""Build a CORS origins whitelist: localhost + LAN IPs + env overrides.
|
||||
Falls back to wildcard only if auto-detection fails entirely."""
|
||||
origins = [
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost:8000",
|
||||
"http://127.0.0.1:8000",
|
||||
]
|
||||
# Add this machine's LAN IPs (covers common home/office setups)
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
for info in socket.getaddrinfo(hostname, None, socket.AF_INET):
|
||||
ip = info[4][0]
|
||||
if ip not in ("127.0.0.1", "0.0.0.0"):
|
||||
origins.append(f"http://{ip}:3000")
|
||||
origins.append(f"http://{ip}:8000")
|
||||
except Exception:
|
||||
pass
|
||||
# Allow user override via CORS_ORIGINS env var (comma-separated)
|
||||
extra = os.environ.get("CORS_ORIGINS", "")
|
||||
if extra:
|
||||
origins.extend([o.strip() for o in extra.split(",") if o.strip()])
|
||||
return list(set(origins)) # deduplicate
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -29,7 +90,7 @@ from fastapi.middleware.gzip import GZipMiddleware
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # For prototyping, allow all
|
||||
allow_origins=_build_cors_origins(),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
@@ -49,6 +110,15 @@ async def force_refresh():
|
||||
async def live_data():
|
||||
return get_latest_data()
|
||||
|
||||
def _etag_response(request: Request, payload: dict, prefix: str = "", default=None):
|
||||
"""Serialize once, hash the bytes for ETag, return 304 or full response."""
|
||||
content = json_mod.dumps(payload, default=default)
|
||||
etag = hashlib.md5(f"{prefix}{content[:256]}".encode()).hexdigest()[:16]
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
return Response(content=content, media_type="application/json",
|
||||
headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
|
||||
@app.get("/api/live-data/fast")
|
||||
async def live_data_fast(request: Request):
|
||||
d = get_latest_data()
|
||||
@@ -63,19 +133,11 @@ async def live_data_fast(request: Request):
|
||||
"uavs": d.get("uavs", []),
|
||||
"liveuamap": d.get("liveuamap", []),
|
||||
"gps_jamming": d.get("gps_jamming", []),
|
||||
"satellites": d.get("satellites", []),
|
||||
"satellite_source": d.get("satellite_source", "none"),
|
||||
"freshness": dict(source_timestamps),
|
||||
}
|
||||
# ETag includes last_updated timestamp so it changes on every data refresh,
|
||||
# not just when item counts change (old bug: positions went stale)
|
||||
last_updated = d.get("last_updated", "")
|
||||
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items())
|
||||
etag = hashlib.md5(f"{last_updated}|{counts}".encode()).hexdigest()[:16]
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
return Response(
|
||||
content=json_mod.dumps(payload),
|
||||
media_type="application/json",
|
||||
headers={"ETag": etag, "Cache-Control": "no-cache"}
|
||||
)
|
||||
return _etag_response(request, payload, prefix="fast|")
|
||||
|
||||
@app.get("/api/live-data/slow")
|
||||
async def live_data_slow(request: Request):
|
||||
@@ -91,19 +153,15 @@ async def live_data_slow(request: Request):
|
||||
"frontlines": d.get("frontlines"),
|
||||
"gdelt": d.get("gdelt", []),
|
||||
"airports": d.get("airports", []),
|
||||
"satellites": d.get("satellites", [])
|
||||
"satellites": d.get("satellites", []),
|
||||
"kiwisdr": d.get("kiwisdr", []),
|
||||
"space_weather": d.get("space_weather"),
|
||||
"internet_outages": d.get("internet_outages", []),
|
||||
"firms_fires": d.get("firms_fires", []),
|
||||
"datacenters": d.get("datacenters", []),
|
||||
"freshness": dict(source_timestamps),
|
||||
}
|
||||
# ETag based on last_updated + item counts
|
||||
last_updated = d.get("last_updated", "")
|
||||
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items())
|
||||
etag = hashlib.md5(f"slow|{last_updated}|{counts}".encode()).hexdigest()[:16]
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
return Response(
|
||||
content=json_mod.dumps(payload, default=str),
|
||||
media_type="application/json",
|
||||
headers={"ETag": etag, "Cache-Control": "no-cache"}
|
||||
)
|
||||
return _etag_response(request, payload, prefix="slow|", default=str)
|
||||
|
||||
@app.get("/api/debug-latest")
|
||||
async def debug_latest_data():
|
||||
@@ -112,7 +170,30 @@ async def debug_latest_data():
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
return {"status": "ok"}
|
||||
import time
|
||||
d = get_latest_data()
|
||||
last = d.get("last_updated")
|
||||
return {
|
||||
"status": "ok",
|
||||
"last_updated": last,
|
||||
"sources": {
|
||||
"flights": len(d.get("commercial_flights", [])),
|
||||
"military": len(d.get("military_flights", [])),
|
||||
"ships": len(d.get("ships", [])),
|
||||
"satellites": len(d.get("satellites", [])),
|
||||
"earthquakes": len(d.get("earthquakes", [])),
|
||||
"cctv": len(d.get("cctv", [])),
|
||||
"news": len(d.get("news", [])),
|
||||
"uavs": len(d.get("uavs", [])),
|
||||
"firms_fires": len(d.get("firms_fires", [])),
|
||||
"liveuamap": len(d.get("liveuamap", [])),
|
||||
"gdelt": len(d.get("gdelt", [])),
|
||||
},
|
||||
"freshness": dict(source_timestamps),
|
||||
"uptime_seconds": round(time.time() - _start_time),
|
||||
}
|
||||
|
||||
_start_time = __import__("time").time()
|
||||
|
||||
from services.radio_intercept import get_top_broadcastify_feeds, get_openmhz_systems, get_recent_openmhz_calls, find_nearest_openmhz_system
|
||||
|
||||
@@ -141,9 +222,9 @@ async def api_get_nearest_radios_list(lat: float, lng: float, limit: int = 5):
|
||||
from services.network_utils import fetch_with_curl
|
||||
|
||||
@app.get("/api/route/{callsign}")
|
||||
async def get_flight_route(callsign: str):
|
||||
r = fetch_with_curl("https://api.adsb.lol/api/0/routeset", method="POST", json_data={"planes": [{"callsign": callsign}]}, timeout=10)
|
||||
if r.status_code == 200:
|
||||
async def get_flight_route(callsign: str, lat: float = 0.0, lng: float = 0.0):
|
||||
r = fetch_with_curl("https://api.adsb.lol/api/0/routeset", method="POST", json_data={"planes": [{"callsign": callsign, "lat": lat, "lng": lng}]}, timeout=10)
|
||||
if r and r.status_code == 200:
|
||||
data = r.json()
|
||||
route_list = []
|
||||
if isinstance(data, dict):
|
||||
@@ -155,9 +236,13 @@ async def get_flight_route(callsign: str):
|
||||
route = route_list[0]
|
||||
airports = route.get("_airports", [])
|
||||
if len(airports) >= 2:
|
||||
orig = airports[0]
|
||||
dest = airports[-1]
|
||||
return {
|
||||
"orig_loc": [airports[0].get("lon", 0), airports[0].get("lat", 0)],
|
||||
"dest_loc": [airports[-1].get("lon", 0), airports[-1].get("lat", 0)]
|
||||
"orig_loc": [orig.get("lon", 0), orig.get("lat", 0)],
|
||||
"dest_loc": [dest.get("lon", 0), dest.get("lat", 0)],
|
||||
"origin_name": f"{orig.get('iata', '') or orig.get('icao', '')}: {orig.get('name', 'Unknown')}",
|
||||
"dest_name": f"{dest.get('iata', '') or dest.get('icao', '')}: {dest.get('name', 'Unknown')}",
|
||||
}
|
||||
return {}
|
||||
|
||||
@@ -168,6 +253,13 @@ def api_region_dossier(lat: float, lng: float):
|
||||
"""Sync def so FastAPI runs it in a threadpool — prevents blocking the event loop."""
|
||||
return get_region_dossier(lat, lng)
|
||||
|
||||
from services.sentinel_search import search_sentinel2_scene
|
||||
|
||||
@app.get("/api/sentinel2/search")
|
||||
def api_sentinel2_search(lat: float, lng: float):
|
||||
"""Search for latest Sentinel-2 imagery at a point. Sync for threadpool execution."""
|
||||
return search_sentinel2_scene(lat, lng)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API Settings — key registry & management
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -189,6 +281,34 @@ async def api_update_key(body: ApiKeyUpdate):
|
||||
return {"status": "updated", "env_key": body.env_key}
|
||||
return {"status": "error", "message": "Failed to update .env file"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# News Feed Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
from services.news_feed_config import get_feeds, save_feeds, reset_feeds
|
||||
|
||||
@app.get("/api/settings/news-feeds")
|
||||
async def api_get_news_feeds():
|
||||
return get_feeds()
|
||||
|
||||
@app.put("/api/settings/news-feeds")
|
||||
async def api_save_news_feeds(request: Request):
|
||||
body = await request.json()
|
||||
ok = save_feeds(body)
|
||||
if ok:
|
||||
return {"status": "updated", "count": len(body)}
|
||||
return Response(
|
||||
content=json_mod.dumps({"status": "error", "message": "Validation failed (max 20 feeds, each needs name/url/weight 1-5)"}),
|
||||
status_code=400,
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
@app.post("/api/settings/news-feeds/reset")
|
||||
async def api_reset_news_feeds():
|
||||
ok = reset_feeds()
|
||||
if ok:
|
||||
return {"status": "reset", "feeds": get_feeds()}
|
||||
return {"status": "error", "message": "Failed to reset feeds"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,20 @@
|
||||
fastapi==0.103.1
|
||||
uvicorn==0.23.2
|
||||
fastapi>=0.103.1
|
||||
uvicorn>=0.23.2
|
||||
yfinance>=0.2.40
|
||||
feedparser==6.0.10
|
||||
legacy-cgi>=2.6
|
||||
requests==2.31.0
|
||||
apscheduler==3.10.3
|
||||
pydantic==2.3.0
|
||||
pydantic-settings==2.0.3
|
||||
pydantic>=2.3.0
|
||||
pydantic-settings>=2.0.3
|
||||
playwright>=1.58.0
|
||||
beautifulsoup4>=4.12.0
|
||||
cachetools>=5.3
|
||||
cloudscraper>=1.2.71
|
||||
python-dotenv>=1.0
|
||||
lxml>=5.0
|
||||
reverse_geocoder>=1.5
|
||||
sgp4>=2.23
|
||||
geopy>=2.4.0
|
||||
pytz>=2023.3
|
||||
pystac-client>=0.7.0
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
5d33551b09405e7e252c6a11f080a6c9eca50f6b
|
||||
@@ -14,7 +14,7 @@ import os
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AIS_WS_URL = "wss://stream.aisstream.io/v0/stream"
|
||||
API_KEY = os.environ.get("AIS_API_KEY", "75cc39af03c9cc23c90e8a7b3c3bc2b2a507c5fb")
|
||||
API_KEY = os.environ.get("AIS_API_KEY", "")
|
||||
|
||||
# AIS vessel type code classification
|
||||
# See: https://coast.noaa.gov/data/marinecadastre/ais/VesselTypeCodes2018.pdf
|
||||
@@ -211,9 +211,10 @@ def _ais_stream_loop():
|
||||
"""Main loop: spawn node proxy and process messages from stdout."""
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
|
||||
proxy_script = os.path.join(os.path.dirname(os.path.dirname(__file__)), "ais_proxy.js")
|
||||
|
||||
backoff = 1 # Exponential backoff starting at 1 second
|
||||
|
||||
while _ws_running:
|
||||
try:
|
||||
logger.info("Starting Node.js AIS Stream Proxy...")
|
||||
@@ -237,49 +238,51 @@ def _ais_stream_loop():
|
||||
logger.info("AIS Stream proxy started — receiving vessel data")
|
||||
|
||||
msg_count = 0
|
||||
ok_streak = 0 # Track consecutive successful messages for backoff reset
|
||||
last_log_time = time.time()
|
||||
for raw_msg in iter(process.stdout.readline, ''):
|
||||
if not _ws_running:
|
||||
process.terminate()
|
||||
break
|
||||
|
||||
|
||||
raw_msg = raw_msg.strip()
|
||||
if not raw_msg:
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
data = json.loads(raw_msg)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
|
||||
if "error" in data:
|
||||
logger.error(f"AIS Stream error: {data['error']}")
|
||||
continue
|
||||
|
||||
|
||||
msg_type = data.get("MessageType", "")
|
||||
metadata = data.get("MetaData", {})
|
||||
message = data.get("Message", {})
|
||||
|
||||
|
||||
mmsi = metadata.get("MMSI", 0)
|
||||
if not mmsi:
|
||||
continue
|
||||
|
||||
|
||||
with _vessels_lock:
|
||||
if mmsi not in _vessels:
|
||||
_vessels[mmsi] = {"_updated": time.time()}
|
||||
vessel = _vessels[mmsi]
|
||||
|
||||
|
||||
# Update position from PositionReport or StandardClassBPositionReport
|
||||
if msg_type in ("PositionReport", "StandardClassBPositionReport"):
|
||||
report = message.get(msg_type, {})
|
||||
lat = report.get("Latitude", metadata.get("latitude", 0))
|
||||
lng = report.get("Longitude", metadata.get("longitude", 0))
|
||||
|
||||
|
||||
# Skip invalid positions
|
||||
if lat == 0 and lng == 0:
|
||||
continue
|
||||
if abs(lat) > 90 or abs(lng) > 180:
|
||||
continue
|
||||
|
||||
|
||||
with _vessels_lock:
|
||||
vessel["lat"] = lat
|
||||
vessel["lng"] = lng
|
||||
@@ -291,12 +294,12 @@ def _ais_stream_loop():
|
||||
# Use metadata name if we don't have one yet
|
||||
if not vessel.get("name") or vessel["name"] == "UNKNOWN":
|
||||
vessel["name"] = metadata.get("ShipName", "UNKNOWN").strip() or "UNKNOWN"
|
||||
|
||||
|
||||
# Update static data from ShipStaticData
|
||||
elif msg_type == "ShipStaticData":
|
||||
static = message.get("ShipStaticData", {})
|
||||
ais_type = static.get("Type", 0)
|
||||
|
||||
|
||||
with _vessels_lock:
|
||||
vessel["name"] = (static.get("Name", "") or metadata.get("ShipName", "UNKNOWN")).strip() or "UNKNOWN"
|
||||
vessel["callsign"] = (static.get("CallSign", "") or "").strip()
|
||||
@@ -305,26 +308,31 @@ def _ais_stream_loop():
|
||||
vessel["ais_type_code"] = ais_type
|
||||
vessel["type"] = classify_vessel(ais_type, mmsi)
|
||||
vessel["_updated"] = time.time()
|
||||
|
||||
|
||||
msg_count += 1
|
||||
if msg_count % 5000 == 0:
|
||||
ok_streak += 1
|
||||
|
||||
# Reset backoff after 200 consecutive successful messages
|
||||
if ok_streak >= 200 and backoff > 1:
|
||||
backoff = 1
|
||||
ok_streak = 0
|
||||
|
||||
# Periodic logging + cache save (time-based instead of count-based to avoid lock in hot loop)
|
||||
now = time.time()
|
||||
if now - last_log_time >= 60:
|
||||
with _vessels_lock:
|
||||
# Inline pruning: remove vessels not updated in 15 minutes
|
||||
prune_cutoff = time.time() - 900
|
||||
stale = [k for k, v in _vessels.items() if v.get("_updated", 0) < prune_cutoff]
|
||||
for k in stale:
|
||||
del _vessels[k]
|
||||
count = len(_vessels)
|
||||
if stale:
|
||||
logger.info(f"AIS pruned {len(stale)} stale vessels")
|
||||
logger.info(f"AIS Stream: processed {msg_count} messages, tracking {count} vessels")
|
||||
_save_cache() # Auto-save every 5000 messages (~60 seconds)
|
||||
|
||||
_save_cache()
|
||||
last_log_time = now
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AIS proxy connection error: {e}")
|
||||
if _ws_running:
|
||||
logger.info("Restarting AIS proxy in 5 seconds...")
|
||||
time.sleep(5)
|
||||
logger.info(f"Restarting AIS proxy in {backoff}s (exponential backoff)...")
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, 60) # Double up to 60s max
|
||||
continue
|
||||
|
||||
|
||||
def _run_ais_loop():
|
||||
|
||||
@@ -145,20 +145,29 @@ def get_api_keys():
|
||||
"has_key": api["env_key"] is not None,
|
||||
"env_key": api["env_key"],
|
||||
"value_obfuscated": None,
|
||||
"value_plain": None,
|
||||
"is_set": False,
|
||||
}
|
||||
if api["env_key"]:
|
||||
raw = os.environ.get(api["env_key"], "")
|
||||
entry["value_obfuscated"] = _obfuscate(raw)
|
||||
entry["value_plain"] = raw # Sent only when reveal is requested
|
||||
entry["is_set"] = bool(raw)
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
def update_api_key(env_key: str, new_value: str) -> bool:
|
||||
"""Update a single key in the .env file and in the current process env."""
|
||||
if not ENV_PATH.exists():
|
||||
valid_keys = {api["env_key"] for api in API_REGISTRY if api.get("env_key")}
|
||||
if env_key not in valid_keys:
|
||||
return False
|
||||
|
||||
if not isinstance(new_value, str):
|
||||
return False
|
||||
if "\n" in new_value or "\r" in new_value:
|
||||
return False
|
||||
|
||||
if not ENV_PATH.exists():
|
||||
ENV_PATH.write_text("", encoding="utf-8")
|
||||
|
||||
# Update os.environ immediately
|
||||
os.environ[env_key] = new_value
|
||||
|
||||
@@ -26,104 +26,116 @@ logger = logging.getLogger(__name__)
|
||||
# Carrier registry: hull number → metadata + fallback position
|
||||
# -----------------------------------------------------------------
|
||||
CARRIER_REGISTRY: Dict[str, dict] = {
|
||||
# Fallback positions sourced from USNI News Fleet & Marine Tracker (Mar 9, 2026)
|
||||
# https://news.usni.org/2026/03/09/usni-news-fleet-and-marine-tracker-march-9-2026
|
||||
# --- Bremerton, WA (Naval Base Kitsap) ---
|
||||
# Distinct pier positions along Sinclair Inlet so carriers don't stack
|
||||
"CVN-68": {
|
||||
"name": "USS Nimitz (CVN-68)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Nimitz",
|
||||
"homeport": "Bremerton, WA",
|
||||
"homeport_lat": 47.56, "homeport_lng": -122.63,
|
||||
"fallback_lat": 21.35, "fallback_lng": -157.95,
|
||||
"fallback_heading": 270,
|
||||
"fallback_desc": "Pacific Fleet / Pearl Harbor"
|
||||
},
|
||||
"CVN-69": {
|
||||
"name": "USS Dwight D. Eisenhower (CVN-69)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Dwight_D._Eisenhower",
|
||||
"homeport": "Norfolk, VA",
|
||||
"homeport_lat": 36.95, "homeport_lng": -76.33,
|
||||
"fallback_lat": 18.0, "fallback_lng": 39.5,
|
||||
"fallback_heading": 120,
|
||||
"fallback_desc": "Red Sea / CENTCOM AOR"
|
||||
},
|
||||
"CVN-78": {
|
||||
"name": "USS Gerald R. Ford (CVN-78)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Gerald_R._Ford",
|
||||
"homeport": "Norfolk, VA",
|
||||
"homeport_lat": 36.95, "homeport_lng": -76.33,
|
||||
"fallback_lat": 34.0, "fallback_lng": 25.0,
|
||||
"homeport_lat": 47.5535, "homeport_lng": -122.6400,
|
||||
"fallback_lat": 47.5535, "fallback_lng": -122.6400,
|
||||
"fallback_heading": 90,
|
||||
"fallback_desc": "Eastern Mediterranean deterrence"
|
||||
},
|
||||
"CVN-70": {
|
||||
"name": "USS Carl Vinson (CVN-70)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Carl_Vinson",
|
||||
"homeport": "San Diego, CA",
|
||||
"homeport_lat": 32.68, "homeport_lng": -117.15,
|
||||
"fallback_lat": 15.0, "fallback_lng": 115.0,
|
||||
"fallback_heading": 45,
|
||||
"fallback_desc": "South China Sea patrol"
|
||||
},
|
||||
"CVN-71": {
|
||||
"name": "USS Theodore Roosevelt (CVN-71)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Theodore_Roosevelt_(CVN-71)",
|
||||
"homeport": "San Diego, CA",
|
||||
"homeport_lat": 32.68, "homeport_lng": -117.15,
|
||||
"fallback_lat": 22.0, "fallback_lng": 122.0,
|
||||
"fallback_heading": 300,
|
||||
"fallback_desc": "Philippine Sea / Taiwan Strait"
|
||||
},
|
||||
"CVN-72": {
|
||||
"name": "USS Abraham Lincoln (CVN-72)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Abraham_Lincoln_(CVN-72)",
|
||||
"homeport": "San Diego, CA",
|
||||
"homeport_lat": 32.68, "homeport_lng": -117.15,
|
||||
"fallback_lat": 21.0, "fallback_lng": -158.0,
|
||||
"fallback_heading": 270,
|
||||
"fallback_desc": "Pacific deployment"
|
||||
},
|
||||
"CVN-73": {
|
||||
"name": "USS George Washington (CVN-73)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_George_Washington_(CVN-73)",
|
||||
"homeport": "Yokosuka, Japan",
|
||||
"homeport_lat": 35.28, "homeport_lng": 139.67,
|
||||
"fallback_lat": 35.0, "fallback_lng": 139.0,
|
||||
"fallback_heading": 0,
|
||||
"fallback_desc": "Yokosuka, Japan (Forward deployed)"
|
||||
},
|
||||
"CVN-74": {
|
||||
"name": "USS John C. Stennis (CVN-74)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_John_C._Stennis",
|
||||
"homeport": "Norfolk, VA",
|
||||
"homeport_lat": 36.95, "homeport_lng": -76.33,
|
||||
"fallback_lat": 36.95, "fallback_lng": -76.33,
|
||||
"fallback_heading": 0,
|
||||
"fallback_desc": "RCOH / Norfolk (maintenance)"
|
||||
},
|
||||
"CVN-75": {
|
||||
"name": "USS Harry S. Truman (CVN-75)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Harry_S._Truman",
|
||||
"homeport": "Norfolk, VA",
|
||||
"homeport_lat": 36.95, "homeport_lng": -76.33,
|
||||
"fallback_lat": 36.0, "fallback_lng": 15.0,
|
||||
"fallback_heading": 90,
|
||||
"fallback_desc": "Mediterranean deployment"
|
||||
"fallback_desc": "Bremerton, WA (Maintenance)"
|
||||
},
|
||||
"CVN-76": {
|
||||
"name": "USS Ronald Reagan (CVN-76)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Ronald_Reagan",
|
||||
"homeport": "Bremerton, WA",
|
||||
"homeport_lat": 47.56, "homeport_lng": -122.63,
|
||||
"fallback_lat": 47.56, "fallback_lng": -122.63,
|
||||
"homeport_lat": 47.5580, "homeport_lng": -122.6360,
|
||||
"fallback_lat": 47.5580, "fallback_lng": -122.6360,
|
||||
"fallback_heading": 90,
|
||||
"fallback_desc": "Bremerton, WA (Decommissioning)"
|
||||
},
|
||||
|
||||
# --- Norfolk, VA (Naval Station Norfolk) ---
|
||||
# Piers run N-S along Willoughby Bay; each carrier gets a distinct berth
|
||||
"CVN-69": {
|
||||
"name": "USS Dwight D. Eisenhower (CVN-69)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Dwight_D._Eisenhower",
|
||||
"homeport": "Norfolk, VA",
|
||||
"homeport_lat": 36.9465, "homeport_lng": -76.3265,
|
||||
"fallback_lat": 36.9465, "fallback_lng": -76.3265,
|
||||
"fallback_heading": 0,
|
||||
"fallback_desc": "Bremerton, WA (Homeport)"
|
||||
"fallback_desc": "Norfolk, VA (Post-deployment maintenance)"
|
||||
},
|
||||
"CVN-78": {
|
||||
"name": "USS Gerald R. Ford (CVN-78)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Gerald_R._Ford",
|
||||
"homeport": "Norfolk, VA",
|
||||
"homeport_lat": 36.9505, "homeport_lng": -76.3250,
|
||||
"fallback_lat": 18.0, "fallback_lng": 39.5,
|
||||
"fallback_heading": 0,
|
||||
"fallback_desc": "Red Sea — Operation Epic Fury (USNI Mar 9)"
|
||||
},
|
||||
"CVN-74": {
|
||||
"name": "USS John C. Stennis (CVN-74)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_John_C._Stennis",
|
||||
"homeport": "Norfolk, VA",
|
||||
"homeport_lat": 36.9540, "homeport_lng": -76.3235,
|
||||
"fallback_lat": 36.98, "fallback_lng": -76.43,
|
||||
"fallback_heading": 0,
|
||||
"fallback_desc": "Newport News, VA (RCOH refueling overhaul)"
|
||||
},
|
||||
"CVN-75": {
|
||||
"name": "USS Harry S. Truman (CVN-75)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Harry_S._Truman",
|
||||
"homeport": "Norfolk, VA",
|
||||
"homeport_lat": 36.9580, "homeport_lng": -76.3220,
|
||||
"fallback_lat": 36.0, "fallback_lng": 15.0,
|
||||
"fallback_heading": 0,
|
||||
"fallback_desc": "Mediterranean Sea deployment (USNI Mar 9)"
|
||||
},
|
||||
"CVN-77": {
|
||||
"name": "USS George H.W. Bush (CVN-77)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_George_H.W._Bush",
|
||||
"homeport": "Norfolk, VA",
|
||||
"homeport_lat": 36.95, "homeport_lng": -76.33,
|
||||
"fallback_lat": 36.95, "fallback_lng": -76.33,
|
||||
"homeport_lat": 36.9620, "homeport_lng": -76.3210,
|
||||
"fallback_lat": 36.5, "fallback_lng": -74.0,
|
||||
"fallback_heading": 0,
|
||||
"fallback_desc": "Norfolk, VA (Homeport)"
|
||||
"fallback_desc": "Atlantic — Pre-deployment workups (USNI Mar 9)"
|
||||
},
|
||||
|
||||
# --- San Diego, CA (Naval Base San Diego) ---
|
||||
# Carrier piers along the east shore of San Diego Bay, spread N-S
|
||||
"CVN-70": {
|
||||
"name": "USS Carl Vinson (CVN-70)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Carl_Vinson",
|
||||
"homeport": "San Diego, CA",
|
||||
"homeport_lat": 32.6840, "homeport_lng": -117.1290,
|
||||
"fallback_lat": 32.6840, "fallback_lng": -117.1290,
|
||||
"fallback_heading": 180,
|
||||
"fallback_desc": "San Diego, CA (Homeport)"
|
||||
},
|
||||
"CVN-71": {
|
||||
"name": "USS Theodore Roosevelt (CVN-71)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Theodore_Roosevelt_(CVN-71)",
|
||||
"homeport": "San Diego, CA",
|
||||
"homeport_lat": 32.6885, "homeport_lng": -117.1280,
|
||||
"fallback_lat": 32.6885, "fallback_lng": -117.1280,
|
||||
"fallback_heading": 180,
|
||||
"fallback_desc": "San Diego, CA (Maintenance)"
|
||||
},
|
||||
"CVN-72": {
|
||||
"name": "USS Abraham Lincoln (CVN-72)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Abraham_Lincoln_(CVN-72)",
|
||||
"homeport": "San Diego, CA",
|
||||
"homeport_lat": 32.6925, "homeport_lng": -117.1275,
|
||||
"fallback_lat": 20.0, "fallback_lng": 64.0,
|
||||
"fallback_heading": 0,
|
||||
"fallback_desc": "Arabian Sea — Operation Epic Fury (USNI Mar 9)"
|
||||
},
|
||||
|
||||
# --- Yokosuka, Japan (CFAY) ---
|
||||
"CVN-73": {
|
||||
"name": "USS George Washington (CVN-73)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_George_Washington_(CVN-73)",
|
||||
"homeport": "Yokosuka, Japan",
|
||||
"homeport_lat": 35.2830, "homeport_lng": 139.6700,
|
||||
"fallback_lat": 35.2830, "fallback_lng": 139.6700,
|
||||
"fallback_heading": 180,
|
||||
"fallback_desc": "Yokosuka, Japan (Forward deployed)"
|
||||
},
|
||||
}
|
||||
|
||||
@@ -302,7 +314,8 @@ def _parse_carrier_positions_from_news(articles: List[dict]) -> Dict[str, dict]:
|
||||
"lat": coords[0],
|
||||
"lng": coords[1],
|
||||
"desc": title[:100],
|
||||
"source": "GDELT OSINT",
|
||||
"source": "GDELT News API",
|
||||
"source_url": article.get("url", "https://api.gdeltproject.org"),
|
||||
"updated": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
logger.info(f"Carrier update: {CARRIER_REGISTRY[hull]['name']} → {coords} (from: {title[:80]})")
|
||||
@@ -316,7 +329,7 @@ def update_carrier_positions():
|
||||
|
||||
logger.info("Carrier tracker: updating positions from OSINT sources...")
|
||||
|
||||
# Start with fallback positions
|
||||
# Start with fallback positions (sourced from USNI News Fleet Tracker)
|
||||
positions: Dict[str, dict] = {}
|
||||
for hull, info in CARRIER_REGISTRY.items():
|
||||
positions[hull] = {
|
||||
@@ -326,7 +339,8 @@ def update_carrier_positions():
|
||||
"heading": info["fallback_heading"],
|
||||
"desc": info["fallback_desc"],
|
||||
"wiki": info["wiki"],
|
||||
"source": "Static OSINT estimate",
|
||||
"source": "USNI News Fleet & Marine Tracker",
|
||||
"source_url": "https://news.usni.org/category/fleet-tracker",
|
||||
"updated": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
|
||||
@@ -370,6 +384,55 @@ def update_carrier_positions():
|
||||
logger.info(f"Carrier tracker: {len(positions)} carriers updated. Sources: {sources}")
|
||||
|
||||
|
||||
def _deconflict_positions(result: List[dict]) -> List[dict]:
|
||||
"""Offset carriers that share identical coordinates so they don't stack.
|
||||
|
||||
At port: offset along the pier axis (~500m / 0.004° apart).
|
||||
At sea: offset perpendicular to each other (~0.08° / ~9km apart)
|
||||
so they're visibly separate but clearly operating together.
|
||||
"""
|
||||
# Group by rounded lat/lng (within ~0.01° ≈ 1km = same spot)
|
||||
from collections import defaultdict
|
||||
groups: dict[str, list[int]] = defaultdict(list)
|
||||
for i, c in enumerate(result):
|
||||
key = f"{round(c['lat'], 2)},{round(c['lng'], 2)}"
|
||||
groups[key].append(i)
|
||||
|
||||
for indices in groups.values():
|
||||
if len(indices) < 2:
|
||||
continue
|
||||
n = len(indices)
|
||||
# Determine if this is a port (near a homeport) or at sea
|
||||
sample = result[indices[0]]
|
||||
at_port = any(
|
||||
abs(sample["lat"] - info.get("homeport_lat", 0)) < 0.05
|
||||
and abs(sample["lng"] - info.get("homeport_lng", 0)) < 0.05
|
||||
for info in CARRIER_REGISTRY.values()
|
||||
)
|
||||
|
||||
if at_port:
|
||||
# Use each carrier's distinct homeport pier coordinates
|
||||
for idx in indices:
|
||||
carrier = result[idx]
|
||||
hull = None
|
||||
for h, info in CARRIER_REGISTRY.items():
|
||||
if info["name"] == carrier["name"]:
|
||||
hull = h
|
||||
break
|
||||
if hull:
|
||||
info = CARRIER_REGISTRY[hull]
|
||||
carrier["lat"] = info["homeport_lat"]
|
||||
carrier["lng"] = info["homeport_lng"]
|
||||
else:
|
||||
# At sea: spread in a line perpendicular to travel (~0.08° apart)
|
||||
spacing = 0.08 # ~9km — close enough to see they're together
|
||||
start_offset = -(n - 1) * spacing / 2
|
||||
for j, idx in enumerate(indices):
|
||||
result[idx]["lng"] += start_offset + j * spacing
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_carrier_positions() -> List[dict]:
|
||||
"""Return current carrier positions for the data pipeline."""
|
||||
with _positions_lock:
|
||||
@@ -381,7 +444,7 @@ def get_carrier_positions() -> List[dict]:
|
||||
"type": "carrier",
|
||||
"lat": pos["lat"],
|
||||
"lng": pos["lng"],
|
||||
"heading": pos.get("heading", 0),
|
||||
"heading": None, # Heading unknown for carriers — OSINT cannot determine true heading
|
||||
"sog": 0,
|
||||
"cog": 0,
|
||||
"country": "United States",
|
||||
@@ -389,9 +452,10 @@ def get_carrier_positions() -> List[dict]:
|
||||
"wiki": pos.get("wiki", info.get("wiki", "")),
|
||||
"estimated": True,
|
||||
"source": pos.get("source", "OSINT estimated position"),
|
||||
"source_url": pos.get("source_url", "https://news.usni.org/category/fleet-tracker"),
|
||||
"last_osint_update": pos.get("updated", "")
|
||||
})
|
||||
return result
|
||||
return _deconflict_positions(result)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
+939
-277
File diff suppressed because it is too large
Load Diff
+160
-29
@@ -86,8 +86,10 @@ def _extract_domain(url):
|
||||
|
||||
def _url_to_headline(url):
|
||||
"""Extract a human-readable headline from a URL path.
|
||||
e.g. 'https://nytimes.com/2026/03/us-strikes-iran-nuclear-sites.html' -> 'Us Strikes Iran Nuclear Sites (nytimes.com)'
|
||||
e.g. 'https://nytimes.com/2026/03/us-strikes-iran-nuclear-sites.html' -> 'Us Strikes Iran Nuclear Sites'
|
||||
Falls back to domain name if the URL slug is gibberish (hex IDs, UUIDs, etc.).
|
||||
"""
|
||||
import re
|
||||
try:
|
||||
from urllib.parse import urlparse, unquote
|
||||
parsed = urlparse(url)
|
||||
@@ -100,43 +102,151 @@ def _url_to_headline(url):
|
||||
if not path:
|
||||
return domain
|
||||
|
||||
# Take the last path segment (usually the slug)
|
||||
slug = path.split('/')[-1]
|
||||
# Remove file extensions
|
||||
for ext in ['.html', '.htm', '.php', '.asp', '.aspx', '.shtml']:
|
||||
if slug.lower().endswith(ext):
|
||||
slug = slug[:-len(ext)]
|
||||
# If slug is purely numeric or a short ID, try the second-to-last segment
|
||||
import re
|
||||
if re.match(r'^[a-z]?\d{5,}$', slug, re.IGNORECASE):
|
||||
segments = path.split('/')
|
||||
if len(segments) >= 2:
|
||||
slug = segments[-2]
|
||||
for ext in ['.html', '.htm', '.php']:
|
||||
if slug.lower().endswith(ext):
|
||||
slug = slug[:-len(ext)]
|
||||
# Try the last path segment first, then walk backwards
|
||||
segments = [s for s in path.split('/') if s]
|
||||
slug = ''
|
||||
for seg in reversed(segments):
|
||||
# Remove file extensions
|
||||
for ext in ['.html', '.htm', '.php', '.asp', '.aspx', '.shtml']:
|
||||
if seg.lower().endswith(ext):
|
||||
seg = seg[:-len(ext)]
|
||||
# Skip segments that are clearly not headlines
|
||||
if _is_gibberish(seg):
|
||||
continue
|
||||
slug = seg
|
||||
break
|
||||
|
||||
if not slug:
|
||||
return domain
|
||||
|
||||
# Remove common ID patterns at start/end
|
||||
slug = re.sub(r'^[\d]+-', '', slug) # leading numbers like "13847569-"
|
||||
slug = re.sub(r'-[\da-f]{6,}$', '', slug) # trailing hex IDs
|
||||
slug = re.sub(r'[-_]c-\d+$', '', slug) # trailing "-c-21803431"
|
||||
slug = re.sub(r'^p=\d+$', '', slug) # WordPress ?p=1234
|
||||
slug = re.sub(r'^[\d]+-', '', slug) # leading "13847569-"
|
||||
slug = re.sub(r'-[\da-f]{6,}$', '', slug) # trailing hex IDs
|
||||
slug = re.sub(r'[-_]c-\d+$', '', slug) # trailing "-c-21803431"
|
||||
slug = re.sub(r'^p=\d+$', '', slug) # WordPress ?p=1234
|
||||
# Convert slug separators to spaces
|
||||
slug = slug.replace('-', ' ').replace('_', ' ')
|
||||
# Clean up multiple spaces
|
||||
slug = re.sub(r'\s+', ' ', slug).strip()
|
||||
|
||||
# If slug is still just a number or too short, fall back to domain
|
||||
if len(slug) < 5 or re.match(r'^\d+$', slug):
|
||||
# Final gibberish check after cleanup
|
||||
if len(slug) < 8 or _is_gibberish(slug.replace(' ', '-')):
|
||||
return domain
|
||||
|
||||
# Title case and truncate
|
||||
headline = slug.title()
|
||||
if len(headline) > 80:
|
||||
headline = headline[:77] + '...'
|
||||
return f"{headline} ({domain})"
|
||||
if len(headline) > 90:
|
||||
headline = headline[:87] + '...'
|
||||
return headline
|
||||
except Exception:
|
||||
return url[:60]
|
||||
|
||||
|
||||
def _is_gibberish(text):
|
||||
"""Detect if a URL segment is gibberish (hex IDs, UUIDs, numeric IDs, etc.)
|
||||
rather than a real human-readable slug like 'us-strikes-iran'."""
|
||||
import re
|
||||
t = text.strip()
|
||||
if not t:
|
||||
return True
|
||||
# Pure numbers
|
||||
if re.match(r'^\d+$', t):
|
||||
return True
|
||||
# UUID pattern (with or without dashes)
|
||||
if re.match(r'^[0-9a-f]{8}[_-]?[0-9a-f]{4}[_-]?[0-9a-f]{4}[_-]?[0-9a-f]{4}[_-]?[0-9a-f]{12}$', t, re.I):
|
||||
return True
|
||||
# Hex-heavy string: more than 40% hex digits among alphanumeric chars
|
||||
alnum = re.sub(r'[^a-zA-Z0-9]', '', t)
|
||||
if alnum:
|
||||
hex_chars = sum(1 for c in alnum if c in '0123456789abcdefABCDEF')
|
||||
if hex_chars / len(alnum) > 0.4 and len(alnum) > 6:
|
||||
return True
|
||||
# Mostly digits with a few alpha (like "article8efa6c53")
|
||||
digits = sum(1 for c in alnum if c.isdigit())
|
||||
if alnum and digits / len(alnum) > 0.5:
|
||||
return True
|
||||
# Too short to be a headline slug
|
||||
if len(t) < 5:
|
||||
return True
|
||||
# Query-param style segments
|
||||
if '=' in t:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Persistent cache for article titles — survives across GDELT cache refreshes
|
||||
_article_title_cache = {}
|
||||
|
||||
def _fetch_article_title(url):
|
||||
"""Fetch the real headline from an article's HTML <title> or og:title tag.
|
||||
Returns the title string, or None if it can't be fetched.
|
||||
Uses a persistent cache to avoid refetching."""
|
||||
if url in _article_title_cache:
|
||||
return _article_title_cache[url]
|
||||
|
||||
import re
|
||||
try:
|
||||
# Only read the first 32KB — the <title> is always in <head>
|
||||
resp = requests.get(url, timeout=4, headers={
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; OSINT Dashboard/1.0)'
|
||||
}, stream=True)
|
||||
if resp.status_code != 200:
|
||||
_article_title_cache[url] = None
|
||||
return None
|
||||
|
||||
chunk = resp.raw.read(32768).decode('utf-8', errors='replace')
|
||||
resp.close()
|
||||
|
||||
title = None
|
||||
|
||||
# Try og:title first (usually the cleanest)
|
||||
og_match = re.search(r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\'>]+)["\']', chunk, re.I)
|
||||
if not og_match:
|
||||
og_match = re.search(r'<meta[^>]+content=["\']([^"\'>]+)["\'][^>]+property=["\']og:title["\']', chunk, re.I)
|
||||
if og_match:
|
||||
title = og_match.group(1).strip()
|
||||
|
||||
# Fall back to <title> tag
|
||||
if not title:
|
||||
title_match = re.search(r'<title[^>]*>([^<]+)</title>', chunk, re.I)
|
||||
if title_match:
|
||||
title = title_match.group(1).strip()
|
||||
|
||||
if title:
|
||||
# Clean up HTML entities
|
||||
import html as html_mod
|
||||
title = html_mod.unescape(title)
|
||||
# Remove site name suffixes like " | CNN" or " - BBC News"
|
||||
title = re.sub(r'\s*[|\-–—]\s*[^|\-–—]{2,30}$', '', title).strip()
|
||||
# Truncate very long titles
|
||||
if len(title) > 120:
|
||||
title = title[:117] + '...'
|
||||
if len(title) > 10:
|
||||
_article_title_cache[url] = title
|
||||
return title
|
||||
|
||||
_article_title_cache[url] = None
|
||||
return None
|
||||
except Exception:
|
||||
_article_title_cache[url] = None
|
||||
return None
|
||||
|
||||
|
||||
def _batch_fetch_titles(urls):
|
||||
"""Fetch real article titles for a list of URLs in parallel.
|
||||
Returns a dict of url -> title (or None if fetch failed)."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
results = {}
|
||||
with ThreadPoolExecutor(max_workers=16) as executor:
|
||||
futures = {executor.submit(_fetch_article_title, u): u for u in urls}
|
||||
for future in futures:
|
||||
url = futures[future]
|
||||
try:
|
||||
results[url] = future.result()
|
||||
except Exception:
|
||||
results[url] = None
|
||||
return results
|
||||
|
||||
|
||||
def _parse_gdelt_export_zip(zip_bytes, conflict_codes, seen_locs, features, loc_index):
|
||||
"""Parse a single GDELT export ZIP and append conflict features.
|
||||
loc_index maps loc_key -> index in features list for fast duplicate merging.
|
||||
@@ -278,19 +388,40 @@ def fetch_global_military_incidents():
|
||||
if zip_bytes:
|
||||
_parse_gdelt_export_zip(zip_bytes, CONFLICT_CODES, seen_locs, features, loc_index)
|
||||
|
||||
# Collect all unique article URLs for batch title fetching
|
||||
all_article_urls = set()
|
||||
for f in features:
|
||||
for u in f["properties"].get("_urls", []):
|
||||
if u:
|
||||
all_article_urls.add(u)
|
||||
|
||||
logger.info(f"Fetching real article titles for {len(all_article_urls)} unique URLs...")
|
||||
fetched_titles = _batch_fetch_titles(all_article_urls)
|
||||
fetched_count = sum(1 for v in fetched_titles.values() if v)
|
||||
logger.info(f"Resolved {fetched_count}/{len(all_article_urls)} article titles from HTML")
|
||||
|
||||
# Build URL + headline arrays for frontend rendering
|
||||
for f in features:
|
||||
urls = f["properties"].pop("_urls", [])
|
||||
f["properties"].pop("_domains", None)
|
||||
headlines = [_url_to_headline(u) for u in urls]
|
||||
headlines = []
|
||||
for u in urls:
|
||||
# Try the real fetched title first, then fall back to URL slug parsing
|
||||
real_title = fetched_titles.get(u)
|
||||
headlines.append(real_title if real_title else _url_to_headline(u))
|
||||
f["properties"]["_urls_list"] = urls
|
||||
f["properties"]["_headlines_list"] = headlines
|
||||
import html
|
||||
# Keep html as fallback
|
||||
if urls:
|
||||
links = [f'<div style="margin-bottom:6px;"><a href="{u}" target="_blank">{h}</a></div>' for u, h in zip(urls, headlines)]
|
||||
links = []
|
||||
for u, h in zip(urls, headlines):
|
||||
safe_url = u if u.startswith(('http://', 'https://')) else 'about:blank'
|
||||
safe_h = html.escape(h)
|
||||
links.append(f'<div style="margin-bottom:6px;"><a href="{safe_url}" target="_blank" rel="noopener noreferrer">{safe_h}</a></div>')
|
||||
f["properties"]["html"] = ''.join(links)
|
||||
else:
|
||||
f["properties"]["html"] = f["properties"]["name"]
|
||||
f["properties"]["html"] = html.escape(f["properties"]["name"])
|
||||
f.pop("_loc_key", None)
|
||||
|
||||
logger.info(f"GDELT multi-file parsed: {len(features)} conflict locations from {successful} files")
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
KiwiSDR public receiver list fetcher.
|
||||
Scrapes the kiwisdr.com public page for active SDR receivers worldwide.
|
||||
Data is embedded as HTML comments inside each entry div.
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from cachetools import TTLCache, cached
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
kiwisdr_cache = TTLCache(maxsize=1, ttl=600) # 10-minute cache
|
||||
|
||||
|
||||
def _parse_comment(html: str, field: str) -> str:
|
||||
"""Extract a field value from HTML comment like <!-- field=value -->"""
|
||||
m = re.search(rf'<!--\s*{field}=(.*?)\s*-->', html)
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
|
||||
def _parse_gps(html: str):
|
||||
"""Extract lat/lon from <!-- gps=(lat, lon) --> comment."""
|
||||
m = re.search(r'<!--\s*gps=\(([^,]+),\s*([^)]+)\)\s*-->', html)
|
||||
if m:
|
||||
try:
|
||||
return float(m.group(1)), float(m.group(2))
|
||||
except ValueError:
|
||||
return None, None
|
||||
return None, None
|
||||
|
||||
|
||||
@cached(kiwisdr_cache)
|
||||
def fetch_kiwisdr_nodes() -> list[dict]:
|
||||
"""Fetch and parse the KiwiSDR public receiver list."""
|
||||
from services.network_utils import smart_request
|
||||
|
||||
try:
|
||||
res = smart_request("http://kiwisdr.com/.public/", timeout=20)
|
||||
if not res or res.status_code != 200:
|
||||
logger.error(f"KiwiSDR fetch failed: HTTP {res.status_code if res else 'no response'}")
|
||||
return []
|
||||
|
||||
html = res.text
|
||||
# Split by entry divs
|
||||
entries = re.findall(r"<div class='cl-entry[^']*'>(.*?)</div>\s*</div>", html, re.DOTALL)
|
||||
|
||||
nodes = []
|
||||
for entry in entries:
|
||||
lat, lon = _parse_gps(entry)
|
||||
if lat is None or lon is None:
|
||||
continue
|
||||
if abs(lat) > 90 or abs(lon) > 180:
|
||||
continue
|
||||
|
||||
offline = _parse_comment(entry, "offline")
|
||||
if offline == "yes":
|
||||
continue
|
||||
|
||||
name = _parse_comment(entry, "name") or "Unknown SDR"
|
||||
users_str = _parse_comment(entry, "users")
|
||||
users_max_str = _parse_comment(entry, "users_max")
|
||||
bands = _parse_comment(entry, "bands")
|
||||
antenna = _parse_comment(entry, "antenna")
|
||||
location = _parse_comment(entry, "loc")
|
||||
|
||||
# Extract the URL from the href
|
||||
url_match = re.search(r"href='(https?://[^']+)'", entry)
|
||||
url = url_match.group(1) if url_match else ""
|
||||
|
||||
try:
|
||||
users = int(users_str) if users_str else 0
|
||||
except ValueError:
|
||||
users = 0
|
||||
try:
|
||||
users_max = int(users_max_str) if users_max_str else 0
|
||||
except ValueError:
|
||||
users_max = 0
|
||||
|
||||
nodes.append({
|
||||
"name": name[:120], # Truncate long names
|
||||
"lat": round(lat, 5),
|
||||
"lon": round(lon, 5),
|
||||
"url": url,
|
||||
"users": users,
|
||||
"users_max": users_max,
|
||||
"bands": bands,
|
||||
"antenna": antenna[:200] if antenna else "",
|
||||
"location": location[:100] if location else "",
|
||||
})
|
||||
|
||||
logger.info(f"KiwiSDR: parsed {len(nodes)} online receivers")
|
||||
return nodes
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"KiwiSDR fetch exception: {e}")
|
||||
return []
|
||||
@@ -3,10 +3,19 @@ import json
|
||||
import subprocess
|
||||
import shutil
|
||||
import time
|
||||
import requests
|
||||
from urllib.parse import urlparse
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Reusable session with connection pooling and retry logic
|
||||
_session = requests.Session()
|
||||
_retry = Retry(total=2, backoff_factor=0.5, status_forcelist=[502, 503, 504])
|
||||
_session.mount("https://", HTTPAdapter(max_retries=_retry, pool_maxsize=20))
|
||||
_session.mount("http://", HTTPAdapter(max_retries=_retry, pool_maxsize=10))
|
||||
|
||||
# Find bash for curl fallback — Git bash's curl has the TLS features
|
||||
# needed to pass CDN fingerprint checks (brotli, zstd, libpsl)
|
||||
_BASH_PATH = shutil.which("bash") or "bash"
|
||||
@@ -15,6 +24,11 @@ _BASH_PATH = shutil.which("bash") or "bash"
|
||||
_domain_fail_cache: dict[str, float] = {}
|
||||
_DOMAIN_FAIL_TTL = 300 # 5 minutes
|
||||
|
||||
# Circuit breaker: track domains where BOTH requests AND curl fail
|
||||
# If a domain failed completely within the last 2 minutes, skip it entirely
|
||||
_circuit_breaker: dict[str, float] = {}
|
||||
_CIRCUIT_BREAKER_TTL = 120 # 2 minutes
|
||||
|
||||
class _DummyResponse:
|
||||
"""Minimal response object matching requests.Response interface."""
|
||||
def __init__(self, status_code, text):
|
||||
@@ -45,46 +59,57 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None)
|
||||
|
||||
domain = urlparse(url).netloc
|
||||
|
||||
# Circuit breaker: if domain failed completely <2min ago, fail fast
|
||||
if domain in _circuit_breaker and (time.time() - _circuit_breaker[domain]) < _CIRCUIT_BREAKER_TTL:
|
||||
raise Exception(f"Circuit breaker open for {domain} (failed <{_CIRCUIT_BREAKER_TTL}s ago)")
|
||||
|
||||
# Check if this domain recently failed with requests — skip straight to curl
|
||||
if domain in _domain_fail_cache and (time.time() - _domain_fail_cache[domain]) < _DOMAIN_FAIL_TTL:
|
||||
pass # Fall through to curl below
|
||||
else:
|
||||
try:
|
||||
import requests
|
||||
if method == "POST":
|
||||
res = requests.post(url, json=json_data, timeout=timeout, headers=default_headers)
|
||||
res = _session.post(url, json=json_data, timeout=timeout, headers=default_headers)
|
||||
else:
|
||||
res = requests.get(url, timeout=timeout, headers=default_headers)
|
||||
res = _session.get(url, timeout=timeout, headers=default_headers)
|
||||
res.raise_for_status()
|
||||
# Clear failure cache on success
|
||||
# Clear failure caches on success
|
||||
_domain_fail_cache.pop(domain, None)
|
||||
_circuit_breaker.pop(domain, None)
|
||||
return res
|
||||
except Exception as e:
|
||||
logger.warning(f"Python requests failed for {url} ({e}), falling back to bash curl...")
|
||||
_domain_fail_cache[domain] = time.time()
|
||||
|
||||
# Build curl command string for bash execution
|
||||
header_flags = " ".join(f'-H "{k}: {v}"' for k, v in default_headers.items())
|
||||
# Build curl as argument list — never pass through shell to prevent injection
|
||||
_CURL_PATH = shutil.which("curl") or "curl"
|
||||
cmd = [_CURL_PATH, "-s", "-w", "\n%{http_code}"]
|
||||
for k, v in default_headers.items():
|
||||
cmd += ["-H", f"{k}: {v}"]
|
||||
if method == "POST" and json_data:
|
||||
payload = json.dumps(json_data).replace('"', '\\"')
|
||||
curl_cmd = f'curl -s -w "\\n%{{http_code}}" {header_flags} -X POST -H "Content-Type: application/json" -d "{payload}" "{url}"'
|
||||
else:
|
||||
curl_cmd = f'curl -s -w "\\n%{{http_code}}" {header_flags} "{url}"'
|
||||
cmd += ["-X", "POST", "-H", "Content-Type: application/json",
|
||||
"--data-binary", "@-"]
|
||||
cmd.append(url)
|
||||
|
||||
try:
|
||||
stdin_data = json.dumps(json_data) if (method == "POST" and json_data) else None
|
||||
res = subprocess.run(
|
||||
[_BASH_PATH, "-c", curl_cmd],
|
||||
capture_output=True, text=True, timeout=timeout + 5
|
||||
cmd, capture_output=True, text=True, timeout=timeout + 5,
|
||||
input=stdin_data
|
||||
)
|
||||
if res.returncode == 0 and res.stdout.strip():
|
||||
# Parse HTTP status code from -w output (last line)
|
||||
lines = res.stdout.rstrip().rsplit("\n", 1)
|
||||
body = lines[0] if len(lines) > 1 else res.stdout
|
||||
http_code = int(lines[-1]) if len(lines) > 1 and lines[-1].strip().isdigit() else 200
|
||||
if http_code < 400:
|
||||
_circuit_breaker.pop(domain, None) # Clear circuit breaker on success
|
||||
return _DummyResponse(http_code, body)
|
||||
else:
|
||||
logger.error(f"bash curl fallback failed: exit={res.returncode} stderr={res.stderr[:200]}")
|
||||
_circuit_breaker[domain] = time.time()
|
||||
return _DummyResponse(500, "")
|
||||
except Exception as curl_e:
|
||||
logger.error(f"bash curl fallback exception: {curl_e}")
|
||||
_circuit_breaker[domain] = time.time()
|
||||
return _DummyResponse(500, "")
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
News feed configuration — manages the user-customisable RSS feed list.
|
||||
Feeds are stored in backend/config/news_feeds.json and persist across restarts.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_PATH = Path(__file__).parent.parent / "config" / "news_feeds.json"
|
||||
MAX_FEEDS = 20
|
||||
|
||||
DEFAULT_FEEDS = [
|
||||
{"name": "NPR", "url": "https://feeds.npr.org/1004/rss.xml", "weight": 4},
|
||||
{"name": "BBC", "url": "http://feeds.bbci.co.uk/news/world/rss.xml", "weight": 3},
|
||||
{"name": "AlJazeera", "url": "https://www.aljazeera.com/xml/rss/all.xml", "weight": 2},
|
||||
{"name": "NYT", "url": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml", "weight": 1},
|
||||
{"name": "GDACS", "url": "https://www.gdacs.org/xml/rss.xml", "weight": 5},
|
||||
{"name": "NHK", "url": "https://www3.nhk.or.jp/nhkworld/rss/world.xml", "weight": 3},
|
||||
{"name": "CNA", "url": "https://www.channelnewsasia.com/rssfeed/8395986", "weight": 3},
|
||||
{"name": "Mercopress", "url": "https://en.mercopress.com/rss/", "weight": 3},
|
||||
]
|
||||
|
||||
|
||||
def get_feeds() -> list[dict]:
|
||||
"""Load feeds from config file, falling back to defaults."""
|
||||
try:
|
||||
if CONFIG_PATH.exists():
|
||||
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
feeds = data.get("feeds", []) if isinstance(data, dict) else data
|
||||
if isinstance(feeds, list) and len(feeds) > 0:
|
||||
return feeds
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read news feed config: {e}")
|
||||
return list(DEFAULT_FEEDS)
|
||||
|
||||
|
||||
def save_feeds(feeds: list[dict]) -> bool:
|
||||
"""Validate and save feeds to config file. Returns True on success."""
|
||||
if not isinstance(feeds, list):
|
||||
return False
|
||||
if len(feeds) > MAX_FEEDS:
|
||||
return False
|
||||
# Validate each feed entry
|
||||
for f in feeds:
|
||||
if not isinstance(f, dict):
|
||||
return False
|
||||
name = f.get("name", "").strip()
|
||||
url = f.get("url", "").strip()
|
||||
weight = f.get("weight", 3)
|
||||
if not name or not url:
|
||||
return False
|
||||
if not isinstance(weight, (int, float)) or weight < 1 or weight > 5:
|
||||
return False
|
||||
# Normalise
|
||||
f["name"] = name
|
||||
f["url"] = url
|
||||
f["weight"] = int(weight)
|
||||
try:
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_PATH.write_text(
|
||||
json.dumps({"feeds": feeds}, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write news feed config: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def reset_feeds() -> bool:
|
||||
"""Reset feeds to defaults."""
|
||||
return save_feeds(list(DEFAULT_FEEDS))
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
import time
|
||||
import concurrent.futures
|
||||
from urllib.parse import quote
|
||||
import requests as _requests
|
||||
from cachetools import TTLCache
|
||||
from services.network_utils import fetch_with_curl
|
||||
|
||||
@@ -10,26 +12,46 @@ logger = logging.getLogger(__name__)
|
||||
# Key: rounded lat/lng grid (0.1 degree ≈ 11km)
|
||||
dossier_cache = TTLCache(maxsize=500, ttl=86400)
|
||||
|
||||
# Nominatim requires max 1 req/sec — track last call time
|
||||
_nominatim_last_call = 0.0
|
||||
|
||||
|
||||
def _reverse_geocode(lat: float, lng: float) -> dict:
|
||||
global _nominatim_last_call
|
||||
url = (
|
||||
f"https://nominatim.openstreetmap.org/reverse?"
|
||||
f"lat={lat}&lon={lng}&format=json&zoom=10&addressdetails=1&accept-language=en"
|
||||
)
|
||||
try:
|
||||
res = fetch_with_curl(url, timeout=10)
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
addr = data.get("address", {})
|
||||
return {
|
||||
"city": addr.get("city") or addr.get("town") or addr.get("village") or addr.get("county") or "",
|
||||
"state": addr.get("state") or addr.get("region") or "",
|
||||
"country": addr.get("country") or "",
|
||||
"country_code": (addr.get("country_code") or "").upper(),
|
||||
"display_name": data.get("display_name", ""),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Reverse geocode failed: {e}")
|
||||
headers = {"User-Agent": "ShadowBroker-OSINT/1.0 (live-risk-dashboard; contact@shadowbroker.app)"}
|
||||
|
||||
for attempt in range(2):
|
||||
# Enforce Nominatim's 1 req/sec policy
|
||||
elapsed = time.time() - _nominatim_last_call
|
||||
if elapsed < 1.1:
|
||||
time.sleep(1.1 - elapsed)
|
||||
_nominatim_last_call = time.time()
|
||||
|
||||
try:
|
||||
# Use requests directly — fetch_with_curl raises on non-200 which breaks 429 handling
|
||||
res = _requests.get(url, timeout=10, headers=headers)
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
addr = data.get("address", {})
|
||||
return {
|
||||
"city": addr.get("city") or addr.get("town") or addr.get("village") or addr.get("county") or "",
|
||||
"state": addr.get("state") or addr.get("region") or "",
|
||||
"country": addr.get("country") or "",
|
||||
"country_code": (addr.get("country_code") or "").upper(),
|
||||
"display_name": data.get("display_name", ""),
|
||||
}
|
||||
elif res.status_code == 429:
|
||||
logger.warning(f"Nominatim 429 rate-limited, retrying after 2s (attempt {attempt+1})")
|
||||
time.sleep(2)
|
||||
continue
|
||||
else:
|
||||
logger.warning(f"Nominatim returned {res.status_code}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Reverse geocode failed: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Sentinel-2 satellite imagery search via Microsoft Planetary Computer STAC API.
|
||||
Free, keyless search for metadata + thumbnails. Used in the right-click dossier.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from cachetools import TTLCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache by rounded lat/lon (0.02° grid ~= 2km), TTL 1 hour
|
||||
_sentinel_cache = TTLCache(maxsize=200, ttl=3600)
|
||||
|
||||
|
||||
def search_sentinel2_scene(lat: float, lng: float) -> dict:
|
||||
"""Search for the latest Sentinel-2 L2A scene covering a point."""
|
||||
cache_key = f"{round(lat, 2)}_{round(lng, 2)}"
|
||||
if cache_key in _sentinel_cache:
|
||||
return _sentinel_cache[cache_key]
|
||||
|
||||
try:
|
||||
from pystac_client import Client
|
||||
|
||||
catalog = Client.open("https://planetarycomputer.microsoft.com/api/stac/v1")
|
||||
end = datetime.utcnow()
|
||||
start = end - timedelta(days=30)
|
||||
|
||||
search = catalog.search(
|
||||
collections=["sentinel-2-l2a"],
|
||||
intersects={"type": "Point", "coordinates": [lng, lat]},
|
||||
datetime=f"{start.isoformat()}Z/{end.isoformat()}Z",
|
||||
sortby=[{"field": "datetime", "direction": "desc"}],
|
||||
max_items=3,
|
||||
query={"eo:cloud_cover": {"lt": 30}},
|
||||
)
|
||||
|
||||
items = list(search.items())
|
||||
if not items:
|
||||
result = {"found": False, "message": "No clear scenes in last 30 days"}
|
||||
_sentinel_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
item = items[0]
|
||||
# Try to sign item first for Azure blob URLs
|
||||
try:
|
||||
import planetary_computer
|
||||
item = planetary_computer.sign_item(item)
|
||||
except ImportError:
|
||||
pass # planetary_computer not installed, try unsigned URLs
|
||||
except Exception as e:
|
||||
logger.warning(f"Sentinel-2 signing failed: {e}")
|
||||
|
||||
# Get the rendered_preview (full-res PNG) and thumbnail separately
|
||||
rendered = item.assets.get("rendered_preview")
|
||||
thumbnail = item.assets.get("thumbnail")
|
||||
|
||||
# Full-res image URL — what opens when user clicks
|
||||
fullres_url = rendered.href if rendered else (thumbnail.href if thumbnail else None)
|
||||
# Thumbnail URL — what shows in the popup card
|
||||
thumb_url = thumbnail.href if thumbnail else (rendered.href if rendered else None)
|
||||
|
||||
result = {
|
||||
"found": True,
|
||||
"scene_id": item.id,
|
||||
"datetime": item.datetime.isoformat() if item.datetime else None,
|
||||
"cloud_cover": item.properties.get("eo:cloud_cover"),
|
||||
"thumbnail_url": thumb_url,
|
||||
"fullres_url": fullres_url,
|
||||
"bbox": list(item.bbox) if item.bbox else None,
|
||||
"platform": item.properties.get("platform", "Sentinel-2"),
|
||||
}
|
||||
_sentinel_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
except ImportError:
|
||||
logger.warning("pystac-client not installed — Sentinel-2 search unavailable")
|
||||
return {"found": False, "error": "pystac-client not installed"}
|
||||
except Exception as e:
|
||||
logger.error(f"Sentinel-2 search failed for ({lat}, {lng}): {e}")
|
||||
return {"found": False, "error": str(e)}
|
||||
@@ -1,17 +0,0 @@
|
||||
import sys
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
# Add backend directory to sys path so we can import modules
|
||||
sys.path.append(r'f:\Codebase\Oracle\live-risk-dashboard\backend')
|
||||
|
||||
from services.data_fetcher import fetch_flights, latest_data
|
||||
|
||||
print("Testing fetch_flights...")
|
||||
try:
|
||||
fetch_flights()
|
||||
print("Commercial flights count:", len(latest_data.get('commercial_flights', [])))
|
||||
print("Private jets count:", len(latest_data.get('private_jets', [])))
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -1,38 +0,0 @@
|
||||
import json
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
def scrape_liveuamap():
|
||||
print("Launching playwright...")
|
||||
with sync_playwright() as p:
|
||||
# User agents are important for headless browsing
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
|
||||
def handle_response(response):
|
||||
try:
|
||||
if not response.url.endswith(('js', 'css', 'png', 'jpg', 'woff2', 'svg', 'ico')):
|
||||
print(f"Intercepted API Call: {response.url}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
page.on("response", handle_response)
|
||||
|
||||
print("Navigating to liveuamap...")
|
||||
try:
|
||||
page.goto("https://liveuamap.com/", timeout=30000, wait_until="domcontentloaded")
|
||||
page.wait_for_timeout(5000)
|
||||
|
||||
print("Grabbing all script tags...")
|
||||
scripts = page.evaluate("() => Array.from(document.querySelectorAll('script')).map(s => s.innerText)")
|
||||
for i, s in enumerate(scripts):
|
||||
if 'JSON.parse' in s or 'markers' in s or 'JSON' in s:
|
||||
with open(f"script_{i}.txt", "w", encoding="utf-8") as f:
|
||||
f.write(s)
|
||||
except Exception as e:
|
||||
print("Playwright timeout or error:", e)
|
||||
|
||||
print("Closing browser...")
|
||||
browser.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
scrape_liveuamap()
|
||||
@@ -1,59 +0,0 @@
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import cloudscraper
|
||||
|
||||
def scrape_openmhz_systems():
|
||||
print("Testing OpenMHZ undocumented API with Cloudscraper...")
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
}
|
||||
|
||||
scraper = cloudscraper.create_scraper(browser={'browser': 'chrome', 'platform': 'windows', 'desktop': True})
|
||||
|
||||
try:
|
||||
# Step 1: Hit the public systems list that the front-end map uses
|
||||
res = scraper.get("https://api.openmhz.com/systems", headers=headers, timeout=15)
|
||||
json_data = res.json()
|
||||
systems = json_data.get('systems', []) if isinstance(json_data, dict) else []
|
||||
print(f"Successfully spoofed OpenMHZ frontend. Found {len(systems)} active police/fire systems.")
|
||||
|
||||
if not systems:
|
||||
return
|
||||
|
||||
# Inspect the first system (usually a major city)
|
||||
city = systems[0]
|
||||
sys_name = city.get('shortName')
|
||||
print(f"Targeting System: {city.get('name')} ({sys_name})")
|
||||
|
||||
if not sys_name:
|
||||
return
|
||||
|
||||
time.sleep(2) # Ethical delay
|
||||
|
||||
# Step 2: Query the recent calls for this specific system
|
||||
# The frontend queries: https://api.openmhz.com/<system_name>/calls
|
||||
calls_url = f"https://api.openmhz.com/{sys_name}/calls"
|
||||
print(f"Fetching recent bursts: {calls_url}")
|
||||
|
||||
call_res = scraper.get(calls_url, headers=headers, timeout=15)
|
||||
|
||||
if call_res.status_code == 200:
|
||||
call_json = call_res.json()
|
||||
calls = call_json.get('calls', []) if isinstance(call_json, dict) else []
|
||||
if calls and len(calls) > 0:
|
||||
print(f"Intercepted {len(calls)} audio bursts.")
|
||||
latest = calls[0]
|
||||
print("LATEST INTERCEPT:")
|
||||
print(f"Talkgroup: {latest.get('talkgroupNum')}")
|
||||
print(f"Audio URL: {latest.get('url')}")
|
||||
else:
|
||||
print("No recent calls found for this system.")
|
||||
else:
|
||||
print(f"Failed to fetch calls. HTTP {call_res.status_code}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Scrape Exception: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
scrape_openmhz_systems()
|
||||
@@ -1,19 +0,0 @@
|
||||
import requests
|
||||
|
||||
def test_openmhz():
|
||||
print("Testing OpenMHZ...")
|
||||
res = requests.get("https://api.openmhz.com/systems")
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
print(f"OpenMHZ returned {len(data)} systems.")
|
||||
print(f"Example: {data[0]['name']} ({data[0]['shortName']})")
|
||||
else:
|
||||
print(f"OpenMHZ Failed: {res.status_code}")
|
||||
|
||||
def test_scanner_radio():
|
||||
print("Testing Scanner Radio...")
|
||||
# Gordon Edwards app often uses something like this
|
||||
# We will just try broadcastify public page scrape as a secondary fallback
|
||||
pass
|
||||
|
||||
test_openmhz()
|
||||
@@ -1,55 +0,0 @@
|
||||
import feedparser
|
||||
import requests
|
||||
import re
|
||||
|
||||
feeds = {
|
||||
"NPR": "https://feeds.npr.org/1004/rss.xml",
|
||||
"BBC": "http://feeds.bbci.co.uk/news/world/rss.xml"
|
||||
}
|
||||
|
||||
keyword_coords = {
|
||||
"venezuela": (7.119, -66.589), "brazil": (-14.235, -51.925), "argentina": (-38.416, -63.616),
|
||||
"colombia": (4.570, -74.297), "mexico": (23.634, -102.552), "united states": (38.907, -77.036),
|
||||
" usa ": (38.907, -77.036), " us ": (38.907, -77.036), "washington": (38.907, -77.036),
|
||||
"canada": (56.130, -106.346), "ukraine": (49.487, 31.272), "kyiv": (50.450, 30.523),
|
||||
"russia": (61.524, 105.318), "moscow": (55.755, 37.617), "israel": (31.046, 34.851),
|
||||
"gaza": (31.416, 34.333), "iran": (32.427, 53.688), "lebanon": (33.854, 35.862),
|
||||
"syria": (34.802, 38.996), "yemen": (15.552, 48.516), "china": (35.861, 104.195),
|
||||
"beijing": (39.904, 116.407), "taiwan": (23.697, 120.960), "north korea": (40.339, 127.510),
|
||||
"south korea": (35.907, 127.766), "pyongyang": (39.039, 125.762), "seoul": (37.566, 126.978),
|
||||
"japan": (36.204, 138.252), "afghanistan": (33.939, 67.709), "pakistan": (30.375, 69.345),
|
||||
"india": (20.593, 78.962), " uk ": (55.378, -3.435), "london": (51.507, -0.127),
|
||||
"france": (46.227, 2.213), "paris": (48.856, 2.352), "germany": (51.165, 10.451),
|
||||
"berlin": (52.520, 13.405), "sudan": (12.862, 30.217), "congo": (-4.038, 21.758),
|
||||
"south africa": (-30.559, 22.937), "nigeria": (9.082, 8.675), "egypt": (26.820, 30.802),
|
||||
"zimbabwe": (-19.015, 29.154), "australia": (-25.274, 133.775), "middle east": (31.500, 34.800),
|
||||
"europe": (48.800, 2.300), "africa": (0.000, 25.000), "america": (38.900, -77.000),
|
||||
"south america": (-14.200, -51.900), "asia": (34.000, 100.000),
|
||||
"california": (36.778, -119.417), "texas": (31.968, -99.901), "florida": (27.994, -81.760),
|
||||
"new york": (40.712, -74.006), "virginia": (37.431, -78.656),
|
||||
"british columbia": (53.726, -127.647), "ontario": (51.253, -85.323), "quebec": (52.939, -73.549),
|
||||
"delhi": (28.704, 77.102), "new delhi": (28.613, 77.209), "mumbai": (19.076, 72.877),
|
||||
"shanghai": (31.230, 121.473), "hong kong": (22.319, 114.169), "istanbul": (41.008, 28.978),
|
||||
"dubai": (25.204, 55.270), "singapore": (1.352, 103.819)
|
||||
}
|
||||
|
||||
for name, url in feeds.items():
|
||||
r = requests.get(url)
|
||||
feed = feedparser.parse(r.text)
|
||||
for entry in feed.entries[:10]:
|
||||
title = entry.get('title', '')
|
||||
summary = entry.get('summary', '')
|
||||
text = (title + " " + summary).lower()
|
||||
padded_text = f" {text} "
|
||||
|
||||
matched_kw = None
|
||||
for kw, coords in keyword_coords.items():
|
||||
if kw.startswith(" ") or kw.endswith(" "):
|
||||
if kw in padded_text:
|
||||
matched_kw = kw
|
||||
break
|
||||
else:
|
||||
if re.search(r'\b' + re.escape(kw) + r'\b', text):
|
||||
matched_kw = kw
|
||||
break
|
||||
print(f"[{name}] {title}\n Matched: {matched_kw}\n Text: {text}\n")
|
||||
@@ -1,67 +0,0 @@
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
import json
|
||||
|
||||
def scrape_broadcastify_top():
|
||||
print("Scraping Broadcastify Top Feeds...")
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
}
|
||||
|
||||
try:
|
||||
# The top 50 feeds page provides a wealth of listening data
|
||||
res = requests.get("https://www.broadcastify.com/listen/top", headers=headers, timeout=10)
|
||||
if res.status_code != 200:
|
||||
print(f"Failed HTTP {res.status_code}")
|
||||
return []
|
||||
|
||||
soup = BeautifulSoup(res.text, 'html.parser')
|
||||
|
||||
# The table of feeds is in a standard class
|
||||
table = soup.find('table', {'class': 'btable'})
|
||||
if not table:
|
||||
print("Could not find feeds table.")
|
||||
return []
|
||||
|
||||
feeds = []
|
||||
rows = table.find_all('tr')[1:] # Skip header
|
||||
|
||||
for row in rows:
|
||||
cols = row.find_all('td')
|
||||
if len(cols) >= 5:
|
||||
# Top layout: [Listeners, Feed ID (hidden), Location, Feed Name, Category, Genre]
|
||||
listeners_str = cols[0].text.strip().replace(',', '')
|
||||
listeners = int(listeners_str) if listeners_str.isdigit() else 0
|
||||
|
||||
# The link is usually in the Feed Name column
|
||||
link_tag = cols[2].find('a')
|
||||
if not link_tag:
|
||||
continue
|
||||
|
||||
href = link_tag.get('href', '')
|
||||
feed_id = href.split('/')[-1] if '/listen/feed/' in href else None
|
||||
|
||||
if not feed_id:
|
||||
continue
|
||||
|
||||
location = cols[1].text.strip()
|
||||
name = cols[2].text.strip()
|
||||
|
||||
feeds.append({
|
||||
"id": feed_id,
|
||||
"listeners": listeners,
|
||||
"location": location,
|
||||
"name": name,
|
||||
"stream_url": f"https://broadcastify.cdnstream1.com/{feed_id}"
|
||||
})
|
||||
|
||||
print(f"Successfully scraped {len(feeds)} top feeds.")
|
||||
return feeds
|
||||
|
||||
except Exception as e:
|
||||
print(f"Scrape error: {e}")
|
||||
return []
|
||||
|
||||
if __name__ == "__main__":
|
||||
top_feeds = scrape_broadcastify_top()
|
||||
print(json.dumps(top_feeds[:3], indent=2))
|
||||
@@ -1,59 +0,0 @@
|
||||
import requests
|
||||
import time
|
||||
import math
|
||||
import random
|
||||
|
||||
def test_fetch_and_triangulate():
|
||||
t0 = time.time()
|
||||
url = "https://api.adsb.lol/v2/lat/39.8/lon/-98.5/dist/1000"
|
||||
try:
|
||||
r = requests.get(url, timeout=10)
|
||||
data = r.json()
|
||||
print(f"Downloaded in {time.time() - t0:.2f}s")
|
||||
if "ac" in data:
|
||||
sampled = data["ac"]
|
||||
print("Flights:", len(sampled))
|
||||
else:
|
||||
print("No 'ac' in response:", data)
|
||||
|
||||
|
||||
# Load airports (mock for test)
|
||||
airports = [{"lat": random.uniform(-90, 90), "lng": random.uniform(-180, 180), "iata": f"A{i}"} for i in range(4000)]
|
||||
|
||||
t1 = time.time()
|
||||
for f in sampled:
|
||||
lat = f.get("lat")
|
||||
lng = f.get("lon")
|
||||
heading = f.get("track", 0)
|
||||
if lat is None or lng is None: continue
|
||||
|
||||
# Project 15 degrees (~1000 miles) backwards and forwards
|
||||
dist_deg = 15.0
|
||||
h_rad = math.radians(heading)
|
||||
dy = math.cos(h_rad) * dist_deg
|
||||
dx = math.sin(h_rad) * dist_deg
|
||||
cos_lat = max(0.2, math.cos(math.radians(lat)))
|
||||
|
||||
origin_lat = lat - dy
|
||||
origin_lng = lng - (dx / cos_lat)
|
||||
|
||||
dest_lat = lat + dy
|
||||
dest_lng = lng + (dx / cos_lat)
|
||||
|
||||
# Find closest origin airport
|
||||
best_o, min_o = None, float('inf')
|
||||
for a in airports:
|
||||
d = (a['lat'] - origin_lat)**2 + (a['lng'] - origin_lng)**2
|
||||
if d < min_o: min_o = d; best_o = a
|
||||
|
||||
# Find closest dest airport
|
||||
best_d, min_d = None, float('inf')
|
||||
for a in airports:
|
||||
d = (a['lat'] - dest_lat)**2 + (a['lng'] - dest_lng)**2
|
||||
if d < min_d: min_d = d; best_d = a
|
||||
|
||||
print(f"Triangulated 500 flights against {len(airports)} airports in {time.time() - t1:.2f}s")
|
||||
except Exception as e:
|
||||
print("Error:", e)
|
||||
|
||||
test_fetch_and_triangulate()
|
||||
@@ -1,13 +0,0 @@
|
||||
from services.data_fetcher import fetch_airports, fetch_flights, cached_airports, latest_data
|
||||
|
||||
fetch_airports()
|
||||
|
||||
# We patch logger to see what happens inside fetch_flights
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
# let's run fetch_flights
|
||||
fetch_flights()
|
||||
|
||||
flights = latest_data.get('flights', [])
|
||||
print(f"Total flights: {len(flights)}")
|
||||
@@ -1,45 +0,0 @@
|
||||
import json
|
||||
import subprocess
|
||||
import os
|
||||
import time
|
||||
|
||||
proxy_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ais_proxy.js")
|
||||
API_KEY = "75cc39af03c9cc23c90e8a7b3c3bc2b2a507c5fb"
|
||||
|
||||
print(f"Proxy script: {proxy_script}")
|
||||
print(f"Exists: {os.path.exists(proxy_script)}")
|
||||
|
||||
process = subprocess.Popen(
|
||||
['node', proxy_script, API_KEY],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, # Separate stderr!
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
print("Process started, reading stdout...")
|
||||
count = 0
|
||||
start = time.time()
|
||||
for line in iter(process.stdout.readline, ''):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
msg_type = data.get("MessageType", "?")
|
||||
mmsi = data.get("MetaData", {}).get("MMSI", 0)
|
||||
count += 1
|
||||
if count <= 5:
|
||||
print(f" MSG {count}: type={msg_type} mmsi={mmsi}")
|
||||
if count == 20:
|
||||
elapsed = time.time() - start
|
||||
print(f"\nReceived {count} messages in {elapsed:.1f}s — proxy is working!")
|
||||
process.terminate()
|
||||
break
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" BAD JSON: {line[:100]}... err={e}")
|
||||
|
||||
if count == 0:
|
||||
# Check stderr
|
||||
stderr_out = process.stderr.read()
|
||||
print(f"Zero messages received. stderr: {stderr_out[:500]}")
|
||||
@@ -1,54 +0,0 @@
|
||||
import json
|
||||
import subprocess
|
||||
import os
|
||||
import time
|
||||
import sys
|
||||
|
||||
proxy_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ais_proxy.js")
|
||||
API_KEY = "75cc39af03c9cc23c90e8a7b3c3bc2b2a507c5fb"
|
||||
|
||||
print(f"Proxy script: {proxy_script}")
|
||||
|
||||
process = subprocess.Popen(
|
||||
['node', proxy_script, API_KEY],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
import threading
|
||||
|
||||
def read_stderr():
|
||||
for line in iter(process.stderr.readline, ''):
|
||||
print(f"[STDERR] {line.strip()}", file=sys.stderr)
|
||||
|
||||
t = threading.Thread(target=read_stderr, daemon=True)
|
||||
t.start()
|
||||
|
||||
print("Process started, reading stdout for 15 seconds...")
|
||||
count = 0
|
||||
start = time.time()
|
||||
while time.time() - start < 15:
|
||||
line = process.stdout.readline()
|
||||
if not line:
|
||||
if process.poll() is not None:
|
||||
print(f"Process exited with code {process.returncode}")
|
||||
break
|
||||
continue
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
msg_type = data.get("MessageType", "?")
|
||||
mmsi = data.get("MetaData", {}).get("MMSI", 0)
|
||||
count += 1
|
||||
if count <= 5:
|
||||
print(f" MSG {count}: type={msg_type} mmsi={mmsi}")
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" BAD LINE: {line[:80]}...")
|
||||
|
||||
elapsed = time.time() - start
|
||||
print(f"\nTotal {count} messages in {elapsed:.1f}s")
|
||||
process.terminate()
|
||||
@@ -1,13 +0,0 @@
|
||||
import requests
|
||||
import traceback
|
||||
|
||||
try:
|
||||
print("Testing adsb.lol...")
|
||||
r = requests.get("https://api.adsb.lol/v2/lat/39.8/lon/-98.5/dist/100", timeout=15)
|
||||
print(f"Status: {r.status_code}")
|
||||
d = r.json()
|
||||
print(f"Aircraft: {len(d.get('ac', []))}")
|
||||
except Exception as e:
|
||||
print(f"Error type: {type(e).__name__}")
|
||||
print(f"Error: {e}")
|
||||
traceback.print_exc()
|
||||
@@ -1,11 +0,0 @@
|
||||
import json
|
||||
import urllib.request
|
||||
import time
|
||||
|
||||
time.sleep(5)
|
||||
try:
|
||||
data = urllib.request.urlopen('http://localhost:8000/api/live-data').read()
|
||||
d = json.loads(data)
|
||||
print(f"News: {len(d.get('news', []))} | Earthquakes: {len(d.get('earthquakes', []))} | Satellites: {len(d.get('satellites', []))} | CCTV: {len(d.get('cctv', []))}")
|
||||
except Exception as e:
|
||||
print(f"Error fetching API: {e}")
|
||||
@@ -1,56 +0,0 @@
|
||||
import requests
|
||||
import json
|
||||
|
||||
# Step 1: Fetch some real flights from adsb.lol
|
||||
print("Fetching real flights from adsb.lol...")
|
||||
r = requests.get("https://api.adsb.lol/v2/lat/39.8/lon/-98.5/dist/250", timeout=10)
|
||||
data = r.json()
|
||||
ac = data.get("ac", [])
|
||||
print("Got", len(ac), "aircraft")
|
||||
|
||||
# Step 2: Build a batch of real callsigns
|
||||
planes = []
|
||||
for f in ac[:20]: # Just 20 real flights
|
||||
cs = str(f.get("flight", "")).strip()
|
||||
lat = f.get("lat")
|
||||
lon = f.get("lon")
|
||||
if cs and lat and lon:
|
||||
planes.append({"callsign": cs, "lat": lat, "lng": lon})
|
||||
|
||||
print("Built batch of", len(planes), "planes")
|
||||
print("Sample plane:", json.dumps(planes[0]) if planes else "NONE")
|
||||
|
||||
# Step 3: Test routeset with real data
|
||||
if planes:
|
||||
payload = {"planes": planes}
|
||||
print("Payload size:", len(json.dumps(payload)), "bytes")
|
||||
r2 = requests.post("https://api.adsb.lol/api/0/routeset", json=payload, timeout=15)
|
||||
print("Routeset HTTP:", r2.status_code)
|
||||
if r2.status_code == 200:
|
||||
result = r2.json()
|
||||
print("Response type:", type(result).__name__)
|
||||
print("Routes found:", len(result) if isinstance(result, list) else "dict")
|
||||
if isinstance(result, list) and len(result) > 0:
|
||||
print("First route:", json.dumps(result[0], indent=2))
|
||||
else:
|
||||
print("Error body:", r2.text[:500])
|
||||
|
||||
# Step 4: Test with bigger batch
|
||||
print("\n--- Testing with 100 real flights ---")
|
||||
planes100 = []
|
||||
for f in ac[:120]:
|
||||
cs = str(f.get("flight", "")).strip()
|
||||
lat = f.get("lat")
|
||||
lon = f.get("lon")
|
||||
if cs and lat and lon:
|
||||
planes100.append({"callsign": cs, "lat": lat, "lng": lon})
|
||||
planes100 = planes100[:100]
|
||||
|
||||
print("Built batch of", len(planes100), "planes")
|
||||
r3 = requests.post("https://api.adsb.lol/api/0/routeset", json={"planes": planes100}, timeout=15)
|
||||
print("Routeset HTTP:", r3.status_code)
|
||||
if r3.status_code == 200:
|
||||
result3 = r3.json()
|
||||
print("Routes found:", len(result3) if isinstance(result3, list) else "dict")
|
||||
else:
|
||||
print("Error body:", r3.text[:500])
|
||||
@@ -1,10 +0,0 @@
|
||||
from services.cctv_pipeline import init_db, TFLJamCamIngestor, LTASingaporeIngestor
|
||||
|
||||
init_db()
|
||||
print("Initialized DB")
|
||||
|
||||
tfl = TFLJamCamIngestor()
|
||||
print(f"TFL Cameras: {len(tfl.fetch_data())}")
|
||||
|
||||
nyc = LTASingaporeIngestor()
|
||||
print(f"SGP Cameras: {len(nyc.fetch_data())}")
|
||||
@@ -1,24 +0,0 @@
|
||||
import requests
|
||||
|
||||
try:
|
||||
print('Testing Seattle SDOT...')
|
||||
r_sea = requests.get('https://data.seattle.gov/resource/65fc-btcc.json?$limit=5', headers={'X-App-Token': 'f2jdDBw5JMXPFOQyk64SKlPkn'})
|
||||
print(r_sea.status_code)
|
||||
try:
|
||||
print(r_sea.json()[0])
|
||||
except:
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
print('Testing NYC 511...')
|
||||
r_nyc = requests.get('https://webcams.nyctmc.org/api/cameras', timeout=5)
|
||||
print(r_nyc.status_code)
|
||||
try:
|
||||
print(len(r_nyc.json()))
|
||||
print(r_nyc.json()[0])
|
||||
except:
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
@@ -1,10 +0,0 @@
|
||||
import json, urllib.request
|
||||
|
||||
data = json.loads(urllib.request.urlopen('http://localhost:8000/api/live-data').read())
|
||||
print(f"Commercial flights: {len(data.get('commercial_flights', []))}")
|
||||
print(f"Private flights: {len(data.get('private_flights', []))}")
|
||||
print(f"Private jets: {len(data.get('private_jets', []))}")
|
||||
print(f"Military flights: {len(data.get('military_flights', []))}")
|
||||
print(f"Tracked flights: {len(data.get('tracked_flights', []))}")
|
||||
print(f"Ships: {len(data.get('ships', []))}")
|
||||
print(f"CCTV: {len(data.get('cctv', []))}")
|
||||
@@ -1,38 +0,0 @@
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
data = json.loads(urllib.request.urlopen('http://localhost:8000/api/live-data').read())
|
||||
|
||||
# Tracked flights
|
||||
tracked = data.get('tracked_flights', [])
|
||||
print(f"=== TRACKED FLIGHTS: {len(tracked)} ===")
|
||||
if tracked:
|
||||
colors = {}
|
||||
for t in tracked:
|
||||
c = t.get('alert_color', 'NONE')
|
||||
colors[c] = colors.get(c, 0) + 1
|
||||
print(f" Colors: {colors}")
|
||||
print(f" Sample: {json.dumps(tracked[0], indent=2)[:500]}")
|
||||
|
||||
# Ships
|
||||
ships = data.get('ships', [])
|
||||
print(f"\n=== SHIPS: {len(ships)} ===")
|
||||
types = {}
|
||||
for s in ships:
|
||||
t = s.get('type', 'unknown')
|
||||
types[t] = types.get(t, 0) + 1
|
||||
print(f" Types: {types}")
|
||||
if ships:
|
||||
print(f" Sample: {json.dumps(ships[0], indent=2)[:300]}")
|
||||
|
||||
# News
|
||||
news = data.get('news', [])
|
||||
print(f"\n=== NEWS: {len(news)} ===")
|
||||
|
||||
# Earthquakes
|
||||
quakes = data.get('earthquakes', [])
|
||||
print(f"=== EARTHQUAKES: {len(quakes)} ===")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
@@ -1,23 +0,0 @@
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
data = json.loads(urllib.request.urlopen('http://localhost:8000/api/live-data').read())
|
||||
|
||||
tracked = data.get('tracked_flights', [])
|
||||
colors = {}
|
||||
for t in tracked:
|
||||
c = t.get('alert_color', 'NONE')
|
||||
colors[c] = colors.get(c, 0) + 1
|
||||
print(f"TRACKED FLIGHTS: {len(tracked)} | Colors: {colors}")
|
||||
|
||||
ships = data.get('ships', [])
|
||||
types = {}
|
||||
for s in ships:
|
||||
t = s.get('type', 'unknown')
|
||||
types[t] = types.get(t, 0) + 1
|
||||
print(f"SHIPS: {len(ships)} | Types: {types}")
|
||||
|
||||
print(f"NEWS: {len(data.get('news', []))} | EARTHQUAKES: {len(data.get('earthquakes', []))} | CCTV: {len(data.get('cctv', []))}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
@@ -1,10 +0,0 @@
|
||||
import requests, json
|
||||
|
||||
url = "https://api.us.socrata.com/api/catalog/v1?domains=data.cityofnewyork.us&q=camera"
|
||||
try:
|
||||
r = requests.get(url)
|
||||
res = r.json().get('results', [])
|
||||
for d in res:
|
||||
print(f"{d['resource']['id']} - {d['resource']['name']}")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
@@ -1,36 +0,0 @@
|
||||
import json, urllib.request
|
||||
|
||||
data = json.loads(urllib.request.urlopen('http://localhost:8000/api/live-data').read())
|
||||
|
||||
# Check trail data
|
||||
comm = data.get('commercial_flights', [])
|
||||
mil = data.get('military_flights', [])
|
||||
tracked = data.get('tracked_flights', [])
|
||||
pvt = data.get('private_flights', [])
|
||||
|
||||
# Count flights with trails
|
||||
comm_trails = [f for f in comm if f.get('trail') and len(f['trail']) > 0]
|
||||
mil_trails = [f for f in mil if f.get('trail') and len(f['trail']) > 0]
|
||||
tracked_trails = [f for f in tracked if f.get('trail') and len(f['trail']) > 0]
|
||||
pvt_trails = [f for f in pvt if f.get('trail') and len(f['trail']) > 0]
|
||||
|
||||
print(f"Commercial: {len(comm)} total, {len(comm_trails)} with trails")
|
||||
print(f"Military: {len(mil)} total, {len(mil_trails)} with trails")
|
||||
print(f"Tracked: {len(tracked)} total, {len(tracked_trails)} with trails")
|
||||
print(f"Private: {len(pvt)} total, {len(pvt_trails)} with trails")
|
||||
|
||||
# Show a sample trail
|
||||
if mil_trails:
|
||||
f = mil_trails[0]
|
||||
print(f"\nSample trail ({f['callsign']}):")
|
||||
print(f" Points: {len(f['trail'])}")
|
||||
if f['trail']:
|
||||
print(f" First: {f['trail'][0]}")
|
||||
print(f" Last: {f['trail'][-1]}")
|
||||
|
||||
# Check for grounded planes
|
||||
grounded = [f for f in comm if f.get('alt', 999) <= 500 and f.get('speed_knots', 999) < 30]
|
||||
print(f"\nGrounded commercial: {len(grounded)}")
|
||||
if grounded:
|
||||
g = grounded[0]
|
||||
print(f" Example: {g['callsign']} alt={g.get('alt')} speed={g.get('speed_knots')}")
|
||||
@@ -1,13 +0,0 @@
|
||||
import sqlite3
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect('cctv.db')
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT source_agency, COUNT(*) as count FROM cameras WHERE id LIKE 'OSM-%' GROUP BY source_agency")
|
||||
rows = cur.fetchall()
|
||||
print('OSM Cameras by City:')
|
||||
for r in rows:
|
||||
print(f"{r['source_agency']}: {r['count']}")
|
||||
except Exception as e:
|
||||
print('DB Error:', e)
|
||||
@@ -1,12 +0,0 @@
|
||||
import json
|
||||
import urllib.request
|
||||
import time
|
||||
|
||||
time.sleep(5)
|
||||
try:
|
||||
data = urllib.request.urlopen('http://localhost:8000/api/live-data').read()
|
||||
d = json.loads(data)
|
||||
ships = d.get('ships', [])
|
||||
print(f"Ships: {len(ships)}")
|
||||
except Exception as e:
|
||||
print(f"Error fetching API: {e}")
|
||||
@@ -1,13 +0,0 @@
|
||||
import requests, json
|
||||
|
||||
print("Searching Socrata NYC/Seattle Cameras...")
|
||||
try:
|
||||
url = "https://api.us.socrata.com/api/catalog/v1?q=traffic cameras&limit=100"
|
||||
r = requests.get(url)
|
||||
res = r.json().get('results', [])
|
||||
for d in res:
|
||||
domain = d['metadata']['domain'].lower()
|
||||
if 'seattle' in domain or 'newyork' in domain or 'nyc' in domain:
|
||||
print(f"{d['resource']['id']} - {d['resource']['name']} ({domain})")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Test trace endpoints with explicit output."""
|
||||
import json, subprocess
|
||||
|
||||
hex_code = "a34bac" # DOJ166
|
||||
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
date_str = now.strftime("%Y/%m/%d")
|
||||
hex_prefix = hex_code[-2:]
|
||||
|
||||
# Test 1: adsb.fi trace_full
|
||||
url1 = f"https://globe.adsb.fi/data/traces/{date_str}/{hex_prefix}/trace_full_{hex_code}.json"
|
||||
print(f"URL1: {url1}")
|
||||
r = subprocess.run(["curl.exe", "-s", "--max-time", "10", url1], capture_output=True, text=True, timeout=15)
|
||||
if r.stdout.strip().startswith("{"):
|
||||
data = json.loads(r.stdout)
|
||||
print(f"SUCCESS! Keys: {list(data.keys())}")
|
||||
if 'trace' in data:
|
||||
pts = data['trace']
|
||||
print(f"Trace points: {len(pts)}")
|
||||
if pts:
|
||||
print(f"FIRST (takeoff): {pts[0]}")
|
||||
print(f"LAST (now): {pts[-1]}")
|
||||
else:
|
||||
print(f"Not JSON (first 100 chars): {r.stdout[:100]}")
|
||||
# That response was behind cloudflare, try adsb.lol instead
|
||||
|
||||
# Test 2: adsb.lol hex lookup
|
||||
url2 = f"https://api.adsb.lol/v2/hex/{hex_code}"
|
||||
print(f"\nURL2: {url2}")
|
||||
r2 = subprocess.run(["curl.exe", "-s", "--max-time", "10", url2], capture_output=True, text=True, timeout=15)
|
||||
if r2.stdout.strip().startswith("{"):
|
||||
data = json.loads(r2.stdout)
|
||||
if 'ac' in data and data['ac']:
|
||||
ac = data['ac'][0]
|
||||
keys = sorted(ac.keys())
|
||||
print(f"All keys ({len(keys)}): {keys}")
|
||||
else:
|
||||
print(f"Not JSON: {r2.stdout[:100]}")
|
||||
|
||||
# Test 3: Try adsb.lol trace
|
||||
url3 = f"https://api.adsb.lol/trace/{hex_code}"
|
||||
print(f"\nURL3: {url3}")
|
||||
r3 = subprocess.run(["curl.exe", "-s", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "10", url3], capture_output=True, text=True, timeout=15)
|
||||
print(f"HTTP status: {r3.stdout}")
|
||||
|
||||
# Test 4: Try globe.adsb.lol format
|
||||
url4 = f"https://globe.adsb.lol/data/traces/{date_str}/{hex_prefix}/trace_full_{hex_code}.json"
|
||||
print(f"\nURL4: {url4}")
|
||||
r4 = subprocess.run(["curl.exe", "-s", "--max-time", "10", url4], capture_output=True, text=True, timeout=15)
|
||||
if r4.stdout.strip().startswith("{"):
|
||||
data = json.loads(r4.stdout)
|
||||
print(f"SUCCESS! Keys: {list(data.keys())}")
|
||||
if 'trace' in data:
|
||||
pts = data['trace']
|
||||
print(f"Trace points: {len(pts)}")
|
||||
if pts:
|
||||
print(f"FIRST (takeoff): {pts[0]}")
|
||||
print(f"LAST (now): {pts[-1]}")
|
||||
else:
|
||||
print(f"Response: {r4.stdout[:150]}")
|
||||
@@ -1,8 +0,0 @@
|
||||
import asyncio, websockets
|
||||
async def main():
|
||||
try:
|
||||
async with websockets.connect('wss://stream.aisstream.io/v0/stream') as ws:
|
||||
print('Connected to AIS Stream!')
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
asyncio.run(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,37 +0,0 @@
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
zip_name = 'ShadowBroker_v0.1.zip'
|
||||
|
||||
if os.path.exists(zip_name):
|
||||
try:
|
||||
os.remove(zip_name)
|
||||
except Exception as e:
|
||||
print(f"Failed to delete old zip: {e}")
|
||||
|
||||
def add_dir(zipf, dir_path, excludes):
|
||||
for root, dirs, files in os.walk(dir_path):
|
||||
dirs[:] = [d for d in dirs if d not in excludes]
|
||||
for f in files:
|
||||
file_path = os.path.join(root, f)
|
||||
zipf.write(file_path, arcname=file_path)
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(zip_name, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
print("Zipping backend...")
|
||||
add_dir(zipf, 'backend', {'venv', '__pycache__'})
|
||||
|
||||
print("Zipping frontend...")
|
||||
add_dir(zipf, 'frontend', {'node_modules', '.next'})
|
||||
|
||||
print("Zipping root files...")
|
||||
zipf.write('docker-compose.yml')
|
||||
zipf.write('start.bat')
|
||||
zipf.write('start.sh')
|
||||
zipf.write('README.md')
|
||||
|
||||
final_size = os.path.getsize(zip_name) / (1024 * 1024)
|
||||
print(f"\n✅ SUCCESS! Created {zip_name}. Final size: {final_size:.2f} MB")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ ERROR creating zip: {e}")
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml"
|
||||
ENGINE="${SHADOWBROKER_CONTAINER_ENGINE:-auto}"
|
||||
COMPOSE_ARGS=()
|
||||
COMPOSE_PROVIDER=""
|
||||
|
||||
find_docker_compose() {
|
||||
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=(docker compose)
|
||||
COMPOSE_PROVIDER="docker compose"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v docker-compose >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=(docker-compose)
|
||||
COMPOSE_PROVIDER="docker-compose"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
find_podman_compose() {
|
||||
if command -v podman >/dev/null 2>&1 && podman compose version >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=(podman compose)
|
||||
COMPOSE_PROVIDER="podman compose"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v podman-compose >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=(podman-compose)
|
||||
COMPOSE_PROVIDER="podman-compose"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ ! -f "$COMPOSE_FILE" ]; then
|
||||
echo "[!] ERROR: Missing compose file: $COMPOSE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--engine)
|
||||
if [ "$#" -lt 2 ]; then
|
||||
echo "[!] ERROR: --engine requires a value: docker, podman, or auto."
|
||||
exit 1
|
||||
fi
|
||||
ENGINE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--engine=*)
|
||||
ENGINE="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
COMPOSE_ARGS+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "${#COMPOSE_ARGS[@]}" -eq 0 ]; then
|
||||
COMPOSE_ARGS=(up -d)
|
||||
fi
|
||||
|
||||
if [ "${#COMPOSE_ARGS[@]}" -gt 0 ]; then
|
||||
last_index=$((${#COMPOSE_ARGS[@]} - 1))
|
||||
if [ "${COMPOSE_ARGS[$last_index]}" = "." ]; then
|
||||
echo "[*] Ignoring trailing '.' argument."
|
||||
unset "COMPOSE_ARGS[$last_index]"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${#COMPOSE_ARGS[@]}" -eq 0 ]; then
|
||||
COMPOSE_ARGS=(up -d)
|
||||
fi
|
||||
|
||||
COMPOSE_CMD=()
|
||||
|
||||
case "$ENGINE" in
|
||||
auto)
|
||||
find_docker_compose || find_podman_compose
|
||||
;;
|
||||
docker)
|
||||
find_docker_compose
|
||||
;;
|
||||
podman)
|
||||
find_podman_compose
|
||||
;;
|
||||
*)
|
||||
echo "[!] ERROR: Unsupported engine '$ENGINE'. Use docker, podman, or auto."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${#COMPOSE_CMD[@]}" -eq 0 ]; then
|
||||
echo "[!] ERROR: No supported compose command found for engine '$ENGINE'."
|
||||
echo " Install one of: docker compose, docker-compose, podman compose, or podman-compose."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$ENGINE" = "podman" ] && [ "$COMPOSE_PROVIDER" = "podman compose" ]; then
|
||||
echo "[*] Using (podman): ${COMPOSE_CMD[*]}"
|
||||
echo "[*] Note: 'podman compose' is Podman's wrapper command and may delegate to docker-compose based on your local Podman configuration."
|
||||
else
|
||||
echo "[*] Using ($ENGINE): ${COMPOSE_CMD[*]}"
|
||||
fi
|
||||
|
||||
"${COMPOSE_CMD[@]}" -f "$COMPOSE_FILE" "${COMPOSE_ARGS[@]}"
|
||||
+8
-5
@@ -8,11 +8,12 @@ services:
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- AISSTREAM_API_KEY=${AISSTREAM_API_KEY}
|
||||
- N2YO_API_KEY=${N2YO_API_KEY}
|
||||
- OPENSKY_USERNAME=${OPENSKY_USERNAME}
|
||||
- OPENSKY_PASSWORD=${OPENSKY_PASSWORD}
|
||||
- AIS_API_KEY=${AIS_API_KEY}
|
||||
- OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID}
|
||||
- OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET}
|
||||
- LTA_ACCOUNT_KEY=${LTA_ACCOUNT_KEY}
|
||||
# Override allowed CORS origins (comma-separated). Auto-detects LAN IPs if empty.
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
||||
volumes:
|
||||
- backend_data:/app/data
|
||||
restart: unless-stopped
|
||||
@@ -24,7 +25,9 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
# Points the Next.js server-side proxy at the backend container via Docker networking.
|
||||
# Change this if your backend runs on a different host or port.
|
||||
- BACKEND_URL=http://backend:8000
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
eslint.config.mjs
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
build_logs*.txt
|
||||
build_output.txt
|
||||
build_error.txt
|
||||
errors.txt
|
||||
server_logs*.txt
|
||||
+34
-11
@@ -1,19 +1,42 @@
|
||||
FROM node:18-alpine
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# NEXT_PUBLIC_* vars must exist at build time for Next.js to inline them.
|
||||
# Default empty = auto-detect from browser hostname at runtime.
|
||||
ARG NEXT_PUBLIC_API_URL=""
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
RUN npm run build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Next.js telemetry disable
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# Start development server
|
||||
CMD ["npm", "run", "dev:frontend"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
+36
-21
@@ -1,36 +1,51 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# ShadowBroker Frontend
|
||||
|
||||
## Getting Started
|
||||
Next.js 16 dashboard with MapLibre GL, Cesium, and Framer Motion.
|
||||
|
||||
First, run the development server:
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
npm install
|
||||
npm run dev # http://localhost:3000
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
## API URL Configuration
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
The frontend needs to reach the backend (default port `8000`). Resolution order:
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
1. **`NEXT_PUBLIC_API_URL`** env var — if set, used as-is (build-time, baked by Next.js)
|
||||
2. **Server-side (SSR)** — falls back to `http://localhost:8000`
|
||||
3. **Client-side (browser)** — auto-detects using `window.location.hostname:8000`
|
||||
|
||||
## Learn More
|
||||
### Common scenarios
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
| Scenario | Action needed |
|
||||
|----------|---------------|
|
||||
| Local dev (`localhost:3000` + `localhost:8000`) | None — auto-detected |
|
||||
| LAN access (`192.168.x.x:3000`) | None — auto-detected from browser hostname |
|
||||
| Public deploy (same host, port 8000) | None — auto-detected |
|
||||
| Backend on different port (e.g. `9096`) | Set `NEXT_PUBLIC_API_URL=http://host:9096` before build |
|
||||
| Backend on different host | Set `NEXT_PUBLIC_API_URL=http://backend-host:8000` before build |
|
||||
| Behind reverse proxy (e.g. `/api` path) | Set `NEXT_PUBLIC_API_URL=https://yourdomain.com` before build |
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
### Setting the variable
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
```bash
|
||||
# Shell (Linux/macOS)
|
||||
NEXT_PUBLIC_API_URL=http://myserver:8000 npm run build
|
||||
|
||||
## Deploy on Vercel
|
||||
# PowerShell (Windows)
|
||||
$env:NEXT_PUBLIC_API_URL="http://myserver:8000"; npm run build
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
# Docker Compose (set in .env file next to docker-compose.yml)
|
||||
NEXT_PUBLIC_API_URL=http://myserver:8000
|
||||
```
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
> **Note:** This is a build-time variable. Changing it requires rebuilding the frontend.
|
||||
|
||||
## Theming
|
||||
|
||||
Dark mode is the default. A light/dark toggle is available in the left panel toolbar.
|
||||
Theme preference is persisted in `localStorage` as `sb-theme` and applied via
|
||||
`data-theme` attribute on `<html>`. CSS variables in `globals.css` define all
|
||||
structural colors for both themes.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
|
||||
> frontend@0.1.0 build
|
||||
> next build
|
||||
|
||||
Γû▓ Next.js 16.1.6 (Turbopack)
|
||||
- Environments: .env.local
|
||||
|
||||
Creating an optimized production build ...
|
||||
Γ£ô Compiled successfully in 9.9s
|
||||
Running TypeScript ...
|
||||
Failed to compile.
|
||||
|
||||
Type error: Cannot find type definition file for 'mapbox__point-geometry'.
|
||||
The file is in the program because:
|
||||
Entry point for implicit type library 'mapbox__point-geometry'
|
||||
|
||||
Next.js build worker exited with code: 1 and signal: null
|
||||
@@ -0,0 +1,92 @@
|
||||
#0 building with "desktop-linux" instance using docker driver
|
||||
|
||||
#1 [internal] load build definition from Dockerfile
|
||||
#1 transferring dockerfile: 838B 0.0s done
|
||||
#1 DONE 0.0s
|
||||
|
||||
#2 [auth] library/node:pull token for registry-1.docker.io
|
||||
#2 DONE 0.0s
|
||||
|
||||
#3 [internal] load metadata for docker.io/library/node:18-alpine
|
||||
#3 DONE 0.6s
|
||||
|
||||
#4 [internal] load .dockerignore
|
||||
#4 transferring context: 207B 0.0s done
|
||||
#4 DONE 0.0s
|
||||
|
||||
#5 [base 1/1] FROM docker.io/library/node:18-alpine@sha256:8d6421d663b4c28fd3ebc498332f249011d118945588d0a35cb9bc4b8ca09d9e
|
||||
#5 resolve docker.io/library/node:18-alpine@sha256:8d6421d663b4c28fd3ebc498332f249011d118945588d0a35cb9bc4b8ca09d9e 0.0s done
|
||||
#5 DONE 0.0s
|
||||
|
||||
#6 [runner 2/8] RUN addgroup --system --gid 1001 nodejs
|
||||
#6 CACHED
|
||||
|
||||
#7 [builder 1/4] WORKDIR /app
|
||||
#7 CACHED
|
||||
|
||||
#8 [runner 3/8] RUN adduser --system --uid 1001 nextjs
|
||||
#8 CACHED
|
||||
|
||||
#9 [internal] load build context
|
||||
#9 transferring context: 3.49kB done
|
||||
#9 DONE 0.0s
|
||||
|
||||
#10 [deps 1/4] RUN apk add --no-cache libc6-compat
|
||||
#10 CACHED
|
||||
|
||||
#11 [deps 2/4] WORKDIR /app
|
||||
#11 CACHED
|
||||
|
||||
#12 [deps 3/4] COPY package*.json ./
|
||||
#12 CACHED
|
||||
|
||||
#13 [deps 4/4] RUN npm ci
|
||||
#13 CACHED
|
||||
|
||||
#14 [builder 2/4] COPY --from=deps /app/node_modules ./node_modules
|
||||
#14 CACHED
|
||||
|
||||
#15 [builder 3/4] COPY . .
|
||||
#15 DONE 0.0s
|
||||
|
||||
#16 [builder 4/4] RUN npm run build
|
||||
#16 1.391
|
||||
#16 1.391 > frontend@0.2.0 build
|
||||
#16 1.391 > next build
|
||||
#16 1.391
|
||||
#16 1.821 You are using Node.js 18.20.8. For Next.js, Node.js version ">=20.9.0" is required.
|
||||
#16 1.837 npm notice
|
||||
#16 1.837 npm notice New major version of npm available! 10.8.2 -> 11.11.0
|
||||
#16 1.837 npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.11.0
|
||||
#16 1.837 npm notice To update run: npm install -g npm@11.11.0
|
||||
#16 1.837 npm notice
|
||||
#16 ERROR: process "/bin/sh -c npm run build" did not complete successfully: exit code: 1
|
||||
------
|
||||
> [builder 4/4] RUN npm run build:
|
||||
1.391
|
||||
1.391 > frontend@0.2.0 build
|
||||
1.391 > next build
|
||||
1.391
|
||||
1.821 You are using Node.js 18.20.8. For Next.js, Node.js version ">=20.9.0" is required.
|
||||
1.837 npm notice
|
||||
1.837 npm notice New major version of npm available! 10.8.2 -> 11.11.0
|
||||
1.837 npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.11.0
|
||||
1.837 npm notice To update run: npm install -g npm@11.11.0
|
||||
1.837 npm notice
|
||||
------
|
||||
|
||||
5 warnings found (use docker --debug to expand):
|
||||
- LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format (line 13)
|
||||
- LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format (line 18)
|
||||
- LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format (line 19)
|
||||
- LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format (line 35)
|
||||
- LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format (line 36)
|
||||
Dockerfile:14
|
||||
--------------------
|
||||
12 | COPY . .
|
||||
13 | ENV NEXT_TELEMETRY_DISABLED 1
|
||||
14 | >>> RUN npm run build
|
||||
15 |
|
||||
16 | FROM base AS runner
|
||||
--------------------
|
||||
ERROR: failed to build: failed to solve: process "/bin/sh -c npm run build" did not complete successfully: exit code: 1
|
||||
Binary file not shown.
@@ -1,7 +1,19 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
// /api/* requests are proxied to the backend by the catch-all route handler at
|
||||
// src/app/api/[...path]/route.ts, which reads BACKEND_URL at request time.
|
||||
// Do NOT add rewrites for /api/* here — next.config is evaluated at build time,
|
||||
// so any URL baked in here ignores the runtime BACKEND_URL env var.
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['react-map-gl', 'mapbox-gl', 'maplibre-gl'],
|
||||
output: "standalone",
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+74
-165
@@ -1,24 +1,21 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/mapbox-gl": "^3.4.1",
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"framer-motion": "^12.34.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"hls.js": "^1.6.15",
|
||||
"lucide-react": "^0.575.0",
|
||||
"mapbox-gl": "^3.19.0",
|
||||
"maplibre-gl": "^4.7.1",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"satellite.js": "^6.0.2"
|
||||
},
|
||||
@@ -1054,12 +1051,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/mapbox-gl-supported": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-3.0.0.tgz",
|
||||
"integrity": "sha512-2XghOwu16ZwPJLOFVuIOaLbN0iKMn867evzXFyf0P22dqugezfJwLmdanAgU25ITvz1TvOfVP4jsDImlDJzcWg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
@@ -1078,17 +1069,6 @@
|
||||
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
|
||||
"integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^4.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/whoots-js": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
|
||||
@@ -1303,17 +1283,6 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -1553,6 +1522,70 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
|
||||
@@ -1648,15 +1681,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mapbox__point-geometry": {
|
||||
"version": "1.0.87",
|
||||
"resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-1.0.87.tgz",
|
||||
@@ -1678,15 +1702,6 @@
|
||||
"@types/pbf": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mapbox-gl": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/mapbox-gl/-/mapbox-gl-3.4.1.tgz",
|
||||
"integrity": "sha512-NsGKKtgW93B+UaLPti6B7NwlxYlES5DpV5Gzj9F75rK5ALKsqSk15CiEHbOnTr09RGbr6ZYiCdI+59NNNcAImg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
|
||||
@@ -2863,12 +2878,6 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cheap-ruler": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cheap-ruler/-/cheap-ruler-4.0.0.tgz",
|
||||
"integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/client-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
@@ -2980,12 +2989,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/csscolorparser": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz",
|
||||
"integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -4272,12 +4275,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/grid-index": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz",
|
||||
"integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/has-bigints": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||
@@ -4389,6 +4386,12 @@
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.15",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
|
||||
"integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -5101,12 +5104,6 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@@ -5447,45 +5444,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-gl": {
|
||||
"version": "3.19.0",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.19.0.tgz",
|
||||
"integrity": "sha512-SFObIgdxN0b6hZNsRxSUmQWdVW9q9GM2gw4McgFbycyhekew7BZIh8V57pEERDWlI9x/5SxxraTit5Cf0hm9OA==",
|
||||
"license": "SEE LICENSE IN LICENSE.txt",
|
||||
"workspaces": [
|
||||
"src/style-spec",
|
||||
"test/build/vite",
|
||||
"test/build/webpack",
|
||||
"test/build/typings"
|
||||
],
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||
"@mapbox/mapbox-gl-supported": "^3.0.0",
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.0.6",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/geojson-vt": "^3.2.5",
|
||||
"@types/mapbox__point-geometry": "^1.0.87",
|
||||
"@types/pbf": "^3.0.5",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"cheap-ruler": "^4.0.0",
|
||||
"csscolorparser": "~1.0.3",
|
||||
"earcut": "^3.0.1",
|
||||
"geojson-vt": "^4.0.2",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"grid-index": "^1.1.0",
|
||||
"kdbush": "^4.0.2",
|
||||
"martinez-polygon-clipping": "^0.8.1",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^4.0.1",
|
||||
"potpack": "^2.0.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"supercluster": "^8.0.1",
|
||||
"tinyqueue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz",
|
||||
@@ -5587,17 +5545,6 @@
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/martinez-polygon-clipping": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/martinez-polygon-clipping/-/martinez-polygon-clipping-0.8.1.tgz",
|
||||
"integrity": "sha512-9PLLMzMPI6ihHox4Ns6LpVBLpRc7sbhULybZ/wyaY8sY3ECNe2+hxm1hA2/9bEEpRrdpjoeduBuZLg2aq1cSIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"robust-predicates": "^2.0.4",
|
||||
"splaytree": "^0.1.4",
|
||||
"tinyqueue": "3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -6061,18 +6008,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -6230,20 +6165,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-map-gl": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-8.1.0.tgz",
|
||||
@@ -6383,12 +6304,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz",
|
||||
"integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
@@ -6784,12 +6699,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/splaytree": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/splaytree/-/splaytree-0.1.4.tgz",
|
||||
"integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/split-string": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:frontend\" \"cd ../backend && python -m uvicorn main:app --reload\"",
|
||||
"dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"",
|
||||
"dev:frontend": "next dev",
|
||||
"dev:backend": "cd ../backend && venv\\Scripts\\python.exe main.py",
|
||||
"dev:backend": "node ../start-backend.js",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/mapbox-gl": "^3.4.1",
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"framer-motion": "^12.34.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"hls.js": "^1.6.15",
|
||||
"lucide-react": "^0.575.0",
|
||||
"mapbox-gl": "^3.19.0",
|
||||
"maplibre-gl": "^4.7.1",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"satellite.js": "^6.0.2"
|
||||
},
|
||||
@@ -37,4 +34,4 @@
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,8 +1,40 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--background: #000000;
|
||||
--foreground: #ededed;
|
||||
--bg-primary: #000000;
|
||||
--bg-secondary: rgb(17, 24, 39);
|
||||
--bg-tertiary: rgb(31, 41, 55);
|
||||
--bg-panel: rgba(17, 24, 39, 0.8);
|
||||
--border-primary: rgb(55, 65, 81);
|
||||
--border-secondary: rgb(75, 85, 99);
|
||||
--text-primary: rgb(243, 244, 246);
|
||||
--text-secondary: rgb(156, 163, 175);
|
||||
--text-muted: rgb(107, 114, 128);
|
||||
--text-heading: rgb(236, 254, 255);
|
||||
--hover-accent: rgba(8, 51, 68, 0.2);
|
||||
--scrollbar-thumb: rgba(100, 116, 139, 0.3);
|
||||
--scrollbar-thumb-hover: rgba(100, 116, 139, 0.5);
|
||||
}
|
||||
|
||||
/* Light theme: only the map basemap changes — UI stays dark */
|
||||
[data-theme="light"] {
|
||||
--background: #000000;
|
||||
--foreground: #ededed;
|
||||
--bg-primary: #000000;
|
||||
--bg-secondary: rgb(17, 24, 39);
|
||||
--bg-tertiary: rgb(31, 41, 55);
|
||||
--bg-panel: rgba(17, 24, 39, 0.8);
|
||||
--border-primary: rgb(55, 65, 81);
|
||||
--border-secondary: rgb(75, 85, 99);
|
||||
--text-primary: rgb(243, 244, 246);
|
||||
--text-secondary: rgb(156, 163, 175);
|
||||
--text-muted: rgb(107, 114, 128);
|
||||
--text-heading: rgb(236, 254, 255);
|
||||
--hover-accent: rgba(8, 51, 68, 0.2);
|
||||
--scrollbar-thumb: rgba(100, 116, 139, 0.3);
|
||||
--scrollbar-thumb-hover: rgba(100, 116, 139, 0.5);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -12,13 +44,6 @@
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
@@ -35,12 +60,12 @@ body {
|
||||
}
|
||||
|
||||
.styled-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 116, 139, 0.3);
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.styled-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 116, 139, 0.5);
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
.styled-scrollbar {
|
||||
@@ -70,4 +95,4 @@ body {
|
||||
/* Keep popups fully bright and interactive above the dimmed canvas */
|
||||
.map-focus-active .maplibregl-popup {
|
||||
z-index: 10 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { ThemeProvider } from "@/lib/ThemeContext";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -29,10 +30,10 @@ export default function RootLayout({
|
||||
<link href="https://cesium.com/downloads/cesiumjs/releases/1.115/Build/Cesium/Widgets/widgets.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-black`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-[var(--bg-primary)]`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{children}
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+225
-34
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import dynamic from 'next/dynamic';
|
||||
import { motion } from "framer-motion";
|
||||
@@ -9,15 +10,112 @@ import NewsFeed from "@/components/NewsFeed";
|
||||
import MarketsPanel from "@/components/MarketsPanel";
|
||||
import FilterPanel from "@/components/FilterPanel";
|
||||
import FindLocateBar from "@/components/FindLocateBar";
|
||||
import TopRightControls from "@/components/TopRightControls";
|
||||
import RadioInterceptPanel from "@/components/RadioInterceptPanel";
|
||||
import SettingsPanel from "@/components/SettingsPanel";
|
||||
import MapLegend from "@/components/MapLegend";
|
||||
import ScaleBar from "@/components/ScaleBar";
|
||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||
import OnboardingModal, { useOnboarding } from "@/components/OnboardingModal";
|
||||
import ChangelogModal, { useChangelog } from "@/components/ChangelogModal";
|
||||
|
||||
// Use dynamic loads for Maplibre to avoid SSR window is not defined errors
|
||||
const MaplibreViewer = dynamic(() => import('@/components/MaplibreViewer'), { ssr: false });
|
||||
|
||||
/* ── LOCATE BAR ── coordinate / place-name search above bottom status bar ── */
|
||||
function LocateBar({ onLocate }: { onLocate: (lat: number, lng: number) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
const [results, setResults] = useState<{ label: string; lat: number; lng: number }[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => { if (open) inputRef.current?.focus(); }, [open]);
|
||||
|
||||
// Parse raw coordinate input: "31.8, 34.8" or "31.8 34.8" or "-12.3, 45.6"
|
||||
const parseCoords = (s: string): { lat: number; lng: number } | null => {
|
||||
const m = s.trim().match(/^([+-]?\d+\.?\d*)[,\s]+([+-]?\d+\.?\d*)$/);
|
||||
if (!m) return null;
|
||||
const lat = parseFloat(m[1]), lng = parseFloat(m[2]);
|
||||
if (lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) return { lat, lng };
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSearch = async (q: string) => {
|
||||
setValue(q);
|
||||
// Check for raw coordinates first
|
||||
const coords = parseCoords(q);
|
||||
if (coords) {
|
||||
setResults([{ label: `${coords.lat.toFixed(4)}, ${coords.lng.toFixed(4)}`, ...coords }]);
|
||||
return;
|
||||
}
|
||||
// Geocode with Nominatim (debounced)
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (q.trim().length < 2) { setResults([]); return; }
|
||||
timerRef.current = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=5`, {
|
||||
headers: { 'Accept-Language': 'en' },
|
||||
});
|
||||
const data = await res.json();
|
||||
setResults(data.map((r: any) => ({ label: r.display_name, lat: parseFloat(r.lat), lng: parseFloat(r.lon) })));
|
||||
} catch { setResults([]); }
|
||||
setLoading(false);
|
||||
}, 350);
|
||||
};
|
||||
|
||||
const handleSelect = (r: { lat: number; lng: number }) => {
|
||||
onLocate(r.lat, r.lng);
|
||||
setOpen(false);
|
||||
setValue('');
|
||||
setResults([]);
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center gap-1.5 bg-[var(--bg-primary)]/60 backdrop-blur-md border border-[var(--border-primary)] rounded-lg px-3 py-1.5 text-[9px] font-mono tracking-[0.15em] text-[var(--text-muted)] hover:text-cyan-400 hover:border-cyan-800 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
LOCATE
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-[420px]">
|
||||
<div className="flex items-center gap-2 bg-[var(--bg-primary)]/80 backdrop-blur-md border border-cyan-800/60 rounded-lg px-3 py-2 shadow-[0_0_20px_rgba(0,255,255,0.1)]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-cyan-500 flex-shrink-0"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') { setOpen(false); setValue(''); setResults([]); } if (e.key === 'Enter' && results.length > 0) handleSelect(results[0]); }}
|
||||
placeholder="Enter coordinates (31.8, 34.8) or place name..."
|
||||
className="flex-1 bg-transparent text-[10px] text-[var(--text-primary)] font-mono tracking-wider outline-none placeholder:text-[var(--text-muted)]"
|
||||
/>
|
||||
{loading && <div className="w-3 h-3 border border-cyan-500 border-t-transparent rounded-full animate-spin" />}
|
||||
<button onClick={() => { setOpen(false); setValue(''); setResults([]); }} className="text-[var(--text-muted)] hover:text-[var(--text-primary)]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 right-0 mb-1 bg-[var(--bg-secondary)]/95 backdrop-blur-md border border-[var(--border-primary)] rounded-lg overflow-hidden shadow-[0_-8px_30px_rgba(0,0,0,0.4)] max-h-[200px] overflow-y-auto styled-scrollbar">
|
||||
{results.map((r, i) => (
|
||||
<button key={i} onClick={() => handleSelect(r)} className="w-full text-left px-3 py-2 hover:bg-cyan-950/40 transition-colors border-b border-[var(--border-primary)]/50 last:border-0 flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-cyan-500 flex-shrink-0"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
<span className="text-[9px] text-[var(--text-secondary)] font-mono truncate">{r.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const dataRef = useRef<any>({});
|
||||
const [dataVersion, setDataVersion] = useState(0);
|
||||
@@ -46,19 +144,36 @@ export default function Dashboard() {
|
||||
global_incidents: true,
|
||||
day_night: true,
|
||||
gps_jamming: true,
|
||||
gibs_imagery: false,
|
||||
highres_satellite: false,
|
||||
kiwisdr: false,
|
||||
firms: false,
|
||||
internet_outages: false,
|
||||
datacenters: false,
|
||||
});
|
||||
|
||||
// NASA GIBS satellite imagery state
|
||||
const [gibsDate, setGibsDate] = useState<string>(() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
});
|
||||
const [gibsOpacity, setGibsOpacity] = useState(0.6);
|
||||
|
||||
const [effects, setEffects] = useState({
|
||||
bloom: true,
|
||||
});
|
||||
|
||||
const [activeStyle, setActiveStyle] = useState('DEFAULT');
|
||||
const stylesList = ['DEFAULT', 'FLIR', 'NVG', 'CRT'];
|
||||
const stylesList = ['DEFAULT', 'SATELLITE'];
|
||||
|
||||
const cycleStyle = () => {
|
||||
setActiveStyle((prev) => {
|
||||
const idx = stylesList.indexOf(prev);
|
||||
return stylesList[(idx + 1) % stylesList.length];
|
||||
const next = stylesList[(idx + 1) % stylesList.length];
|
||||
// Auto-toggle High-Res Satellite layer with SATELLITE style
|
||||
setActiveLayers((l: any) => ({ ...l, highres_satellite: next === 'SATELLITE' }));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -74,6 +189,11 @@ export default function Dashboard() {
|
||||
// Mouse coordinate + reverse geocoding state
|
||||
const [mouseCoords, setMouseCoords] = useState<{ lat: number, lng: number } | null>(null);
|
||||
const [locationLabel, setLocationLabel] = useState('');
|
||||
|
||||
// Onboarding & connection status
|
||||
const { showOnboarding, setShowOnboarding } = useOnboarding();
|
||||
const { showChangelog, setShowChangelog } = useChangelog();
|
||||
const [backendStatus, setBackendStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
|
||||
const geocodeCache = useRef<Map<string, string>>(new Map());
|
||||
const geocodeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -146,11 +266,19 @@ export default function Dashboard() {
|
||||
setRegionDossierLoading(true);
|
||||
setRegionDossier(null);
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8000/api/region-dossier?lat=${coords.lat}&lng=${coords.lng}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setRegionDossier(data);
|
||||
const [dossierRes, sentinelRes] = await Promise.allSettled([
|
||||
fetch(`${API_BASE}/api/region-dossier?lat=${coords.lat}&lng=${coords.lng}`),
|
||||
fetch(`${API_BASE}/api/sentinel2/search?lat=${coords.lat}&lng=${coords.lng}`),
|
||||
]);
|
||||
let dossierData: any = {};
|
||||
if (dossierRes.status === 'fulfilled' && dossierRes.value.ok) {
|
||||
dossierData = await dossierRes.value.json();
|
||||
}
|
||||
let sentinelData = null;
|
||||
if (sentinelRes.status === 'fulfilled' && sentinelRes.value.ok) {
|
||||
sentinelData = await sentinelRes.value.json();
|
||||
}
|
||||
setRegionDossier({ ...dossierData, sentinel2: sentinelData });
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch region dossier", e);
|
||||
} finally {
|
||||
@@ -171,29 +299,40 @@ export default function Dashboard() {
|
||||
const slowEtag = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Track whether we've received substantial data yet (backend may still be starting up)
|
||||
let hasData = false;
|
||||
let fastTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
let slowTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const fetchFastData = async () => {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (fastEtag.current) headers['If-None-Match'] = fastEtag.current;
|
||||
const res = await fetch("http://localhost:8000/api/live-data/fast", { headers });
|
||||
if (res.status === 304) return; // Data unchanged, skip update
|
||||
const res = await fetch(`${API_BASE}/api/live-data/fast`, { headers });
|
||||
if (res.status === 304) { setBackendStatus('connected'); scheduleNext('fast'); return; }
|
||||
if (res.ok) {
|
||||
setBackendStatus('connected');
|
||||
fastEtag.current = res.headers.get('etag') || null;
|
||||
const json = await res.json();
|
||||
dataRef.current = { ...dataRef.current, ...json };
|
||||
setDataVersion(v => v + 1);
|
||||
// Check if we got real data (backend finished loading)
|
||||
const flights = json.commercial_flights?.length || 0;
|
||||
if (flights > 100) hasData = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed fetching fast live data", e);
|
||||
setBackendStatus('disconnected');
|
||||
}
|
||||
scheduleNext('fast');
|
||||
};
|
||||
|
||||
const fetchSlowData = async () => {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (slowEtag.current) headers['If-None-Match'] = slowEtag.current;
|
||||
const res = await fetch("http://localhost:8000/api/live-data/slow", { headers });
|
||||
if (res.status === 304) return;
|
||||
const res = await fetch(`${API_BASE}/api/live-data/slow`, { headers });
|
||||
if (res.status === 304) { scheduleNext('slow'); return; }
|
||||
if (res.ok) {
|
||||
slowEtag.current = res.headers.get('etag') || null;
|
||||
const json = await res.json();
|
||||
@@ -203,24 +342,31 @@ export default function Dashboard() {
|
||||
} catch (e) {
|
||||
console.error("Failed fetching slow live data", e);
|
||||
}
|
||||
scheduleNext('slow');
|
||||
};
|
||||
|
||||
// Adaptive polling: retry every 3s during startup, back off to normal cadence once data arrives
|
||||
const scheduleNext = (tier: 'fast' | 'slow') => {
|
||||
if (tier === 'fast') {
|
||||
const delay = hasData ? 15000 : 3000; // 3s startup retry → 15s steady state
|
||||
fastTimerId = setTimeout(fetchFastData, delay);
|
||||
} else {
|
||||
const delay = hasData ? 120000 : 5000; // 5s startup retry → 120s steady state
|
||||
slowTimerId = setTimeout(fetchSlowData, delay);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFastData();
|
||||
fetchSlowData();
|
||||
|
||||
// Fast polling: 15s (backend updates every 60s — polling more often just yields 304s)
|
||||
// Slow polling: 60s (backend updates every 30min)
|
||||
const fastInterval = setInterval(fetchFastData, 15000);
|
||||
const slowInterval = setInterval(fetchSlowData, 60000);
|
||||
|
||||
return () => {
|
||||
clearInterval(fastInterval);
|
||||
clearInterval(slowInterval);
|
||||
if (fastTimerId) clearTimeout(fastTimerId);
|
||||
if (slowTimerId) clearTimeout(slowTimerId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="fixed inset-0 w-full h-full bg-black overflow-hidden font-sans">
|
||||
<main className="fixed inset-0 w-full h-full bg-[var(--bg-primary)] overflow-hidden font-sans">
|
||||
|
||||
{/* MAPLIBRE WEBGL OVERLAY */}
|
||||
<ErrorBoundary name="Map">
|
||||
@@ -232,6 +378,8 @@ export default function Dashboard() {
|
||||
onEntityClick={setSelectedEntity}
|
||||
selectedEntity={selectedEntity}
|
||||
flyToLocation={flyToLocation}
|
||||
gibsDate={gibsDate}
|
||||
gibsOpacity={gibsOpacity}
|
||||
isEavesdropping={isEavesdropping}
|
||||
onEavesdropClick={setEavesdropLocation}
|
||||
onCameraMove={setCameraCenter}
|
||||
@@ -266,10 +414,10 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-2xl font-bold tracking-[0.4em] text-white flex items-center gap-3" style={{ fontFamily: 'monospace' }}>
|
||||
<h1 className="text-2xl font-bold tracking-[0.4em] text-[var(--text-primary)] flex items-center gap-3" style={{ fontFamily: 'monospace' }}>
|
||||
S H A D O W <span className="text-cyan-400">B R O K E R</span>
|
||||
</h1>
|
||||
<span className="text-[9px] text-gray-500 font-mono tracking-[0.3em] mt-1 ml-1">GLOBAL THREAT INTERCEPT</span>
|
||||
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-[0.3em] mt-1 ml-1">GLOBAL THREAT INTERCEPT</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -279,7 +427,7 @@ export default function Dashboard() {
|
||||
</div>
|
||||
|
||||
{/* SYSTEM METRICS TOP RIGHT */}
|
||||
<div className="absolute top-2 right-6 text-[9px] flex flex-col items-end font-mono tracking-widest text-gray-600 z-[200] pointer-events-none">
|
||||
<div className="absolute top-2 right-6 text-[9px] flex flex-col items-end font-mono tracking-widest text-[var(--text-muted)] z-[200] pointer-events-none">
|
||||
<div>RTX</div>
|
||||
<div>VSR</div>
|
||||
</div>
|
||||
@@ -287,7 +435,7 @@ export default function Dashboard() {
|
||||
{/* LEFT HUD CONTAINER */}
|
||||
<div className="absolute left-6 top-24 bottom-6 w-80 flex flex-col gap-6 z-[200] pointer-events-none">
|
||||
{/* LEFT PANEL - DATA LAYERS */}
|
||||
<WorldviewLeftPanel data={data} activeLayers={activeLayers} setActiveLayers={setActiveLayers} onSettingsClick={() => setSettingsOpen(true)} onLegendClick={() => setLegendOpen(true)} />
|
||||
<WorldviewLeftPanel data={data} activeLayers={activeLayers} setActiveLayers={setActiveLayers} onSettingsClick={() => setSettingsOpen(true)} onLegendClick={() => setLegendOpen(true)} gibsDate={gibsDate} setGibsDate={setGibsDate} gibsOpacity={gibsOpacity} setGibsOpacity={setGibsOpacity} onEntityClick={setSelectedEntity} onFlyTo={(lat, lng) => setFlyToLocation({ lat, lng, ts: Date.now() })} />
|
||||
|
||||
{/* LEFT BOTTOM - DISPLAY CONFIG */}
|
||||
<WorldviewRightPanel effects={effects} setEffects={setEffects} setUiVisible={setUiVisible} />
|
||||
@@ -295,6 +443,8 @@ export default function Dashboard() {
|
||||
|
||||
{/* RIGHT HUD CONTAINER */}
|
||||
<div className="absolute right-6 top-24 bottom-6 w-80 flex flex-col gap-4 z-[200] pointer-events-auto overflow-y-auto styled-scrollbar pr-2">
|
||||
<TopRightControls />
|
||||
|
||||
{/* FIND / LOCATE */}
|
||||
<div className="flex-shrink-0">
|
||||
<FindLocateBar
|
||||
@@ -327,6 +477,7 @@ export default function Dashboard() {
|
||||
setIsEavesdropping={setIsEavesdropping}
|
||||
eavesdropLocation={eavesdropLocation}
|
||||
cameraCenter={cameraCenter}
|
||||
selectedEntity={selectedEntity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -341,46 +492,64 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOTTOM CENTER COORDINATE / LOCATION BAR */}
|
||||
<motion.div
|
||||
{/* BOTTOM CENTER COORDINATE / LOCATION BAR — hidden when Sentinel-2 imagery overlay is open */}
|
||||
{!(selectedEntity?.type === 'region_dossier' && regionDossier?.sentinel2) && <motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 1, duration: 1 }}
|
||||
className="absolute bottom-6 left-1/2 -translate-x-1/2 z-[200] pointer-events-auto"
|
||||
className="absolute bottom-6 left-1/2 -translate-x-1/2 z-[200] pointer-events-auto flex flex-col items-center gap-2"
|
||||
>
|
||||
{/* LOCATE BAR — search by coordinates or place name */}
|
||||
<LocateBar onLocate={(lat, lng) => setFlyToLocation({ lat, lng, ts: Date.now() })} />
|
||||
|
||||
<div
|
||||
className="bg-black/60 backdrop-blur-md border border-gray-800 rounded-xl px-6 py-2.5 flex items-center gap-6 shadow-[0_4px_30px_rgba(0,0,0,0.5)] border-b-2 border-b-cyan-900 cursor-pointer"
|
||||
className="bg-[var(--bg-primary)]/60 backdrop-blur-md border border-[var(--border-primary)] rounded-xl px-6 py-2.5 flex items-center gap-6 shadow-[0_4px_30px_rgba(0,0,0,0.2)] border-b-2 border-b-cyan-900 cursor-pointer"
|
||||
onClick={cycleStyle}
|
||||
>
|
||||
{/* Coordinates */}
|
||||
<div className="flex flex-col items-center min-w-[120px]">
|
||||
<div className="text-[8px] text-gray-600 font-mono tracking-[0.2em]">COORDINATES</div>
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">COORDINATES</div>
|
||||
<div className="text-[11px] text-cyan-400 font-mono font-bold tracking-wide">
|
||||
{mouseCoords ? `${mouseCoords.lat.toFixed(4)}, ${mouseCoords.lng.toFixed(4)}` : '0.0000, 0.0000'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-8 bg-gray-700" />
|
||||
<div className="w-px h-8 bg-[var(--border-primary)]" />
|
||||
|
||||
{/* Location name */}
|
||||
<div className="flex flex-col items-center min-w-[180px] max-w-[320px]">
|
||||
<div className="text-[8px] text-gray-600 font-mono tracking-[0.2em]">LOCATION</div>
|
||||
<div className="text-[10px] text-gray-300 font-mono truncate max-w-[320px]">
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">LOCATION</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] font-mono truncate max-w-[320px]">
|
||||
{locationLabel || 'Hover over map...'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-8 bg-gray-700" />
|
||||
<div className="w-px h-8 bg-[var(--border-primary)]" />
|
||||
|
||||
{/* Style preset (compact) */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-[8px] text-gray-600 font-mono tracking-[0.2em]">STYLE</div>
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">STYLE</div>
|
||||
<div className="text-[11px] text-cyan-400 font-mono font-bold">{activeStyle}</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-8 bg-[var(--border-primary)]" />
|
||||
|
||||
{/* Space Weather */}
|
||||
<div className="flex flex-col items-center" title={`Kp Index: ${data?.space_weather?.kp_index ?? 'N/A'}`}>
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">SOLAR</div>
|
||||
<div className={`text-[11px] font-mono font-bold ${
|
||||
(data?.space_weather?.kp_index ?? 0) >= 5 ? 'text-red-400' :
|
||||
(data?.space_weather?.kp_index ?? 0) >= 4 ? 'text-yellow-400' :
|
||||
'text-green-400'
|
||||
}`}>
|
||||
{data?.space_weather?.kp_text || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -388,7 +557,7 @@ export default function Dashboard() {
|
||||
{!uiVisible && (
|
||||
<button
|
||||
onClick={() => setUiVisible(true)}
|
||||
className="absolute bottom-6 right-6 z-[200] bg-black/60 backdrop-blur-md border border-gray-800 rounded px-4 py-2 text-[10px] font-mono tracking-widest text-cyan-500 hover:text-cyan-300 hover:border-cyan-800 transition-colors pointer-events-auto"
|
||||
className="absolute bottom-6 right-6 z-[200] bg-[var(--bg-primary)]/60 backdrop-blur-md border border-[var(--border-primary)] rounded px-4 py-2 text-[10px] font-mono tracking-widest text-cyan-500 hover:text-cyan-300 hover:border-cyan-800 transition-colors pointer-events-auto"
|
||||
>
|
||||
RESTORE UI
|
||||
</button>
|
||||
@@ -425,6 +594,28 @@ export default function Dashboard() {
|
||||
{/* MAP LEGEND */}
|
||||
<MapLegend isOpen={legendOpen} onClose={() => setLegendOpen(false)} />
|
||||
|
||||
{/* ONBOARDING MODAL */}
|
||||
{showOnboarding && (
|
||||
<OnboardingModal
|
||||
onClose={() => setShowOnboarding(false)}
|
||||
onOpenSettings={() => { setShowOnboarding(false); setSettingsOpen(true); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* v0.4 CHANGELOG MODAL — shows once per version after onboarding */}
|
||||
{!showOnboarding && showChangelog && (
|
||||
<ChangelogModal onClose={() => setShowChangelog(false)} />
|
||||
)}
|
||||
|
||||
{/* BACKEND DISCONNECTED BANNER */}
|
||||
{backendStatus === 'disconnected' && (
|
||||
<div className="absolute top-0 left-0 right-0 z-[9000] flex items-center justify-center py-2 bg-red-950/90 border-b border-red-500/40 backdrop-blur-sm">
|
||||
<span className="text-[10px] font-mono tracking-widest text-red-400">
|
||||
BACKEND OFFLINE — Cannot reach backend server. Check that the backend container is running and BACKEND_URL is correct.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -171,16 +171,16 @@ export default function AdvancedFilterModal({
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.92 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`bg-[#0a0e14]/95 backdrop-blur-xl border ${c.border} rounded-xl shadow-[0_8px_60px_rgba(0,0,0,0.8)] flex flex-col font-mono overflow-hidden`}
|
||||
className={`bg-[var(--bg-secondary)]/95 backdrop-blur-xl border ${c.border} rounded-xl shadow-[0_8px_60px_rgba(0,0,0,0.3)] flex flex-col font-mono overflow-hidden`}
|
||||
style={{ maxHeight: '70vh' }}
|
||||
>
|
||||
{/* ── Title Bar (Draggable) ── */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 cursor-grab active:cursor-grabbing border-b border-gray-800/60 select-none flex-shrink-0"
|
||||
className="flex items-center justify-between px-4 py-3 cursor-grab active:cursor-grabbing border-b border-[var(--border-primary)]/60 select-none flex-shrink-0"
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<GripHorizontal size={14} className="text-gray-600" />
|
||||
<GripHorizontal size={14} className="text-[var(--text-muted)]" />
|
||||
{icon}
|
||||
<span className={`text-[11px] ${c.text} tracking-[0.25em] font-semibold`}>{title}</span>
|
||||
{totalSelected > 0 && (
|
||||
@@ -189,14 +189,14 @@ export default function AdvancedFilterModal({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-600 hover:text-white transition-colors p-1 rounded hover:bg-gray-800">
|
||||
<button onClick={onClose} className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors p-1 rounded hover:bg-[var(--bg-tertiary)]">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Tab Bar (for multi-field categories) ── */}
|
||||
{fields.length > 1 && (
|
||||
<div className="flex border-b border-gray-800/40 px-3 pt-2 gap-1 flex-shrink-0">
|
||||
<div className="flex border-b border-[var(--border-primary)]/40 px-3 pt-2 gap-1 flex-shrink-0">
|
||||
{fields.map(field => {
|
||||
const isActive = activeTab === field.key;
|
||||
const count = draft[field.key]?.size || 0;
|
||||
@@ -257,7 +257,7 @@ export default function AdvancedFilterModal({
|
||||
value={searchTerms[activeTab] || ''}
|
||||
onChange={(e) => setSearchTerms(prev => ({ ...prev, [activeTab]: e.target.value }))}
|
||||
placeholder={`Search ${activeField?.label.toLowerCase() || ''}...`}
|
||||
className={`w-full bg-black/50 border border-gray-700/70 rounded-lg text-[11px] text-gray-300 pl-8 pr-8 py-2 font-mono tracking-wide focus:outline-none focus:${c.border} focus:ring-1 ${c.ring} placeholder-gray-600 transition-all`}
|
||||
className={`w-full bg-[var(--bg-primary)]/50 border border-[var(--border-primary)]/70 rounded-lg text-[11px] text-[var(--text-secondary)] pl-8 pr-8 py-2 font-mono tracking-wide focus:outline-none focus:${c.border} focus:ring-1 ${c.ring} placeholder-[var(--text-muted)] transition-all`}
|
||||
autoFocus
|
||||
/>
|
||||
{searchTerms[activeTab] && (
|
||||
@@ -270,10 +270,10 @@ export default function AdvancedFilterModal({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between mt-1.5">
|
||||
<span className="text-[8px] text-gray-600 tracking-widest">
|
||||
<span className="text-[8px] text-[var(--text-muted)] tracking-widest">
|
||||
{filteredOptions.length} AVAILABLE
|
||||
</span>
|
||||
<span className="text-[8px] text-gray-600 tracking-widest">
|
||||
<span className="text-[8px] text-[var(--text-muted)] tracking-widest">
|
||||
{draft[activeTab]?.size || 0} SELECTED
|
||||
</span>
|
||||
</div>
|
||||
@@ -282,7 +282,7 @@ export default function AdvancedFilterModal({
|
||||
{/* ── Scrollable Checkbox List ── */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-2 pb-2 styled-scrollbar" style={{ maxHeight: '35vh' }}>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-600 text-[10px] tracking-widest">
|
||||
<div className="text-center py-8 text-[var(--text-muted)] text-[10px] tracking-widest">
|
||||
NO MATCHING RESULTS
|
||||
</div>
|
||||
) : (
|
||||
@@ -295,13 +295,13 @@ export default function AdvancedFilterModal({
|
||||
onClick={() => toggleItem(activeTab, option)}
|
||||
className={`flex items-center gap-2.5 px-3 py-1.5 rounded-md text-left transition-all group ${isChecked
|
||||
? `${c.bg} ${c.text}`
|
||||
: `text-gray-400 hover:bg-gray-800/50 hover:text-gray-200`
|
||||
: `text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]/50 hover:text-[var(--text-primary)]`
|
||||
}`}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<div className={`w-3.5 h-3.5 rounded-[3px] border flex items-center justify-center flex-shrink-0 transition-all ${isChecked
|
||||
? `${c.border} ${c.bg}`
|
||||
: 'border-gray-700 group-hover:border-gray-500'
|
||||
: 'border-[var(--border-primary)] group-hover:border-[var(--border-secondary)]'
|
||||
}`}>
|
||||
{isChecked && <Check size={9} strokeWidth={3} />}
|
||||
</div>
|
||||
@@ -316,7 +316,7 @@ export default function AdvancedFilterModal({
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-800/60 flex-shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border-primary)]/60 flex-shrink-0">
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="text-[9px] text-red-400/70 hover:text-red-300 tracking-widest transition-colors"
|
||||
@@ -326,7 +326,7 @@ export default function AdvancedFilterModal({
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[9px] text-gray-500 hover:text-gray-300 tracking-widest border border-gray-700 rounded-md px-4 py-1.5 hover:bg-gray-800/50 transition-all"
|
||||
className="text-[9px] text-[var(--text-muted)] hover:text-[var(--text-secondary)] tracking-widest border border-[var(--border-primary)] rounded-md px-4 py-1.5 hover:bg-[var(--bg-tertiary)]/50 transition-all"
|
||||
>
|
||||
CANCEL
|
||||
</button>
|
||||
|
||||
@@ -656,13 +656,11 @@ export default function CesiumViewer({ data, activeLayers, activeFilters, effect
|
||||
}
|
||||
if (filters.tracked_owner?.length) {
|
||||
const op = (f.alert_operator || '').toLowerCase();
|
||||
const t1 = (f.alert_tag1 || '').toLowerCase();
|
||||
const t2 = (f.alert_tag2 || '').toLowerCase();
|
||||
const t3 = (f.alert_tag3 || '').toLowerCase();
|
||||
const tags = (f.alert_tags || '').toLowerCase();
|
||||
const cs = (f.callsign || '').toLowerCase();
|
||||
if (!filters.tracked_owner.some(sv => {
|
||||
const q = sv.toLowerCase();
|
||||
return op.includes(q) || t1.includes(q) || t2.includes(q) || t3.includes(q) || cs.includes(q);
|
||||
return op.includes(q) || tags.includes(q) || cs.includes(q);
|
||||
})) return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, Zap, Shield, Satellite, MapPin, Palette, ToggleRight, Bug, Heart } from "lucide-react";
|
||||
|
||||
const CURRENT_VERSION = "0.8";
|
||||
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
|
||||
|
||||
const NEW_FEATURES = [
|
||||
{
|
||||
icon: <Shield size={14} className="text-pink-400" />,
|
||||
title: "POTUS Fleet Tracking",
|
||||
desc: "Air Force One, Air Force Two, and Marine One aircraft now display with oversized hot-pink icons and a gold dashed halo ring — instantly recognizable on the map.",
|
||||
color: "pink",
|
||||
},
|
||||
{
|
||||
icon: <Palette size={14} className="text-yellow-400" />,
|
||||
title: "Full Aircraft Color-Coding",
|
||||
desc: "9-color system: military (yellow), medical/rescue (lime), police/government (blue), privacy (black), VIPs (hot pink), dictators/oligarchs (red), and more — all enriched from plane_alert_db.",
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
icon: <Satellite size={14} className="text-green-400" />,
|
||||
title: "Sentinel-2 Satellite Overhaul",
|
||||
desc: "Replaced the tiny satellite popup with a fullscreen image overlay. Added Download, Copy to Clipboard, and Open Full Res buttons. Green dossier-themed UI.",
|
||||
color: "green",
|
||||
},
|
||||
{
|
||||
icon: <MapPin size={14} className="text-blue-400" />,
|
||||
title: "Region Dossier & Carrier Fidelity",
|
||||
desc: "Fixed Nominatim 429 rate-limit errors with retry/backoff. Carriers at shared homeports now dock at distinct pier positions instead of stacking.",
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
icon: <Zap size={14} className="text-cyan-400" />,
|
||||
title: "Overhauled Map Legend & Controls",
|
||||
desc: "Full 9-color aircraft legend with POTUS fleet, wildfires, and infrastructure sections. New version badge, update checker, and Discussions shortcut in the UI.",
|
||||
color: "cyan",
|
||||
},
|
||||
{
|
||||
icon: <ToggleRight size={14} className="text-purple-400" />,
|
||||
title: "Toggle All Data Layers",
|
||||
desc: "One-click button to enable/disable all data layers at once. Turns cyan when active. MODIS Terra excluded from bulk toggle to prevent accidental imagery load.",
|
||||
color: "purple",
|
||||
},
|
||||
];
|
||||
|
||||
const BUG_FIXES = [
|
||||
"POTUS fleet ICAO codes expanded — all Air Force Two (C-32A/B) airframes now correctly identified with gold halo",
|
||||
"POTUS icon priority fixed — presidential aircraft always show the POTUS icon even when grounded",
|
||||
"Sentinel-2 imagery no longer overlaps the bottom coordinate bar",
|
||||
"Docker ENV format warnings resolved (legacy syntax → key=value)",
|
||||
"Settings/Key/Version buttons now cyan in dark mode, grey only in light mode",
|
||||
];
|
||||
|
||||
const CONTRIBUTORS = [
|
||||
{ name: "@suranyami", desc: "Parallel multi-arch Docker builds (11min → 3min) + runtime BACKEND_URL fix", pr: "#35, #44" },
|
||||
];
|
||||
|
||||
export function useChangelog() {
|
||||
const [show, setShow] = useState(false);
|
||||
useEffect(() => {
|
||||
const seen = localStorage.getItem(STORAGE_KEY);
|
||||
if (!seen) setShow(true);
|
||||
}, []);
|
||||
return { showChangelog: show, setShowChangelog: setShow };
|
||||
}
|
||||
|
||||
interface ChangelogModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ChangelogModal = React.memo(function ChangelogModal({ onClose }: ChangelogModalProps) {
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key="changelog-backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[10000]"
|
||||
onClick={handleDismiss}
|
||||
/>
|
||||
<motion.div
|
||||
key="changelog-modal"
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
className="fixed inset-0 z-[10001] flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<div
|
||||
className="w-[560px] max-h-[85vh] bg-[var(--bg-secondary)]/98 border border-cyan-900/50 rounded-xl shadow-[0_0_80px_rgba(0,200,255,0.08)] pointer-events-auto flex flex-col overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-5 pb-3 border-b border-[var(--border-primary)]/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="px-2 py-1 rounded bg-cyan-500/15 border border-cyan-500/30 text-[10px] font-mono font-bold text-cyan-400 tracking-widest">
|
||||
v{CURRENT_VERSION}
|
||||
</div>
|
||||
<h2 className="text-sm font-bold tracking-[0.15em] text-[var(--text-primary)] font-mono">
|
||||
WHAT'S NEW
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest mt-1">
|
||||
SHADOWBROKER INTELLIGENCE PLATFORM UPDATE
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="w-8 h-8 rounded-lg border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-5 space-y-4">
|
||||
{/* New Features */}
|
||||
<div>
|
||||
<div className="text-[9px] font-mono tracking-[0.2em] text-cyan-400 font-bold mb-3 flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
||||
NEW CAPABILITIES
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{NEW_FEATURES.map((f) => (
|
||||
<div key={f.title} className="flex items-start gap-3 p-3 rounded-lg border border-[var(--border-primary)]/50 bg-[var(--bg-primary)]/30 hover:border-[var(--border-secondary)] transition-colors">
|
||||
<div className="mt-0.5 flex-shrink-0">{f.icon}</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-mono text-[var(--text-primary)] font-bold">{f.title}</div>
|
||||
<div className="text-[9px] font-mono text-[var(--text-muted)] leading-relaxed mt-0.5">{f.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bug Fixes */}
|
||||
<div>
|
||||
<div className="text-[9px] font-mono tracking-[0.2em] text-green-400 font-bold mb-3 flex items-center gap-2">
|
||||
<Bug size={10} className="text-green-400" />
|
||||
FIXES & IMPROVEMENTS
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{BUG_FIXES.map((fix, i) => (
|
||||
<div key={i} className="flex items-start gap-2 px-3 py-1.5">
|
||||
<span className="text-green-500 text-[10px] mt-0.5 flex-shrink-0">+</span>
|
||||
<span className="text-[9px] font-mono text-[var(--text-secondary)] leading-relaxed">{fix}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contributors */}
|
||||
<div>
|
||||
<div className="text-[9px] font-mono tracking-[0.2em] text-pink-400 font-bold mb-3 flex items-center gap-2">
|
||||
<Heart size={10} className="text-pink-400" />
|
||||
COMMUNITY CONTRIBUTORS
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{CONTRIBUTORS.map((c, i) => (
|
||||
<div key={i} className="flex items-start gap-2 px-3 py-2 rounded-lg border border-pink-500/20 bg-pink-500/5">
|
||||
<span className="text-pink-400 text-[10px] mt-0.5 flex-shrink-0">♥</span>
|
||||
<div>
|
||||
<span className="text-[10px] font-mono text-pink-300 font-bold">{c.name}</span>
|
||||
<span className="text-[9px] font-mono text-[var(--text-muted)]"> — {c.desc}</span>
|
||||
<span className="text-[8px] font-mono text-[var(--text-muted)]"> (PR {c.pr})</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-[var(--border-primary)]/80 flex items-center justify-center">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="px-8 py-2.5 rounded-lg bg-cyan-500/15 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/25 text-[10px] font-mono tracking-[0.2em] transition-all"
|
||||
>
|
||||
ACKNOWLEDGED
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
|
||||
export default ChangelogModal;
|
||||
@@ -34,7 +34,7 @@ class ErrorBoundary extends Component<Props, State> {
|
||||
<div className="flex items-center justify-center p-4 bg-red-950/40 border border-red-800 rounded-lg m-2">
|
||||
<div className="text-center font-mono">
|
||||
<div className="text-red-400 text-xs tracking-widest mb-1">⚠ SYSTEM ERROR</div>
|
||||
<div className="text-gray-400 text-[10px]">{this.props.name || "Component"} failed to render</div>
|
||||
<div className="text-[var(--text-secondary)] text-[10px]">{this.props.name || "Component"} failed to render</div>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
className="mt-2 px-3 py-1 text-[10px] bg-red-900/60 hover:bg-red-800/60 text-red-300 rounded border border-red-700 transition-colors"
|
||||
|
||||
@@ -106,8 +106,7 @@ export default function FilterPanel({ data, activeFilters, setActiveFilters }: F
|
||||
const ops = new Set<string>(trackedOperators);
|
||||
for (const f of data?.tracked_flights || []) {
|
||||
if (f.alert_operator) ops.add(f.alert_operator);
|
||||
if (f.alert_tag1) ops.add(f.alert_tag1);
|
||||
if (f.alert_tag2) ops.add(f.alert_tag2);
|
||||
if (f.alert_tags) ops.add(f.alert_tags);
|
||||
}
|
||||
return Array.from(ops).sort();
|
||||
}, [data?.tracked_flights]);
|
||||
@@ -252,23 +251,23 @@ export default function FilterPanel({ data, activeFilters, setActiveFilters }: F
|
||||
initial={{ y: -30, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
className="w-full bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl z-10 flex flex-col font-mono text-sm shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto flex-shrink-0"
|
||||
className="w-full bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl z-10 flex flex-col font-mono text-sm shadow-[0_4px_30px_rgba(0,0,0,0.2)] pointer-events-auto flex-shrink-0"
|
||||
>
|
||||
{/* Header Toggle */}
|
||||
<div
|
||||
className="flex justify-between items-center p-3 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
|
||||
className="flex justify-between items-center p-3 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={12} className="text-cyan-500" />
|
||||
<span className="text-[10px] text-gray-500 font-mono tracking-widest">DATA FILTERS</span>
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">DATA FILTERS</span>
|
||||
{activeCount > 0 && (
|
||||
<span className="text-[9px] bg-cyan-500/20 text-cyan-400 px-1.5 py-0.5 rounded-sm">
|
||||
{activeCount} ACTIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="text-gray-500 hover:text-white transition-colors">
|
||||
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
{isMinimized ? <SlidersHorizontal size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
@@ -295,20 +294,20 @@ export default function FilterPanel({ data, activeFilters, setActiveFilters }: F
|
||||
return (
|
||||
<div
|
||||
key={section.key}
|
||||
className={`border rounded-lg transition-all cursor-pointer group ${borderColors[section.color] || 'border-gray-800'} hover:bg-black/30`}
|
||||
className={`border rounded-lg transition-all cursor-pointer group ${borderColors[section.color] || 'border-[var(--border-primary)]'} hover:bg-[var(--bg-primary)]/30`}
|
||||
onClick={() => setOpenModal(section.key)}
|
||||
>
|
||||
<div className="flex items-center justify-between p-2.5 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{section.icon}
|
||||
<span className="text-[9px] text-gray-400 tracking-widest group-hover:text-gray-200 transition-colors">{section.title}</span>
|
||||
<span className="text-[9px] text-[var(--text-secondary)] tracking-widest group-hover:text-[var(--text-primary)] transition-colors">{section.title}</span>
|
||||
{count > 0 && (
|
||||
<span className={`text-[8px] ${bgColors[section.color]} ${textColors[section.color]} px-1.5 py-0.5 rounded-sm`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<SlidersHorizontal size={10} className="text-gray-600 group-hover:text-gray-400 transition-colors" />
|
||||
<SlidersHorizontal size={10} className="text-[var(--text-muted)] group-hover:text-[var(--text-secondary)] transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -89,12 +89,13 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
|
||||
});
|
||||
}
|
||||
|
||||
// Tracked flights
|
||||
// Tracked flights — include tags/owner/name for broad search (first name, last name, etc.)
|
||||
for (const f of data?.tracked_flights || []) {
|
||||
const uid = f.icao24 || f.registration || f.callsign || '';
|
||||
const operator = f.alert_operator || 'Unknown Operator';
|
||||
const category = f.alert_category || 'Tracked';
|
||||
const type = f.alert_type || f.model || 'Unknown';
|
||||
const extras = [f.alert_tags, f.owner, f.name, f.callsign].filter(Boolean).join(' ');
|
||||
results.push({
|
||||
id: `tracked-${uid}`,
|
||||
label: operator,
|
||||
@@ -104,7 +105,8 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
|
||||
lat: f.lat,
|
||||
lng: f.lng,
|
||||
entityType: "tracked_flight",
|
||||
});
|
||||
_extra: extras,
|
||||
} as any);
|
||||
}
|
||||
|
||||
// Ships
|
||||
@@ -144,7 +146,7 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
|
||||
const q = query.toLowerCase();
|
||||
return allEntities
|
||||
.filter(e => {
|
||||
const searchable = `${e.label} ${e.sublabel} ${e.id}`.toLowerCase();
|
||||
const searchable = `${e.label} ${e.sublabel} ${e.id} ${(e as any)._extra || ''}`.toLowerCase();
|
||||
return searchable.includes(q);
|
||||
})
|
||||
.slice(0, 12);
|
||||
@@ -171,14 +173,14 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full pointer-events-auto">
|
||||
<div className="flex items-center gap-2 bg-black/40 backdrop-blur-md border border-gray-800 rounded-lg px-3 py-2 focus-within:border-cyan-500/40 transition-colors">
|
||||
<Search size={12} className="text-gray-500 flex-shrink-0" />
|
||||
<div className="flex items-center gap-2 bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-lg px-3 py-2 focus-within:border-cyan-500/40 transition-colors">
|
||||
<Search size={12} className="text-[var(--text-muted)] flex-shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
placeholder="Find aircraft or vessel..."
|
||||
className="flex-1 bg-transparent text-[10px] text-gray-300 font-mono tracking-wider outline-none placeholder:text-gray-600"
|
||||
placeholder="Find aircraft, person or vessel..."
|
||||
className="flex-1 bg-transparent text-[10px] text-[var(--text-secondary)] font-mono tracking-wider outline-none placeholder:text-[var(--text-muted)]"
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setIsOpen(true);
|
||||
@@ -186,11 +188,11 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
|
||||
onFocus={() => setIsOpen(true)}
|
||||
/>
|
||||
{query && (
|
||||
<button onClick={() => { setQuery(""); setIsOpen(false); }} className="text-gray-600 hover:text-white transition-colors">
|
||||
<button onClick={() => { setQuery(""); setIsOpen(false); }} className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
<Crosshair size={12} className="text-gray-600 flex-shrink-0" />
|
||||
<Crosshair size={12} className="text-[var(--text-muted)] flex-shrink-0" />
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
@@ -199,21 +201,21 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-black/90 backdrop-blur-md border border-gray-800 rounded-lg overflow-hidden z-50 shadow-[0_8px_30px_rgba(0,0,0,0.6)]"
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-[var(--bg-secondary)]/90 backdrop-blur-md border border-[var(--border-primary)] rounded-lg overflow-hidden z-50 shadow-[0_8px_30px_rgba(0,0,0,0.3)]"
|
||||
>
|
||||
<div className="max-h-[300px] overflow-y-auto styled-scrollbar">
|
||||
{filtered.map((r, idx) => (
|
||||
<button
|
||||
key={`${r.id}-${idx}`}
|
||||
onClick={() => handleSelect(r)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-cyan-950/30 transition-colors text-left border-b border-gray-800/50 last:border-0 group"
|
||||
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-[var(--hover-accent)] transition-colors text-left border-b border-[var(--border-primary)]/50 last:border-0 group"
|
||||
>
|
||||
<div className="flex-shrink-0 w-5 h-5 flex items-center justify-center rounded bg-gray-900 border border-gray-800 group-hover:border-cyan-800">
|
||||
<div className="flex-shrink-0 w-5 h-5 flex items-center justify-center rounded bg-[var(--bg-secondary)] border border-[var(--border-primary)] group-hover:border-cyan-800">
|
||||
{categoryIcons[r.category]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] text-gray-200 font-mono tracking-wide truncate">{r.label}</div>
|
||||
<div className="text-[8px] text-gray-500 font-mono truncate">{r.sublabel}</div>
|
||||
<div className="text-[10px] text-[var(--text-primary)] font-mono tracking-wide truncate">{r.label}</div>
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono truncate">{r.sublabel}</div>
|
||||
</div>
|
||||
<span className={`text-[7px] font-bold tracking-widest ${r.categoryColor} flex-shrink-0`}>
|
||||
{r.category}
|
||||
@@ -221,7 +223,7 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 border-t border-gray-800 bg-black/50 text-[8px] text-gray-600 font-mono tracking-widest">
|
||||
<div className="px-3 py-1.5 border-t border-[var(--border-primary)] bg-[var(--bg-primary)]/50 text-[8px] text-[var(--text-muted)] font-mono tracking-widest">
|
||||
{filtered.length} RESULT{filtered.length !== 1 ? 'S' : ''} — CLICK TO LOCATE
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -231,9 +233,9 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-black/90 backdrop-blur-md border border-gray-800 rounded-lg z-50 p-4 text-center"
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-[var(--bg-secondary)]/90 backdrop-blur-md border border-[var(--border-primary)] rounded-lg z-50 p-4 text-center"
|
||||
>
|
||||
<div className="text-[9px] text-gray-600 font-mono tracking-widest">NO MATCHING ASSETS</div>
|
||||
<div className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">NO MATCHING ASSETS</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap, Tooltip, CircleMarker, useMapEvents } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Fix standard leaflet icon path issues in React
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||
});
|
||||
|
||||
// Create custom icons dynamically for the layers
|
||||
const createDivIcon = (svg: string, size = 16, rotate = 0) => {
|
||||
return L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `<div style="transform: rotate(${rotate}deg); width: ${size}px; height: ${size}px;">${svg}</div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2]
|
||||
});
|
||||
};
|
||||
|
||||
const svgPlaneCyan = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#00d4ff"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>`;
|
||||
const svgPlaneOrange = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffaa00"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>`;
|
||||
const svgPlaneRed = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ff3333"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>`;
|
||||
const svgShip = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#888888"><path d="M12 22V8" /><path d="M5 12H19" /><path d="M9 22H15" /><circle cx="12" cy="5" r="3" /><path d="M12 22C8 22 4 19 4 15V13M12 22C16 22 20 19 20 15V13" /></svg>`;
|
||||
const svgThreat = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffff00" stroke="#ff0000" stroke-width="2"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>`;
|
||||
const svgTriangleYellow = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffaa00" stroke="#000" stroke-width="1"><path d="M1 21h22L12 2 1 21z"/></svg>`;
|
||||
const svgTriangleRed = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ff0000" stroke="#fff" stroke-width="1"><path d="M1 21h22L12 2 1 21z"/></svg>`;
|
||||
|
||||
// Helper component to center map when Find Locater is used
|
||||
function MapCenterControl({ location }: { location: { lat: number, lng: number } | null }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (location) {
|
||||
map.flyTo([location.lat, location.lng], 8, { duration: 1.5 });
|
||||
}
|
||||
}, [location, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Eavesdrop mode controller
|
||||
function ClickHandler({ isEavesdropping, onEavesdropClick }: any) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (!isEavesdropping) return;
|
||||
const cb = (e: any) => onEavesdropClick({ lat: e.latlng.lat, lng: e.latlng.lng });
|
||||
map.on('click', cb);
|
||||
return () => { map.off('click', cb); };
|
||||
}, [isEavesdropping, map, onEavesdropClick]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Map state tracker for LOD
|
||||
function MapStateTracker({ onStateChange }: { onStateChange: (zoom: number, bounds: L.LatLngBounds) => void }) {
|
||||
const map = useMapEvents({
|
||||
moveend: () => onStateChange(map.getZoom(), map.getBounds()),
|
||||
zoomend: () => onStateChange(map.getZoom(), map.getBounds()),
|
||||
});
|
||||
useEffect(() => {
|
||||
onStateChange(map.getZoom(), map.getBounds());
|
||||
}, [map, onStateChange]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function LeafletViewer({ data, activeLayers, activeFilters, effects, onEntityClick, selectedEntity, flyToLocation, isEavesdropping, onEavesdropClick, onCameraMove }: any) {
|
||||
const [zoom, setZoom] = useState(3);
|
||||
const [bounds, setBounds] = useState<L.LatLngBounds | null>(null);
|
||||
|
||||
const handleMapState = (z: number, b: L.LatLngBounds) => {
|
||||
setZoom(z);
|
||||
setBounds(b);
|
||||
};
|
||||
|
||||
const isVisible = (lat: number, lng: number) => {
|
||||
if (!bounds) return true;
|
||||
return bounds.pad(0.2).contains([lat, lng]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: "100vw", height: "100vh", position: "fixed", top: 0, left: 0, zIndex: 0, background: "black" }}>
|
||||
<MapContainer
|
||||
center={[20, 0]}
|
||||
zoom={3}
|
||||
style={{ width: "100%", height: "100%", background: "#06080a" }} // Extremely dark ocean base
|
||||
zoomControl={false}
|
||||
minZoom={2}
|
||||
maxZoom={12}
|
||||
>
|
||||
<MapStateTracker onStateChange={handleMapState} />
|
||||
<MapCenterControl location={flyToLocation} />
|
||||
<ClickHandler isEavesdropping={isEavesdropping} onEavesdropClick={onEavesdropClick} />
|
||||
|
||||
{/* Dark Mode Satellite Stamen / CartoDB Voyager basemap substitute */}
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||
maxZoom={19}
|
||||
/>
|
||||
|
||||
{/* --- COMMERCIAL FLIGHTS --- */}
|
||||
{activeLayers.flights && data?.commercial_flights?.map((f: any, idx: number) => {
|
||||
if (f.lat == null || f.lng == null) return null;
|
||||
if (zoom >= 6 && !isVisible(f.lat, f.lng)) return null;
|
||||
|
||||
if (zoom < 6) {
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`comm-${idx}`}
|
||||
center={[f.lat, f.lng]}
|
||||
radius={2}
|
||||
pathOptions={{ color: '#00d4ff', fillColor: '#00d4ff', fillOpacity: 0.8, weight: 1, stroke: false }}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const icon = createDivIcon(svgPlaneCyan, 18, f.true_track || f.heading || 0);
|
||||
return (
|
||||
<Marker
|
||||
key={`comm-${idx}`}
|
||||
position={[f.lat, f.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={0.9}>
|
||||
<div className="text-cyan-400 font-bold bg-black px-1 text-xs border border-cyan-500/50">{f.callsign || f.icao24}</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* --- PRIVATE FLIGHTS --- */}
|
||||
{activeLayers.private && data?.private_flights?.map((f: any, idx: number) => {
|
||||
if (f.lat == null || f.lng == null) return null;
|
||||
if (zoom >= 6 && !isVisible(f.lat, f.lng)) return null;
|
||||
|
||||
if (zoom < 6) {
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`priv-${idx}`}
|
||||
center={[f.lat, f.lng]}
|
||||
radius={2}
|
||||
pathOptions={{ color: '#ffaa00', fillColor: '#ffaa00', fillOpacity: 0.8, weight: 1, stroke: false }}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'private_flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const icon = createDivIcon(svgPlaneOrange, 18, f.true_track || f.heading || 0);
|
||||
return (
|
||||
<Marker
|
||||
key={`priv-${idx}`}
|
||||
position={[f.lat, f.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'private_flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={0.9}>
|
||||
<div className="text-orange-400 font-bold bg-black px-1 text-xs border border-orange-500/50">{f.callsign || f.icao24}</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* --- MILITARY FLIGHTS --- */}
|
||||
{activeLayers.military && data?.military_flights?.map((f: any, idx: number) => {
|
||||
if (f.lat == null || f.lng == null) return null;
|
||||
if (zoom >= 6 && !isVisible(f.lat, f.lng)) return null;
|
||||
|
||||
if (zoom < 6) {
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`mil-${idx}`}
|
||||
center={[f.lat, f.lng]}
|
||||
radius={3}
|
||||
pathOptions={{ color: '#ff3333', fillColor: '#ff3333', fillOpacity: 0.9, weight: 1, stroke: false }}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'military_flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const icon = createDivIcon(svgPlaneRed, 20, f.true_track || f.heading || 0);
|
||||
return (
|
||||
<Marker
|
||||
key={`mil-${idx}`}
|
||||
position={[f.lat, f.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'military_flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={0.9}>
|
||||
<div className="text-red-500 font-bold bg-black px-1 text-xs border border-red-500/50">{f.callsign || f.icao24}</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* --- SHIPS --- */}
|
||||
{(activeLayers.ships_important || activeLayers.ships_civilian || activeLayers.ships_passenger) && data?.ships?.map((s: any, idx: number) => {
|
||||
if (s.lat == null || s.lng == null) return null;
|
||||
if (zoom >= 6 && !isVisible(s.lat, s.lng)) return null;
|
||||
|
||||
if (zoom < 6) {
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`ship-${idx}`}
|
||||
center={[s.lat, s.lng]}
|
||||
radius={1.5}
|
||||
pathOptions={{ color: '#888888', fillColor: '#888888', fillOpacity: 0.6, weight: 0.5, stroke: false }}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'ship', id: idx, name: s.name }) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const icon = createDivIcon(svgShip, 12, s.heading || 0);
|
||||
return (
|
||||
<Marker
|
||||
key={`ship-${idx}`}
|
||||
position={[s.lat, s.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'ship', id: idx, name: s.name }) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -5]} opacity={0.8}>
|
||||
<div className="text-gray-300 font-bold bg-black px-1 text-[10px] border border-gray-600/50">{s.name}</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* --- GDELT GLOBAL INCIDENTS --- */}
|
||||
{activeLayers.global_incidents && data?.gdelt?.map((incident: any, idx: number) => {
|
||||
const geom = incident.geometry;
|
||||
if (!geom || geom.type !== 'Point' || !geom.coordinates) return null;
|
||||
const lng = geom.coordinates[0];
|
||||
const lat = geom.coordinates[1];
|
||||
if (!isVisible(lat, lng)) return null;
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`gdelt-${idx}`}
|
||||
center={[geom.coordinates[1], geom.coordinates[0]]}
|
||||
radius={8}
|
||||
pathOptions={{ color: '#ff0000', fillColor: '#ff8c00', fillOpacity: 0.6, weight: 2 }}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'gdelt', id: idx }) }}
|
||||
>
|
||||
<Tooltip>
|
||||
<div className="text-orange-500 text-xs bg-black p-1 max-w-[200px] whitespace-normal">
|
||||
{incident.title}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</CircleMarker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* --- LIVEUAMAP INCIDENTS --- */}
|
||||
{activeLayers.global_incidents && data?.liveuamap?.map((incident: any, idx: number) => {
|
||||
if (incident.lat == null || incident.lng == null) return null;
|
||||
if (!isVisible(incident.lat, incident.lng)) return null;
|
||||
const isViolent = /bomb|missil|strike|attack|kill|destroy|fire|shoot|expl|raid/i.test(incident.title || "");
|
||||
const icon = createDivIcon(isViolent ? svgTriangleRed : svgTriangleYellow, 18);
|
||||
return (
|
||||
<Marker
|
||||
key={`liveua-${idx}`}
|
||||
position={[incident.lat, incident.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'liveuamap', id: incident.id, title: incident.title }) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={0.95}>
|
||||
<div className="text-white font-bold bg-black p-1 text-[11px] border border-gray-600 max-w-[200px] whitespace-normal">
|
||||
<span className={isViolent ? "text-red-500" : "text-yellow-500"}>[LIVEUA]</span> {incident.title}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* --- RSS THREAT ALERTS --- */}
|
||||
{activeLayers.global_incidents && data?.news?.filter((n: any) => n.coordinates)?.map((n: any, idx: number) => {
|
||||
if (n.coordinates.lat == null || n.coordinates.lng == null) return null;
|
||||
if (!isVisible(n.coordinates.lat, n.coordinates.lng)) return null;
|
||||
const icon = createDivIcon(svgThreat, 24);
|
||||
return (
|
||||
<Marker
|
||||
key={`threat-${idx}`}
|
||||
position={[n.coordinates.lat, n.coordinates.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'news', id: idx }) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -12]} opacity={1.0} permanent={true} className="bg-transparent border-0 shadow-none">
|
||||
<div className="text-red-500 font-bold bg-black/80 px-2 py-1 text-[10px] border border-red-500/50 backdrop-blur" style={{ textShadow: "0px 0px 4px #000" }}>
|
||||
!! LVL {n.threat_level} !!<br />
|
||||
<span className="text-yellow-400 font-normal">{n.title.substring(0, 30)}...</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -94,18 +94,30 @@ const LEGEND: LegendCategory[] = [
|
||||
{ svg: airliner("yellow"), label: "Military — Standard" },
|
||||
{ svg: plane("yellow"), label: "Fighter / Interceptor" },
|
||||
{ svg: heli("yellow"), label: "Military — Helicopter" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`, label: "UAV / Drone" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="orange" stroke-width="1.5" stroke-dasharray="4 2" opacity="0.6"/><circle cx="12" cy="12" r="2" fill="orange"/></svg>`, label: "UAV Operational Range (dashed circle)" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`, label: "UAV / Drone (live ADS-B)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "TRACKED AIRCRAFT (ALERT)",
|
||||
color: "text-pink-400 border-pink-500/30",
|
||||
items: [
|
||||
{ svg: airliner("#FF1493"), label: "Alert — Low Priority (pink)" },
|
||||
{ svg: airliner("#FF2020"), label: "Alert — High Priority (red)" },
|
||||
{ svg: airliner("#1A3A8A"), label: "Alert — Government (navy)" },
|
||||
{ svg: airliner("white"), label: "Alert — General (white)" },
|
||||
{ svg: airliner("#FF1493"), label: "VIP / Celebrity / Bizjet (hot pink)" },
|
||||
{ svg: airliner("#FF2020"), label: "Dictator / Oligarch (red)" },
|
||||
{ svg: airliner("#3b82f6"), label: "Government / Police / Customs (blue)" },
|
||||
{ svg: heli("#32CD32"), label: "Medical / Fire / Rescue (lime)" },
|
||||
{ svg: airliner("yellow"), label: "Military / Intelligence (yellow)" },
|
||||
{ svg: airliner("#222"), label: "PIA — Privacy / Stealth (black)" },
|
||||
{ svg: airliner("#FF8C00"), label: "Private Flights / Joe Cool (orange)" },
|
||||
{ svg: airliner("white"), label: "Climate Crisis (white)" },
|
||||
{ svg: airliner("#9B59B6"), label: "Private Jets / Historic / Other (purple)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "POTUS FLEET",
|
||||
color: "text-yellow-400 border-yellow-500/30",
|
||||
items: [
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="none" stroke="gold" stroke-width="2" stroke-dasharray="4 2"/><g transform="translate(6,6)"><path d="M12 2C11.2 2 10.5 2.8 10.5 3.5V8.5L3 13V15L10.5 12.5V18L8 19.5V21L12 19.5L16 21V19.5L13.5 18V12.5L21 15V13L13.5 8.5V3.5C13.5 2.8 12.8 2 12 2Z" fill="#FF1493" stroke="black" stroke-width="0.5"/></g></svg>`, label: "Air Force One / Two (gold ring)" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="none" stroke="gold" stroke-width="2" stroke-dasharray="4 2"/><g transform="translate(8,6)"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z" fill="#FF1493" stroke="black" stroke-width="0.5"/></g></svg>`, label: "Marine One (gold ring + heli)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -139,7 +151,15 @@ const LEGEND: LegendCategory[] = [
|
||||
name: "GEOPHYSICAL",
|
||||
color: "text-orange-400 border-orange-500/30",
|
||||
items: [
|
||||
{ svg: circle("#ff6600"), label: "Earthquake (size = magnitude)" },
|
||||
{ svg: circle("#ffcc00"), label: "Earthquake (yellow blob, size = magnitude)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "WILDFIRES",
|
||||
color: "text-red-400 border-red-500/30",
|
||||
items: [
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M12 1C8 7 5 10 5 14a7 7 0 0 0 14 0c0-4-3-7-7-13z" fill="#ff6600" stroke="#ffcc00" stroke-width="1"/></svg>`, label: "Active wildfire / hotspot" },
|
||||
{ svg: clusterCircle("#cc0000", "#ff3300"), label: "Fire cluster (grouped hotspots)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -167,6 +187,14 @@ const LEGEND: LegendCategory[] = [
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" fill="#ff0040" stroke="#000" stroke-width="1" opacity="0.2" rx="2"/></svg>`, label: "Low severity (25-50% degraded)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "INFRASTRUCTURE",
|
||||
color: "text-purple-400 border-purple-500/30",
|
||||
items: [
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#a78bfa" stroke-width="1.5"><rect x="3" y="3" width="18" height="6" rx="1" fill="#2e1065"/><rect x="3" y="11" width="18" height="6" rx="1" fill="#2e1065"/><circle cx="7" cy="6" r="1" fill="#a78bfa"/><circle cx="7" cy="14" r="1" fill="#a78bfa"/></svg>`, label: "Data Center" },
|
||||
{ svg: circle("#888"), label: "Internet Outage Zone (grey)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "SURVEILLANCE / CCTV",
|
||||
color: "text-green-400 border-green-500/30",
|
||||
@@ -217,10 +245,10 @@ const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: b
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[520px] max-h-[80vh] bg-gray-950/95 backdrop-blur-xl border border-cyan-900/50 rounded-xl z-[9999] flex flex-col shadow-[0_0_60px_rgba(0,0,0,0.8)]"
|
||||
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[520px] max-h-[80vh] bg-[var(--bg-secondary)]/95 backdrop-blur-xl border border-cyan-900/50 rounded-xl z-[9999] flex flex-col shadow-[0_0_60px_rgba(0,0,0,0.3)]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-800/80 flex-shrink-0">
|
||||
<div className="flex items-center justify-between p-5 border-b border-[var(--border-primary)]/80 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="cyan" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -230,13 +258,13 @@ const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: b
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-[0.2em] text-white font-mono">MAP LEGEND</h2>
|
||||
<span className="text-[9px] text-gray-500 font-mono tracking-widest">ICON REFERENCE KEY</span>
|
||||
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">MAP LEGEND</h2>
|
||||
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">ICON REFERENCE KEY</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-lg border border-gray-700 hover:border-red-500/50 flex items-center justify-center text-gray-500 hover:text-red-400 transition-all hover:bg-red-950/20"
|
||||
className="w-8 h-8 rounded-lg border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
@@ -247,16 +275,16 @@ const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: b
|
||||
{LEGEND.map((cat) => {
|
||||
const isCollapsed = collapsed.has(cat.name);
|
||||
return (
|
||||
<div key={cat.name} className="rounded-lg border border-gray-800/60 overflow-hidden">
|
||||
<div key={cat.name} className="rounded-lg border border-[var(--border-primary)]/60 overflow-hidden">
|
||||
{/* Category Header */}
|
||||
<button
|
||||
onClick={() => toggle(cat.name)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 bg-gray-900/50 hover:bg-gray-900/80 transition-colors"
|
||||
className="w-full flex items-center justify-between px-3 py-2 bg-[var(--bg-secondary)]/50 hover:bg-[var(--bg-secondary)]/80 transition-colors"
|
||||
>
|
||||
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${cat.color}`}>
|
||||
{cat.name}
|
||||
</span>
|
||||
{isCollapsed ? <ChevronDown size={12} className="text-gray-500" /> : <ChevronUp size={12} className="text-gray-500" />}
|
||||
{isCollapsed ? <ChevronDown size={12} className="text-[var(--text-muted)]" /> : <ChevronUp size={12} className="text-[var(--text-muted)]" />}
|
||||
</button>
|
||||
|
||||
{/* Items */}
|
||||
@@ -267,13 +295,13 @@ const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: b
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="border-t border-gray-800/40"
|
||||
className="border-t border-[var(--border-primary)]/40"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-0">
|
||||
{cat.items.map((item, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 px-4 py-1.5 hover:bg-gray-900/30 transition-colors">
|
||||
<div key={idx} className="flex items-center gap-3 px-4 py-1.5 hover:bg-[var(--bg-secondary)]/30 transition-colors">
|
||||
<IconImg svg={item.svg} />
|
||||
<span className="text-[11px] text-gray-300 font-mono">{item.label}</span>
|
||||
<span className="text-[11px] text-[var(--text-secondary)] font-mono">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -286,8 +314,8 @@ const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: b
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t border-gray-800/80 flex-shrink-0">
|
||||
<div className="text-[9px] text-gray-600 font-mono text-center tracking-wider">
|
||||
<div className="p-3 border-t border-[var(--border-primary)]/80 flex-shrink-0">
|
||||
<div className="text-[9px] text-[var(--text-muted)] font-mono text-center tracking-wider">
|
||||
{LEGEND.reduce((sum, c) => sum + c.items.length, 0)} ICON DEFINITIONS ACROSS {LEGEND.length} CATEGORIES
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,15 +15,15 @@ const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: any }) {
|
||||
initial={{ y: -50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="w-full bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl z-10 flex flex-col font-mono text-sm shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto flex-shrink-0"
|
||||
className="w-full bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl z-10 flex flex-col font-mono text-sm shadow-[0_4px_30px_rgba(0,0,0,0.2)] pointer-events-auto flex-shrink-0"
|
||||
>
|
||||
{/* Header Toggle */}
|
||||
<div
|
||||
className="flex justify-between items-center p-3 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
|
||||
className="flex justify-between items-center p-3 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<span className="text-[10px] text-gray-500 font-mono tracking-widest">GLOBAL MARKETS</span>
|
||||
<button className="text-gray-500 hover:text-white transition-colors">
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">GLOBAL MARKETS</span>
|
||||
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
@@ -36,7 +36,7 @@ const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: any }) {
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-y-auto styled-scrollbar flex flex-col gap-4 p-4 pt-3 max-h-[400px]"
|
||||
>
|
||||
<div className="border-b border-gray-800 pb-3">
|
||||
<div className="border-b border-[var(--border-primary)] pb-3">
|
||||
<h2 className="text-xs font-bold tracking-widest text-cyan-400 flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="text-cyan-500" size={14} /> DEFENSE SEC TICKERS
|
||||
</h2>
|
||||
@@ -45,7 +45,7 @@ const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: any }) {
|
||||
<div key={ticker} className="flex items-center justify-between border border-cyan-500/10 bg-cyan-950/10 p-1.5 rounded-sm relative group overflow-hidden">
|
||||
<span className="font-bold text-cyan-300 z-10 text-[10px]">[{ticker}]</span>
|
||||
<div className="flex items-center gap-3 text-right z-10">
|
||||
<span className="text-gray-200 font-bold text-xs">${info.price.toFixed(2)}</span>
|
||||
<span className="text-[var(--text-primary)] font-bold text-xs">${info.price.toFixed(2)}</span>
|
||||
<span className={`flex items-center gap-0.5 w-12 justify-end text-[9px] ${info.up ? 'text-cyan-400' : 'text-red-400'}`}>
|
||||
{info.up ? <ArrowUpRight size={10} /> : <ArrowDownRight size={10} />}
|
||||
{Math.abs(info.change_percent).toFixed(2)}%
|
||||
@@ -65,7 +65,7 @@ const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: any }) {
|
||||
<div key={name} className="flex flex-col border border-cyan-500/10 bg-cyan-950/10 p-1.5 rounded-sm justify-between">
|
||||
<span className="font-bold text-cyan-500 text-[9px] uppercase mb-0.5">{name}</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-200 font-bold text-[11px]">${info.price.toFixed(2)}</span>
|
||||
<span className="text-[var(--text-primary)] font-bold text-[11px]">${info.price.toFixed(2)}</span>
|
||||
<span className={`flex items-center gap-0.5 text-[9px] ${info.up ? 'text-cyan-400' : 'text-red-400'}`}>
|
||||
{info.up ? <ArrowUpRight size={10} /> : <ArrowDownRight size={10} />}
|
||||
{Math.abs(info.change_percent).toFixed(2)}%
|
||||
|
||||
@@ -3,9 +3,43 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { AlertTriangle, Clock, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef, useCallback } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
import WikiImage from '@/components/WikiImage';
|
||||
|
||||
// HLS video player — uses hls.js on Chrome/Firefox, native on Safari
|
||||
function HlsVideo({ url, className }: { url: string; className?: string }) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !url) return;
|
||||
|
||||
let hls: Hls | null = null;
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({ enableWorker: false, lowLatencyMode: true });
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// Safari native HLS
|
||||
video.src = url;
|
||||
}
|
||||
|
||||
return () => { hls?.destroy(); };
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Format time from pubish string "Tue, 24 Feb 2026 15:30:00 GMT" to "15:30"
|
||||
function formatTime(pubDate: string) {
|
||||
try {
|
||||
@@ -165,7 +199,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
>
|
||||
<div className="p-3 border-b border-emerald-500/30 bg-emerald-950/40 flex justify-between items-center">
|
||||
<h2 className="text-xs tracking-widest font-bold text-emerald-400">REGION DOSSIER</h2>
|
||||
<span className="text-[8px] text-gray-500">
|
||||
<span className="text-[8px] text-[var(--text-muted)]">
|
||||
{selectedEntity.extra ? `${selectedEntity.extra.lat.toFixed(3)}, ${selectedEntity.extra.lng.toFixed(3)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
@@ -177,41 +211,43 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
<div className="p-3 flex flex-col gap-1.5 max-h-[500px] overflow-y-auto styled-scrollbar text-[10px]">
|
||||
{/* COUNTRY */}
|
||||
<div className="text-[9px] text-emerald-500 tracking-widest font-bold border-b border-emerald-900/50 pb-1">COUNTRY LEVEL {d.country?.flag_emoji || ''}</div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">COUNTRY</span><span className="text-white font-bold">{d.country?.name}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">COUNTRY</span><span className="text-[var(--text-primary)] font-bold">{d.country?.name}</span></div>
|
||||
{d.country?.official_name && d.country.official_name !== d.country.name && (
|
||||
<div className="flex justify-between"><span className="text-gray-500">OFFICIAL</span><span className="text-gray-300 text-right max-w-[180px]">{d.country.official_name}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">OFFICIAL</span><span className="text-[var(--text-secondary)] text-right max-w-[180px]">{d.country.official_name}</span></div>
|
||||
)}
|
||||
<div className="flex justify-between"><span className="text-gray-500">LEADER</span><span className="text-emerald-400 font-bold">{d.country?.leader}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">GOVERNMENT</span><span className="text-white font-bold text-right max-w-[180px]">{d.country?.government_type}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">POPULATION</span><span className="text-white font-bold">{d.country?.population?.toLocaleString()}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">CAPITAL</span><span className="text-white font-bold">{d.country?.capital}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">LANGUAGES</span><span className="text-white text-right max-w-[180px]">{d.country?.languages?.join(', ')}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">LEADER</span><span className="text-emerald-400 font-bold">{d.country?.leader}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">GOVERNMENT</span><span className="text-[var(--text-primary)] font-bold text-right max-w-[180px]">{d.country?.government_type}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">POPULATION</span><span className="text-[var(--text-primary)] font-bold">{d.country?.population?.toLocaleString()}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">CAPITAL</span><span className="text-[var(--text-primary)] font-bold">{d.country?.capital}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">LANGUAGES</span><span className="text-[var(--text-primary)] text-right max-w-[180px]">{d.country?.languages?.join(', ')}</span></div>
|
||||
{d.country?.currencies?.length > 0 && (
|
||||
<div className="flex justify-between"><span className="text-gray-500">CURRENCY</span><span className="text-white text-right max-w-[180px]">{d.country.currencies.join(', ')}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">CURRENCY</span><span className="text-[var(--text-primary)] text-right max-w-[180px]">{d.country.currencies.join(', ')}</span></div>
|
||||
)}
|
||||
<div className="flex justify-between"><span className="text-gray-500">REGION</span><span className="text-white">{d.country?.subregion || d.country?.region}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">REGION</span><span className="text-[var(--text-primary)]">{d.country?.subregion || d.country?.region}</span></div>
|
||||
{d.country?.area_km2 > 0 && (
|
||||
<div className="flex justify-between"><span className="text-gray-500">AREA</span><span className="text-white">{d.country.area_km2.toLocaleString()} km²</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">AREA</span><span className="text-[var(--text-primary)]">{d.country.area_km2.toLocaleString()} km²</span></div>
|
||||
)}
|
||||
|
||||
{/* LOCAL */}
|
||||
{(d.local?.name || d.local?.state) && (
|
||||
<>
|
||||
<div className="text-[9px] text-emerald-500 tracking-widest font-bold border-b border-emerald-900/50 pb-1 mt-2">LOCAL LEVEL</div>
|
||||
{d.local.name && <div className="flex justify-between"><span className="text-gray-500">LOCALITY</span><span className="text-white font-bold">{d.local.name}</span></div>}
|
||||
{d.local.state && <div className="flex justify-between"><span className="text-gray-500">STATE/PROVINCE</span><span className="text-white font-bold">{d.local.state}</span></div>}
|
||||
{d.local.description && <div className="flex justify-between"><span className="text-gray-500">TYPE</span><span className="text-gray-300">{d.local.description}</span></div>}
|
||||
{d.local.name && <div className="flex justify-between"><span className="text-[var(--text-muted)]">LOCALITY</span><span className="text-[var(--text-primary)] font-bold">{d.local.name}</span></div>}
|
||||
{d.local.state && <div className="flex justify-between"><span className="text-[var(--text-muted)]">STATE/PROVINCE</span><span className="text-[var(--text-primary)] font-bold">{d.local.state}</span></div>}
|
||||
{d.local.description && <div className="flex justify-between"><span className="text-[var(--text-muted)]">TYPE</span><span className="text-[var(--text-secondary)]">{d.local.description}</span></div>}
|
||||
{d.local.summary && (
|
||||
<div className="mt-1 p-2 bg-black/60 border border-emerald-800/50 rounded text-[9px] text-gray-300 leading-relaxed">
|
||||
<div className="mt-1 p-2 bg-black/60 border border-emerald-800/50 rounded text-[9px] text-[var(--text-secondary)] leading-relaxed">
|
||||
<span className="text-emerald-400 font-bold">>_ INTEL: </span>
|
||||
{d.local.summary.length > 500 ? d.local.summary.substring(0, 500) + '...' : d.local.summary}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Sentinel-2 imagery now shown as map popup — see MaplibreViewer */}
|
||||
</div>
|
||||
) : d?.error ? (
|
||||
<div className="p-4 text-gray-400 text-[10px]">{d.error}</div>
|
||||
<div className="p-4 text-[var(--text-secondary)] text-[10px]">{d.error}</div>
|
||||
) : (
|
||||
<div className="p-4 text-red-400 text-[10px]">INTEL UNAVAILABLE</div>
|
||||
)}
|
||||
@@ -224,72 +260,92 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
if (flight) {
|
||||
const callsign = flight.callsign || "UNKNOWN";
|
||||
const alertColorMap: Record<string, string> = {
|
||||
'pink': 'text-pink-400', 'red': 'text-red-400',
|
||||
'darkblue': 'text-blue-400', 'white': 'text-white'
|
||||
'#ff1493': 'text-[#ff1493]', pink: 'text-[#ff1493]', red: 'text-red-400', yellow: 'text-yellow-400',
|
||||
blue: 'text-blue-400', orange: 'text-orange-400', '#32cd32': 'text-[#32cd32]', purple: 'text-purple-400',
|
||||
black: 'text-gray-400', white: 'text-white'
|
||||
};
|
||||
const alertBorderMap: Record<string, string> = {
|
||||
'pink': 'border-pink-500/30', 'red': 'border-red-500/30',
|
||||
'darkblue': 'border-blue-500/30', 'white': 'border-gray-500/30'
|
||||
'#ff1493': 'border-[#ff1493]/30', pink: 'border-[#ff1493]/30', red: 'border-red-500/30', yellow: 'border-yellow-500/30',
|
||||
blue: 'border-blue-500/30', orange: 'border-orange-500/30', '#32cd32': 'border-[#32cd32]/30', purple: 'border-purple-500/30',
|
||||
black: 'border-gray-500/30', white: 'border-[var(--border-primary)]/30'
|
||||
};
|
||||
const alertBgMap: Record<string, string> = {
|
||||
'pink': 'bg-pink-950/40', 'red': 'bg-red-950/40',
|
||||
'darkblue': 'bg-blue-950/40', 'white': 'bg-gray-900/40'
|
||||
'#ff1493': 'bg-[#ff1493]/10', pink: 'bg-[#ff1493]/10', red: 'bg-red-950/40', yellow: 'bg-yellow-950/40',
|
||||
blue: 'bg-blue-950/40', orange: 'bg-orange-950/40', '#32cd32': 'bg-lime-950/40', purple: 'bg-purple-950/40',
|
||||
black: 'bg-gray-900/40', white: 'bg-[var(--bg-panel)]'
|
||||
};
|
||||
const ac = flight.alert_color || 'white';
|
||||
const headerColor = alertColorMap[ac] || 'text-white';
|
||||
const borderColor = alertBorderMap[ac] || 'border-gray-500/30';
|
||||
const bgColor = alertBgMap[ac] || 'bg-gray-900/40';
|
||||
const borderColor = alertBorderMap[ac] || 'border-[var(--border-primary)]/30';
|
||||
const bgColor = alertBgMap[ac] || 'bg-[var(--bg-panel)]';
|
||||
|
||||
const shadowColor = (ac === 'pink' || ac === '#ff1493') ? 'rgba(255,20,147,0.4)'
|
||||
: ac === 'red' ? 'rgba(255,32,32,0.2)'
|
||||
: ac === 'yellow' ? 'rgba(255,255,0,0.2)'
|
||||
: ac === 'blue' ? 'rgba(59,130,246,0.2)'
|
||||
: ac === 'orange' ? 'rgba(255,140,0,0.3)'
|
||||
: ac === '#32cd32' ? 'rgba(50,205,50,0.2)'
|
||||
: ac === 'purple' ? 'rgba(155,89,182,0.2)'
|
||||
: 'rgba(255,255,255,0.1)';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className={`w-full bg-black/60 backdrop-blur-md border ${ac === 'pink' ? 'border-pink-800' : ac === 'red' ? 'border-red-800' : ac === 'darkblue' ? 'border-blue-800' : 'border-gray-600'} rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(255,20,147,0.2)] pointer-events-auto overflow-hidden flex-shrink-0`}
|
||||
className={`w-full bg-black/60 backdrop-blur-md border ${(ac === 'pink' || ac === '#ff1493') ? 'border-[#ff1493]' : ac === 'red' ? 'border-red-800' : ac === 'yellow' ? 'border-yellow-800' : ac === 'blue' ? 'border-blue-800' : ac === 'orange' ? 'border-orange-800' : ac === '#32cd32' ? 'border-lime-800' : ac === 'purple' ? 'border-purple-800' : 'border-[var(--border-secondary)]'} rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_${shadowColor}] pointer-events-auto overflow-hidden flex-shrink-0`}
|
||||
>
|
||||
<div className={`p-3 border-b ${borderColor} ${bgColor} flex justify-between items-center`}>
|
||||
<h2 className={`text-xs tracking-widest font-bold ${headerColor} flex items-center gap-2`}>
|
||||
⚠ TRACKED AIRCRAFT — {flight.alert_category || "ALERT"}
|
||||
</h2>
|
||||
<span className="text-[10px] text-gray-500 font-mono">TRK: {callsign}</span>
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono">TRK: {callsign}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">OPERATOR</span>
|
||||
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" ? (
|
||||
<a
|
||||
href={`https://en.wikipedia.org/wiki/${encodeURIComponent(flight.alert_operator.replace(/ /g, '_'))}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`text-xs font-bold underline ${headerColor} hover:opacity-80 transition-opacity`}
|
||||
title={`Search Wikipedia for ${flight.alert_operator}`}
|
||||
>
|
||||
{flight.alert_operator}
|
||||
</a>
|
||||
) : (
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">OPERATOR</span>
|
||||
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" ? (() => {
|
||||
const wikiSlug = flight.alert_wiki || flight.alert_operator.replace(/\s*\(.*?\)\s*/g, '').trim().replace(/ /g, '_');
|
||||
const wikiHref = `https://en.wikipedia.org/wiki/${encodeURIComponent(wikiSlug)}`;
|
||||
return (
|
||||
<a
|
||||
href={wikiHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`text-xs font-bold underline ${headerColor} hover:opacity-80 transition-opacity`}
|
||||
title={`Search Wikipedia for ${flight.alert_operator}`}
|
||||
>
|
||||
{flight.alert_operator}
|
||||
</a>
|
||||
);
|
||||
})() : (
|
||||
<span className={`text-xs font-bold ${headerColor}`}>UNKNOWN</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Owner/Operator Wikipedia photo */}
|
||||
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" && (
|
||||
<div className="border-b border-gray-800 pb-2">
|
||||
<WikiImage
|
||||
wikiUrl={`https://en.wikipedia.org/wiki/${encodeURIComponent(flight.alert_operator.replace(/ /g, '_'))}`}
|
||||
label={flight.alert_operator}
|
||||
maxH="max-h-36"
|
||||
accent={ac === 'pink' ? 'hover:border-pink-500/50' : ac === 'red' ? 'hover:border-red-500/50' : 'hover:border-cyan-500/50'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" && (() => {
|
||||
const wikiSlug = flight.alert_wiki || flight.alert_operator.replace(/\s*\(.*?\)\s*/g, '').trim().replace(/ /g, '_');
|
||||
const wikiHref = `https://en.wikipedia.org/wiki/${encodeURIComponent(wikiSlug)}`;
|
||||
return (
|
||||
<div className="border-b border-[var(--border-primary)] pb-2">
|
||||
<WikiImage
|
||||
wikiUrl={wikiHref}
|
||||
label={flight.alert_operator}
|
||||
maxH="max-h-36"
|
||||
accent={ac === 'pink' ? 'hover:border-pink-500/50' : ac === 'red' ? 'hover:border-red-500/50' : 'hover:border-cyan-500/50'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Aircraft model Wikipedia photo */}
|
||||
{aircraftImgUrl && (
|
||||
<div className="border-b border-gray-800 pb-2">
|
||||
<div className="border-b border-[var(--border-primary)] pb-2">
|
||||
<a href={aircraftWikiUrl || '#'} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<img
|
||||
src={aircraftImgUrl}
|
||||
alt={AIRCRAFT_WIKI[flight.model] || flight.model}
|
||||
className={`w-full h-auto max-h-28 object-cover rounded border border-gray-700/50 ${ac === 'pink' ? 'hover:border-pink-500/50' : 'hover:border-cyan-500/50'} transition-colors`}
|
||||
className={`w-full h-auto max-h-28 object-cover rounded border border-[var(--border-primary)]/50 ${ac === 'pink' ? 'hover:border-pink-500/50' : 'hover:border-cyan-500/50'} transition-colors`}
|
||||
/>
|
||||
</a>
|
||||
{aircraftWikiUrl && (
|
||||
@@ -300,65 +356,53 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">CATEGORY</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">CATEGORY</span>
|
||||
<span className={`text-xs font-bold ${headerColor}`}>{flight.alert_category || "N/A"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">AIRCRAFT</span>
|
||||
<span className="text-white text-xs font-bold">{flight.alert_type || flight.model || "UNKNOWN"}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">AIRCRAFT</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.alert_type || flight.model || "UNKNOWN"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">REGISTRATION</span>
|
||||
<span className="text-white text-xs font-bold">{flight.registration || "N/A"}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">REGISTRATION</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.registration || "N/A"}</span>
|
||||
</div>
|
||||
{flight.alert_tag1 && (
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">INTEL TAG</span>
|
||||
<span className={`text-xs font-bold ${headerColor}`}>{flight.alert_tag1}</span>
|
||||
{flight.alert_tags && (
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">INTEL TAGS</span>
|
||||
<span className={`text-xs font-bold text-right max-w-[200px] ${headerColor}`}>{flight.alert_tags}</span>
|
||||
</div>
|
||||
)}
|
||||
{flight.alert_tag2 && (
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">SECONDARY</span>
|
||||
<span className="text-white text-xs font-bold">{flight.alert_tag2}</span>
|
||||
</div>
|
||||
)}
|
||||
{flight.alert_tag3 && (
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">DETAIL</span>
|
||||
<span className="text-gray-400 text-xs">{flight.alert_tag3}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">ALTITUDE</span>
|
||||
<span className="text-white text-xs font-bold">{(Math.round((flight.alt || 0) / 0.3048)).toLocaleString()} ft</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">ALTITUDE</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{(Math.round((flight.alt || 0) / 0.3048)).toLocaleString()} ft</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">GROUND SPEED</span>
|
||||
<span className="text-white text-xs font-bold">{flight.speed_knots ? `${flight.speed_knots} kts` : 'N/A'}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">GROUND SPEED</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.speed_knots ? `${flight.speed_knots} kts` : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">HEADING</span>
|
||||
<span className="text-white text-xs font-bold">{Math.round(flight.heading || 0)}°</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">HEADING</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{Math.round(flight.heading || 0)}°</span>
|
||||
</div>
|
||||
{flight.squawk && (
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">SQUAWK</span>
|
||||
<span className={`text-xs font-bold ${flight.squawk === '7700' ? 'text-red-400 animate-pulse' : flight.squawk === '7600' ? 'text-yellow-400' : 'text-white'}`}>{flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">SQUAWK</span>
|
||||
<span className={`text-xs font-bold ${flight.squawk === '7700' ? 'text-red-400 animate-pulse' : flight.squawk === '7600' ? 'text-yellow-400' : 'text-[var(--text-primary)]'}`}>{flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
{flight.alert_link && (
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">REFERENCE</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">REFERENCE</span>
|
||||
<a href={flight.alert_link} target="_blank" rel="noreferrer" className={`text-xs font-bold underline ${headerColor} hover:opacity-80`}>
|
||||
View Intel Source
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{flight.icao24 && (
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">FLIGHT RECORD</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">FLIGHT RECORD</span>
|
||||
<a href={`https://adsb.lol/?icao=${flight.icao24}`} target="_blank" rel="noreferrer" className={`${headerColor} hover:opacity-80 text-xs font-bold underline`}>
|
||||
View History Log
|
||||
</a>
|
||||
@@ -417,34 +461,34 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
<h2 className={`text-xs tracking-widest font-bold ${selectedEntity.type === 'military_flight' ? 'text-red-400' : selectedEntity.type === 'private_flight' ? 'text-orange-400' : selectedEntity.type === 'private_jet' ? 'text-purple-400' : 'text-cyan-400'} flex items-center gap-2`}>
|
||||
{selectedEntity.type === 'military_flight' ? "MILITARY BOGEY INTERCEPT" : selectedEntity.type === 'private_flight' ? "PRIVATE TRANSPONDER" : selectedEntity.type === 'private_jet' ? "PRIVATE JET TRANSPONDER" : "COMMERCIAL TRANSPONDER"}
|
||||
</h2>
|
||||
<span className="text-[10px] text-gray-500 font-mono">TRK: {callsign}</span>
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono">TRK: {callsign}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">OPERATOR</span>
|
||||
<span className="text-white text-xs font-bold">{airline}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">OPERATOR</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{airline}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">REGISTRATION</span>
|
||||
<span className="text-white text-xs font-bold">{flight.registration || "N/A"}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">REGISTRATION</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.registration || "N/A"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">AIRCRAFT MODEL</span>
|
||||
<span className="text-white text-xs font-bold">{flight.model || "UNKNOWN"}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">AIRCRAFT MODEL</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.model || "UNKNOWN"}</span>
|
||||
</div>
|
||||
{/* Aircraft photo + Wikipedia link */}
|
||||
{(aircraftImgUrl || aircraftImgLoading || aircraftWikiUrl) && (
|
||||
<div className="border-b border-gray-800 pb-3">
|
||||
<div className="border-b border-[var(--border-primary)] pb-3">
|
||||
{aircraftImgLoading && (
|
||||
<div className="w-full h-24 rounded bg-gray-800/60 animate-pulse" />
|
||||
<div className="w-full h-24 rounded bg-[var(--bg-tertiary)]/60 animate-pulse" />
|
||||
)}
|
||||
{aircraftImgUrl && (
|
||||
<a href={aircraftWikiUrl || '#'} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<img
|
||||
src={aircraftImgUrl}
|
||||
alt={AIRCRAFT_WIKI[flight.model] || flight.model}
|
||||
className="w-full h-auto max-h-32 object-cover rounded border border-gray-700/50 hover:border-cyan-500/50 transition-colors"
|
||||
className="w-full h-auto max-h-32 object-cover rounded border border-[var(--border-primary)]/50 hover:border-cyan-500/50 transition-colors"
|
||||
style={{ imageRendering: 'auto' }}
|
||||
/>
|
||||
</a>
|
||||
@@ -457,31 +501,31 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">ALTITUDE</span>
|
||||
<span className="text-white text-xs font-bold">{(Math.round((flight.alt || 0) / 0.3048)).toLocaleString()} ft</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">ALTITUDE</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{(Math.round((flight.alt || 0) / 0.3048)).toLocaleString()} ft</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">GROUND SPEED</span>
|
||||
<span className="text-white text-xs font-bold">{flight.speed_knots ? `${flight.speed_knots} kts` : 'N/A'}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">GROUND SPEED</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.speed_knots ? `${flight.speed_knots} kts` : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">HEADING</span>
|
||||
<span className="text-white text-xs font-bold">{Math.round(flight.heading || 0)}°</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">HEADING</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{Math.round(flight.heading || 0)}°</span>
|
||||
</div>
|
||||
{flight.squawk && (
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">SQUAWK</span>
|
||||
<span className={`text-xs font-bold ${flight.squawk === '7700' ? 'text-red-400 animate-pulse' : flight.squawk === '7600' ? 'text-yellow-400' : 'text-white'}`}>{flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">SQUAWK</span>
|
||||
<span className={`text-xs font-bold ${flight.squawk === '7700' ? 'text-red-400 animate-pulse' : flight.squawk === '7600' ? 'text-yellow-400' : 'text-[var(--text-primary)]'}`}>{flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">ROUTE</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">ROUTE</span>
|
||||
<span className="text-cyan-400 text-xs font-bold">{flight.origin_name !== "UNKNOWN" ? `[${flight.origin_name}] → [${flight.dest_name}]` : "UNKNOWN"}</span>
|
||||
</div>
|
||||
{flight.icao24 && (
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">FLIGHT RECORD</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">FLIGHT RECORD</span>
|
||||
<a href={`https://adsb.lol/?icao=${flight.icao24}`} target="_blank" rel="noreferrer" className="text-cyan-400 hover:text-cyan-300 text-xs font-bold underline">
|
||||
View History Log
|
||||
</a>
|
||||
@@ -514,7 +558,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
'military_vessel': 'text-yellow-400',
|
||||
'carrier': 'text-orange-400',
|
||||
};
|
||||
const headerColor = headerColorMap[ship.type] || 'text-gray-400';
|
||||
const headerColor = headerColorMap[ship.type] || 'text-[var(--text-secondary)]';
|
||||
|
||||
const headerTitleMap: Record<string, string> = {
|
||||
'tanker': 'AIS TANKER INTERCEPT',
|
||||
@@ -537,49 +581,49 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
<h2 className={`text-xs tracking-widest font-bold ${headerColor} flex items-center gap-2`}>
|
||||
{headerTitle}
|
||||
</h2>
|
||||
<span className="text-[10px] text-gray-500 font-mono">MMSI: {ship.mmsi || 'N/A'}</span>
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono">MMSI: {ship.mmsi || 'N/A'}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">VESSEL NAME</span>
|
||||
<span className="text-white text-xs font-bold text-right ml-4">{ship.name || 'UNKNOWN'}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">VESSEL NAME</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold text-right ml-4">{ship.name || 'UNKNOWN'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">VESSEL TYPE</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">VESSEL TYPE</span>
|
||||
<span className={`text-xs font-bold ${headerColor}`}>{typeLabel}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">FLAG STATE</span>
|
||||
<span className="text-white text-xs font-bold">{ship.country || 'UNKNOWN'}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">FLAG STATE</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.country || 'UNKNOWN'}</span>
|
||||
</div>
|
||||
{ship.callsign && (
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">CALLSIGN</span>
|
||||
<span className="text-white text-xs font-bold">{ship.callsign}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">CALLSIGN</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.callsign}</span>
|
||||
</div>
|
||||
)}
|
||||
{ship.imo > 0 && (
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">IMO NUMBER</span>
|
||||
<span className="text-white text-xs font-bold">{ship.imo}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">IMO NUMBER</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.imo}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">DESTINATION</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">DESTINATION</span>
|
||||
<span className={`text-xs font-bold ${ship.destination && ship.destination !== 'UNKNOWN' ? 'text-cyan-400' : 'text-orange-400'}`}>{ship.destination || 'UNKNOWN'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">SPEED (SOG)</span>
|
||||
<span className="text-white text-xs font-bold">{ship.type === 'carrier' ? 'UNKNOWN' : `${ship.sog || 0} kts`}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">SPEED (SOG)</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.type === 'carrier' ? 'UNKNOWN' : `${ship.sog || 0} kts`}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">COURSE (COG)</span>
|
||||
<span className="text-white text-xs font-bold">{ship.type === 'carrier' ? 'UNKNOWN' : `${Math.round(ship.cog || 0)}°`}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">COURSE (COG)</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.type === 'carrier' ? 'UNKNOWN' : `${Math.round(ship.cog || 0)}°`}</span>
|
||||
</div>
|
||||
{ship.mmsi && (
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">VESSEL RECORD</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">VESSEL RECORD</span>
|
||||
<a href={`https://www.marinetraffic.com/en/ais/details/ships/mmsi:${ship.mmsi}`} target="_blank" rel="noreferrer" className="text-cyan-400 hover:text-cyan-300 text-xs font-bold underline">
|
||||
View on MarineTraffic
|
||||
</a>
|
||||
@@ -587,7 +631,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
)}
|
||||
{/* Ship/Carrier Wikipedia photo */}
|
||||
{(ship.wiki || VESSEL_TYPE_WIKI[ship.type]) && (
|
||||
<div className="border-t border-gray-800 pt-2">
|
||||
<div className="border-t border-[var(--border-primary)] pt-2">
|
||||
<WikiImage
|
||||
wikiUrl={ship.wiki || VESSEL_TYPE_WIKI[ship.type]}
|
||||
label={ship.type === 'carrier' ? ship.name : typeLabel}
|
||||
@@ -617,24 +661,48 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
<h2 className="text-xs tracking-widest font-bold text-orange-400 flex items-center gap-2">
|
||||
<AlertTriangle size={14} className="text-orange-400" /> MILITARY INCIDENT CLUSTER
|
||||
</h2>
|
||||
<span className="text-[10px] text-gray-500 font-mono">ID: {selectedEntity.id}</span>
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono">ID: {selectedEntity.id}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">LOCATION</span>
|
||||
<span className="text-white text-xs font-bold text-right ml-4">{props.name || 'UNKNOWN REGION'}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">LOCATION</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold text-right ml-4">{props.name || 'UNKNOWN REGION'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">ARTICLE COUNT</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">ARTICLE COUNT</span>
|
||||
<span className="text-orange-400 text-xs font-bold">{props.count || 1}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
<span className="text-gray-500 text-[10px]">LATEST REPORTS:</span>
|
||||
<div
|
||||
className="text-white text-xs whitespace-normal [&_a]:text-orange-400 [&_a]:underline hover:[&_a]:text-orange-300 [&_br]:mb-2"
|
||||
dangerouslySetInnerHTML={{ __html: props.html || 'No articles available.' }}
|
||||
/>
|
||||
<span className="text-[var(--text-muted)] text-[10px]">LATEST REPORTS:</span>
|
||||
<div className="flex flex-col gap-1 max-h-[250px] overflow-y-auto styled-scrollbar">
|
||||
{(() => {
|
||||
const urls: string[] = props._urls_list || [];
|
||||
const headlines: string[] = props._headlines_list || [];
|
||||
if (urls.length === 0) return <span className="text-[var(--text-muted)] text-[10px]">No articles available.</span>;
|
||||
return urls.map((url: string, idx: number) => {
|
||||
const headline = headlines[idx] || '';
|
||||
let domain = '';
|
||||
try { domain = new URL(url).hostname.replace('www.', ''); } catch { domain = ''; }
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block py-1.5 border-b border-[var(--border-primary)]/50 last:border-0 cursor-pointer group"
|
||||
>
|
||||
<span className="text-orange-400 text-[11px] font-bold leading-tight group-hover:text-orange-300 block">
|
||||
{headline || domain || 'View Article'}
|
||||
</span>
|
||||
{headline && domain && (
|
||||
<span className="text-[var(--text-muted)] text-[9px] block mt-0.5">{domain}</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -656,25 +724,25 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
<h2 className="text-xs tracking-widest font-bold text-yellow-400 flex items-center gap-2">
|
||||
<AlertTriangle size={14} className="text-yellow-400" /> REGIONAL TACTICAL EVENT
|
||||
</h2>
|
||||
<span className="text-[10px] text-gray-500 font-mono">ID: {item.id}</span>
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono">ID: {item.id}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">REGION</span>
|
||||
<span className="text-white text-xs font-bold text-right ml-4">{item.region || 'UNKNOWN'}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">REGION</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold text-right ml-4">{item.region || 'UNKNOWN'}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">DESCRIPTION</span>
|
||||
<div className="flex flex-col gap-2 border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">DESCRIPTION</span>
|
||||
<span className="text-yellow-400 text-xs font-bold leading-tight">{item.title}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2 mt-2">
|
||||
<span className="text-gray-500 text-[10px]">REPORTED TIME</span>
|
||||
<span className="text-white text-xs font-bold">{item.timestamp || 'UNKNOWN'}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2 mt-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">REPORTED TIME</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{item.timestamp || 'UNKNOWN'}</span>
|
||||
</div>
|
||||
{item.link && (
|
||||
<div className="flex justify-between items-center pb-2 mt-2">
|
||||
<span className="text-gray-500 text-[10px]">SOURCE</span>
|
||||
<span className="text-[var(--text-muted)] text-[10px]">SOURCE</span>
|
||||
<a href={item.link} target="_blank" rel="noreferrer" className="text-yellow-400 hover:text-yellow-300 text-xs font-bold underline">
|
||||
View Liveuamap Report
|
||||
</a>
|
||||
@@ -700,16 +768,16 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
<h2 className="text-xs tracking-widest font-bold text-red-400 flex items-center gap-2">
|
||||
<AlertTriangle size={14} className="text-red-400" /> THREAT INTERCEPT
|
||||
</h2>
|
||||
<span className="text-[10px] text-gray-500 font-mono">LVL: {item.risk_score}/10</span>
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono">LVL: {item.risk_score}/10</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">SOURCE</span>
|
||||
<span className="text-white text-xs font-bold text-right ml-4">{item.source || 'UNKNOWN'}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">SOURCE</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold text-right ml-4">{item.source || 'UNKNOWN'}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">HEADLINE</span>
|
||||
<div className="flex flex-col gap-2 border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">HEADLINE</span>
|
||||
<span className="text-red-400 text-xs font-bold leading-tight">{item.title}</span>
|
||||
</div>
|
||||
{item.machine_assessment && (
|
||||
@@ -721,7 +789,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
)}
|
||||
{item.link && (
|
||||
<div className="flex justify-between items-center pb-2 mt-2">
|
||||
<span className="text-gray-500 text-[10px]">REFERENCE</span>
|
||||
<span className="text-[var(--text-muted)] text-[10px]">REFERENCE</span>
|
||||
<a href={item.link} target="_blank" rel="noreferrer" className="text-red-400 hover:text-red-300 text-xs font-bold underline">
|
||||
View Source Article
|
||||
</a>
|
||||
@@ -747,20 +815,20 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
<h2 className="text-xs tracking-widest font-bold text-cyan-400 flex items-center gap-2">
|
||||
AERONAUTICAL HUB
|
||||
</h2>
|
||||
<span className="text-[10px] text-gray-500 font-mono">IATA: {apt.iata}</span>
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono">IATA: {apt.iata}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">FACILITY NAME</span>
|
||||
<span className="text-white text-[10px] font-bold text-right ml-4 break-words">{apt.name}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">FACILITY NAME</span>
|
||||
<span className="text-[var(--text-primary)] text-[10px] font-bold text-right ml-4 break-words">{apt.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">COORDINATES</span>
|
||||
<span className="text-white text-xs font-bold">{apt.lat.toFixed(4)}, {apt.lng.toFixed(4)}</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">COORDINATES</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{apt.lat.toFixed(4)}, {apt.lng.toFixed(4)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||
<span className="text-gray-500 text-[10px]">STATUS</span>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">STATUS</span>
|
||||
<span className="text-green-400 animate-pulse text-xs font-bold">OPERATIONAL</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -783,7 +851,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
? new Date(selectedEntity.extra.last_updated + 'Z').toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZoneName: 'short' }).toUpperCase() + ' — OPTIC INTERCEPT'
|
||||
: 'OPTIC INTERCEPT'}
|
||||
</h2>
|
||||
<span className="text-[10px] text-gray-500 font-mono">ID: {selectedEntity.id}</span>
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono">ID: {selectedEntity.id}</span>
|
||||
</div>
|
||||
<div className="relative w-full h-48 bg-black flex items-center justify-center p-1">
|
||||
{(() => {
|
||||
@@ -807,11 +875,8 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
/>
|
||||
);
|
||||
if (mt === 'hls') return (
|
||||
<video
|
||||
src={url}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
<HlsVideo
|
||||
url={url}
|
||||
className="w-full h-full object-cover border border-cyan-900/50 rounded-sm filter contrast-125 saturate-50"
|
||||
/>
|
||||
);
|
||||
@@ -870,7 +935,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className={`w-full bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden transition-all duration-300 ${isMinimized ? 'h-[50px] flex-shrink-0' : 'flex-1 min-h-0'}`}
|
||||
className={`w-full bg-[var(--bg-panel)] backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden transition-all duration-300 ${isMinimized ? 'h-[50px] flex-shrink-0' : 'flex-1 min-h-0'}`}
|
||||
>
|
||||
<div
|
||||
className="p-3 border-b border-cyan-500/20 bg-cyan-950/20 relative overflow-hidden cursor-pointer hover:bg-cyan-900/30 transition-colors"
|
||||
@@ -880,7 +945,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
<h2 className="text-xs tracking-widest font-bold text-cyan-400 flex items-center gap-2">
|
||||
<AlertTriangle size={14} /> GLOBAL THREAT INTERCEPT
|
||||
</h2>
|
||||
<button className="text-cyan-500 hover:text-white transition-colors">
|
||||
<button className="text-cyan-500 hover:text-[var(--text-primary)] transition-colors">
|
||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
@@ -933,19 +998,19 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
<motion.div
|
||||
key={idx}
|
||||
ref={(el) => { itemRefs.current[idx] = el; }}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
initial={idx < 15 ? { opacity: 0, x: -10 } : { opacity: 1, x: 0 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 + (idx * 0.05) }}
|
||||
transition={idx < 15 ? { delay: 0.1 + (idx * 0.05) } : { duration: 0 }}
|
||||
className={`p-2 rounded-sm border-l-[2px] border-r border-t border-b ${bgClass} flex flex-col gap-1 relative group shrink-0`}
|
||||
>
|
||||
<div className="flex items-center justify-between text-[8px] text-gray-400 uppercase tracking-widest">
|
||||
<div className="flex items-center justify-between text-[8px] text-[var(--text-secondary)] uppercase tracking-widest">
|
||||
<span className="font-bold flex items-center gap-1 text-cyan-600">
|
||||
>_ {item.source}
|
||||
</span>
|
||||
<span>[{item.published ? formatTime(item.published) : ''}]</span>
|
||||
</div>
|
||||
|
||||
<a href={item.link} target="_blank" rel="noreferrer" className={`text-[11px] ${titleClass} hover:text-white transition-colors leading-tight`}>
|
||||
<a href={item.link} target="_blank" rel="noreferrer" className={`text-[11px] ${titleClass} hover:text-[var(--text-primary)] transition-colors leading-tight`}>
|
||||
{item.title}
|
||||
</a>
|
||||
|
||||
@@ -963,12 +1028,12 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.cluster_count > 1 && (
|
||||
<button onClick={() => toggleExpand(idx)} className="text-[8px] font-bold text-cyan-500 bg-cyan-950/50 hover:text-white hover:bg-cyan-900 border border-cyan-500/30 px-1.5 py-0.5 rounded-sm transition-colors cursor-pointer">
|
||||
<button onClick={() => toggleExpand(idx)} className="text-[8px] font-bold text-cyan-500 bg-cyan-950/50 hover:text-[var(--text-primary)] hover:bg-cyan-900 border border-cyan-500/30 px-1.5 py-0.5 rounded-sm transition-colors cursor-pointer">
|
||||
{isExpanded ? '[- COLLAPSE]' : `[+${item.cluster_count - 1} SOURCES]`}
|
||||
</button>
|
||||
)}
|
||||
{item.coords && (
|
||||
<span className="text-[8px] text-gray-500 font-mono tracking-tighter">
|
||||
<span className="text-[8px] text-[var(--text-muted)] font-mono tracking-tighter">
|
||||
{item.coords[0].toFixed(2)}, {item.coords[1].toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
@@ -985,7 +1050,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
>
|
||||
{item.articles.slice(1).map((subItem: any, subIdx: number) => (
|
||||
<div key={subIdx} className="flex flex-col gap-0.5 pl-2 border-l border-cyan-500/20">
|
||||
<div className="flex items-center justify-between text-[7.5px] text-gray-500 uppercase font-bold">
|
||||
<div className="flex items-center justify-between text-[7.5px] text-[var(--text-muted)] uppercase font-bold">
|
||||
<span>>_ {subItem.source}</span>
|
||||
<span className={
|
||||
subItem.risk_score >= 9 ? 'text-red-400' :
|
||||
@@ -994,7 +1059,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
'text-green-400'
|
||||
}>LVL: {subItem.risk_score}/10</span>
|
||||
</div>
|
||||
<a href={subItem.link} target="_blank" rel="noreferrer" className="text-[10px] text-gray-400 hover:text-white transition-colors leading-tight">
|
||||
<a href={subItem.link} target="_blank" rel="noreferrer" className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors leading-tight">
|
||||
{subItem.title}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, ExternalLink, Key, Shield, Radar, Globe, Satellite, Ship, Radio } from "lucide-react";
|
||||
|
||||
const STORAGE_KEY = "shadowbroker_onboarding_complete";
|
||||
|
||||
const API_GUIDES = [
|
||||
{
|
||||
name: "OpenSky Network",
|
||||
icon: <Radar size={14} className="text-cyan-400" />,
|
||||
required: true,
|
||||
description: "Flight tracking with global ADS-B coverage. Provides real-time aircraft positions.",
|
||||
steps: [
|
||||
"Create a free account at opensky-network.org",
|
||||
"Go to Dashboard → OAuth → Create Client",
|
||||
"Copy your Client ID and Client Secret",
|
||||
"Paste both into Settings → Aviation",
|
||||
],
|
||||
url: "https://opensky-network.org/index.php?option=com_users&view=registration",
|
||||
color: "cyan",
|
||||
},
|
||||
{
|
||||
name: "AIS Stream",
|
||||
icon: <Ship size={14} className="text-blue-400" />,
|
||||
required: true,
|
||||
description: "Real-time vessel tracking via AIS (Automatic Identification System).",
|
||||
steps: [
|
||||
"Register at aisstream.io",
|
||||
"Navigate to your API Keys page",
|
||||
"Generate a new API key",
|
||||
"Paste it into Settings → Maritime",
|
||||
],
|
||||
url: "https://aisstream.io/authenticate",
|
||||
color: "blue",
|
||||
},
|
||||
];
|
||||
|
||||
const FREE_SOURCES = [
|
||||
{ name: "ADS-B Exchange", desc: "Military & general aviation", icon: <Radar size={12} /> },
|
||||
{ name: "USGS Earthquakes", desc: "Global seismic data", icon: <Globe size={12} /> },
|
||||
{ name: "CelesTrak", desc: "2,000+ satellite orbits", icon: <Satellite size={12} /> },
|
||||
{ name: "GDELT Project", desc: "Global conflict events", icon: <Globe size={12} /> },
|
||||
{ name: "RainViewer", desc: "Weather radar overlay", icon: <Globe size={12} /> },
|
||||
{ name: "OpenMHz", desc: "Radio scanner feeds", icon: <Radio size={12} /> },
|
||||
{ name: "RSS Feeds", desc: "NPR, BBC, Reuters, AP", icon: <Globe size={12} /> },
|
||||
{ name: "Yahoo Finance", desc: "Defense stocks & oil", icon: <Globe size={12} /> },
|
||||
];
|
||||
|
||||
interface OnboardingModalProps {
|
||||
onClose: () => void;
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
const OnboardingModal = React.memo(function OnboardingModal({ onClose, onOpenSettings }: OnboardingModalProps) {
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleOpenSettings = () => {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
onClose();
|
||||
onOpenSettings();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
key="onboarding-backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[10000]"
|
||||
onClick={handleDismiss}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<motion.div
|
||||
key="onboarding-modal"
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
className="fixed inset-0 z-[10001] flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<div
|
||||
className="w-[580px] max-h-[85vh] bg-[var(--bg-secondary)]/98 border border-cyan-900/50 rounded-xl shadow-[0_0_80px_rgba(0,200,255,0.08)] pointer-events-auto flex flex-col overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-6 pb-4 border-b border-[var(--border-primary)]/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center">
|
||||
<Shield size={20} className="text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">MISSION BRIEFING</h2>
|
||||
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">FIRST-TIME SETUP</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="w-8 h-8 rounded-lg border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Indicators */}
|
||||
<div className="flex gap-2 px-6 pt-4">
|
||||
{["Welcome", "API Keys", "Free Sources"].map((label, i) => (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => setStep(i)}
|
||||
className={`flex-1 py-1.5 text-[9px] font-mono tracking-widest rounded border transition-all ${
|
||||
step === i
|
||||
? "border-cyan-500/50 text-cyan-400 bg-cyan-950/20"
|
||||
: "border-[var(--border-primary)] text-[var(--text-muted)] hover:border-[var(--border-secondary)] hover:text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{label.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-6">
|
||||
{step === 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center py-4">
|
||||
<div className="text-lg font-bold tracking-[0.3em] text-[var(--text-primary)] font-mono mb-2">
|
||||
S H A D O W <span className="text-cyan-400">B R O K E R</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-[var(--text-secondary)] font-mono leading-relaxed max-w-md mx-auto">
|
||||
Real-time OSINT dashboard aggregating 12+ live intelligence sources.
|
||||
Flights, ships, satellites, earthquakes, conflicts, and more — all on one map.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-950/20 border border-yellow-500/20 rounded-lg p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Key size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-[11px] text-yellow-400 font-mono font-bold mb-1">API Keys Required</p>
|
||||
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||
Two API keys are needed for full functionality: <span className="text-cyan-400">OpenSky Network</span> (flights) and <span className="text-blue-400">AIS Stream</span> (ships).
|
||||
Both are free. Without them, some panels will show no data.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-950/20 border border-green-500/20 rounded-lg p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Globe size={14} className="text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-[11px] text-green-400 font-mono font-bold mb-1">8 Sources Work Immediately</p>
|
||||
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||
Military aircraft, satellites, earthquakes, global conflicts, weather radar, radio scanners, news, and market data all work out of the box — no keys needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
{API_GUIDES.map((api) => (
|
||||
<div key={api.name} className={`rounded-lg border border-${api.color}-900/30 bg-${api.color}-950/10 p-4`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{api.icon}
|
||||
<span className="text-xs font-mono text-white font-bold">{api.name}</span>
|
||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-yellow-500/30 text-yellow-400 bg-yellow-950/20">REQUIRED</span>
|
||||
</div>
|
||||
<a
|
||||
href={api.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`text-[10px] font-mono text-${api.color}-400 hover:text-${api.color}-300 flex items-center gap-1 transition-colors`}
|
||||
>
|
||||
GET KEY <ExternalLink size={10} />
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--text-secondary)] font-mono mb-3">{api.description}</p>
|
||||
<ol className="space-y-1.5">
|
||||
{api.steps.map((s, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className={`text-[9px] font-mono text-${api.color}-500 font-bold mt-0.5 w-3 flex-shrink-0`}>{i + 1}.</span>
|
||||
<span className="text-[10px] text-gray-300 font-mono">{s}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={handleOpenSettings}
|
||||
className="w-full py-3 rounded-lg bg-cyan-500/10 border border-cyan-500/30 text-cyan-400 hover:bg-cyan-500/20 transition-colors text-[11px] font-mono tracking-widest flex items-center justify-center gap-2"
|
||||
>
|
||||
<Key size={14} />
|
||||
OPEN SETTINGS TO ENTER KEYS
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] text-[var(--text-secondary)] font-mono mb-3">
|
||||
These data sources are completely free and require no API keys. They activate automatically on launch.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{FREE_SOURCES.map((src) => (
|
||||
<div key={src.name} className="rounded-lg border border-[var(--border-primary)]/60 bg-[var(--bg-secondary)]/30 p-3 hover:border-[var(--border-secondary)] transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-green-500">{src.icon}</span>
|
||||
<span className="text-[10px] font-mono text-[var(--text-primary)] font-medium">{src.name}</span>
|
||||
</div>
|
||||
<p className="text-[9px] text-[var(--text-muted)] font-mono">{src.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-[var(--border-primary)]/80 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setStep(Math.max(0, step - 1))}
|
||||
className={`px-4 py-2 rounded border text-[10px] font-mono tracking-widest transition-all ${
|
||||
step === 0
|
||||
? "border-[var(--border-primary)] text-[var(--text-muted)] cursor-not-allowed"
|
||||
: "border-[var(--border-primary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)]"
|
||||
}`}
|
||||
disabled={step === 0}
|
||||
>
|
||||
PREV
|
||||
</button>
|
||||
|
||||
<div className="flex gap-1.5">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className={`w-1.5 h-1.5 rounded-full transition-colors ${step === i ? "bg-cyan-400" : "bg-[var(--border-primary)]"}`} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{step < 2 ? (
|
||||
<button
|
||||
onClick={() => setStep(step + 1)}
|
||||
className="px-4 py-2 rounded border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/10 text-[10px] font-mono tracking-widest transition-all"
|
||||
>
|
||||
NEXT
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="px-4 py-2 rounded bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/30 text-[10px] font-mono tracking-widest transition-all"
|
||||
>
|
||||
LAUNCH
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
|
||||
export function useOnboarding() {
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const done = localStorage.getItem(STORAGE_KEY);
|
||||
if (!done) {
|
||||
setShowOnboarding(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { showOnboarding, setShowOnboarding };
|
||||
}
|
||||
|
||||
export default OnboardingModal;
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { RadioReceiver, Activity, Play, Square, FastForward, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesdropping, eavesdropLocation, cameraCenter }: { data: any, isEavesdropping?: boolean, setIsEavesdropping?: (val: boolean) => void, eavesdropLocation?: { lat: number, lng: number } | null, cameraCenter?: { lat: number, lng: number } | null }) {
|
||||
export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesdropping, eavesdropLocation, cameraCenter, selectedEntity }: { data: any, isEavesdropping?: boolean, setIsEavesdropping?: (val: boolean) => void, eavesdropLocation?: { lat: number, lng: number } | null, cameraCenter?: { lat: number, lng: number } | null, selectedEntity?: { type: string, id: string | number, extra?: any } | null }) {
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
const [feeds, setFeeds] = useState<any[]>([]);
|
||||
const [activeFeed, setActiveFeed] = useState<any | null>(null);
|
||||
@@ -18,7 +19,7 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
||||
useEffect(() => {
|
||||
const fetchFeeds = async () => {
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/radio/top");
|
||||
const res = await fetch(`${API_BASE}/api/radio/top`);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setFeeds(json);
|
||||
@@ -47,12 +48,12 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
||||
category: 'SIGINT'
|
||||
}, ...prev]);
|
||||
|
||||
const res = await fetch(`http://localhost:8000/api/radio/nearest?lat=${eavesdropLocation.lat}&lng=${eavesdropLocation.lng}`);
|
||||
const res = await fetch(`${API_BASE}/api/radio/nearest?lat=${eavesdropLocation.lat}&lng=${eavesdropLocation.lng}`);
|
||||
if (res.ok) {
|
||||
const system = await res.json();
|
||||
if (system && system.shortName) {
|
||||
// Valid OpenMHZ system found! Fetch recent calls
|
||||
const callRes = await fetch(`http://localhost:8000/api/radio/openmhz/calls/${system.shortName}`);
|
||||
const callRes = await fetch(`${API_BASE}/api/radio/openmhz/calls/${system.shortName}`);
|
||||
if (callRes.ok) {
|
||||
const calls = await callRes.json();
|
||||
if (calls && calls.length > 0) {
|
||||
@@ -189,14 +190,14 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
||||
|
||||
if (scanLoc) {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8000/api/radio/nearest-list?lat=${scanLoc.lat}&lng=${scanLoc.lng}&limit=3`);
|
||||
const res = await fetch(`${API_BASE}/api/radio/nearest-list?lat=${scanLoc.lat}&lng=${scanLoc.lng}&limit=3`);
|
||||
if (res.ok) {
|
||||
const systems = await res.json();
|
||||
|
||||
// Try to find a system with an active unplayed burst
|
||||
for (const system of systems) {
|
||||
if (system && system.shortName) {
|
||||
const callRes = await fetch(`http://localhost:8000/api/radio/openmhz/calls/${system.shortName}`);
|
||||
const callRes = await fetch(`${API_BASE}/api/radio/openmhz/calls/${system.shortName}`);
|
||||
if (callRes.ok) {
|
||||
const calls = await callRes.json();
|
||||
if (calls && calls.length > 0) {
|
||||
@@ -248,7 +249,7 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className="w-full flex flex-col bg-black/40 backdrop-blur-md border border-cyan-900/50 rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.5)] relative overflow-hidden max-h-full"
|
||||
className="w-full flex flex-col bg-[var(--bg-primary)]/40 backdrop-blur-md border border-cyan-900/50 rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.2)] relative overflow-hidden max-h-full"
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between p-3 border-b border-cyan-900/50 cursor-pointer bg-cyan-950/20 hover:bg-cyan-900/30 transition-colors"
|
||||
@@ -273,13 +274,13 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
||||
className="flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Audio Player Controls */}
|
||||
<div className="p-4 border-b border-cyan-900/40 bg-black/60">
|
||||
<div className="p-4 border-b border-cyan-900/40 bg-[var(--bg-primary)]/60">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-cyan-300 font-mono tracking-wide">
|
||||
{activeFeed ? activeFeed.name : "NO SIGNAL"}
|
||||
</span>
|
||||
<span className="text-[9px] text-gray-500 font-mono">
|
||||
<span className="text-[9px] text-[var(--text-muted)] font-mono">
|
||||
{activeFeed ? `LOCATION: ${activeFeed.location.toUpperCase()}` : "AWAITING TUNING..."}
|
||||
</span>
|
||||
</div>
|
||||
@@ -346,6 +347,36 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KiwiSDR Tuner — appears when a KiwiSDR node is clicked on the map */}
|
||||
{selectedEntity?.type === 'kiwisdr' && selectedEntity.extra?.url && (
|
||||
<div className="p-3 border-b border-amber-900/40 bg-amber-950/10">
|
||||
<div className="text-[9px] text-amber-400 font-mono tracking-widest mb-2 flex items-center gap-2">
|
||||
<RadioReceiver size={10} />
|
||||
SDR TUNER: {(selectedEntity.extra.name || 'REMOTE RECEIVER').toUpperCase().slice(0, 60)}
|
||||
</div>
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono mb-2">
|
||||
{selectedEntity.extra.location && <span>{selectedEntity.extra.location} · </span>}
|
||||
{selectedEntity.extra.antenna && <span>{selectedEntity.extra.antenna.slice(0, 80)} · </span>}
|
||||
{selectedEntity.extra.users !== undefined && <span>{selectedEntity.extra.users}/{selectedEntity.extra.users_max} users</span>}
|
||||
</div>
|
||||
<iframe
|
||||
src={selectedEntity.extra.url}
|
||||
className="w-full h-72 rounded border border-amber-900/50 bg-black"
|
||||
allow="microphone"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
title="KiwiSDR Tuner"
|
||||
/>
|
||||
<a
|
||||
href={selectedEntity.extra.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[8px] text-amber-500 hover:text-amber-300 font-mono mt-1 inline-block"
|
||||
>
|
||||
OPEN IN NEW TAB →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feed List */}
|
||||
<div className="flex-col overflow-y-auto styled-scrollbar max-h-64 p-2">
|
||||
{feeds.length === 0 ? (
|
||||
@@ -358,10 +389,10 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
||||
className={`p-2 mb-1 rounded cursor-pointer border-l-2 ${activeFeed?.id === feed.id ? 'bg-cyan-900/30 border-cyan-400' : 'border-transparent hover:bg-white/5'} flex justify-between items-center transition-colors`}
|
||||
>
|
||||
<div className="flex flex-col overflow-hidden pr-2">
|
||||
<span className={`text-[11px] font-mono truncate ${activeFeed?.id === feed.id ? 'text-cyan-300' : 'text-gray-300'}`}>
|
||||
<span className={`text-[11px] font-mono truncate ${activeFeed?.id === feed.id ? 'text-cyan-300' : 'text-[var(--text-secondary)]'}`}>
|
||||
{feed.name}
|
||||
</span>
|
||||
<span className="text-[9px] text-gray-500 font-mono truncate">
|
||||
<span className="text-[9px] text-[var(--text-muted)] font-mono truncate">
|
||||
{feed.location} | {feed.category}
|
||||
</span>
|
||||
</div>
|
||||
@@ -370,7 +401,7 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
||||
<Activity size={10} />
|
||||
{feed.listeners.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-[8px] text-gray-600 font-mono mt-0.5">LSTN</span>
|
||||
<span className="text-[8px] text-[var(--text-muted)] font-mono mt-0.5">LSTN</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -136,7 +136,7 @@ function ScaleBar({ zoom, latitude, measureMode, measurePoints, onToggleMeasure,
|
||||
{/* Unit toggle */}
|
||||
<button
|
||||
onClick={() => setUnit(u => u === "mi" ? "km" : "mi")}
|
||||
className="text-[8px] font-mono tracking-widest px-1.5 py-0.5 rounded border border-gray-700 hover:border-cyan-500/50 text-gray-500 hover:text-cyan-400 transition-all hover:bg-cyan-950/20 uppercase"
|
||||
className="text-[8px] font-mono tracking-widest px-1.5 py-0.5 rounded border border-[var(--border-primary)] hover:border-cyan-500/50 text-[var(--text-muted)] hover:text-cyan-400 transition-all hover:bg-cyan-950/20 uppercase"
|
||||
title={`Switch to ${unit === "mi" ? "Metric (km)" : "Imperial (mi)"}`}
|
||||
>
|
||||
{unit === "mi" ? "MI" : "KM"}
|
||||
@@ -147,7 +147,7 @@ function ScaleBar({ zoom, latitude, measureMode, measurePoints, onToggleMeasure,
|
||||
onClick={onToggleMeasure}
|
||||
className={`flex items-center gap-1 text-[8px] font-mono tracking-widest px-2 py-0.5 rounded border transition-all ${measureMode
|
||||
? "border-cyan-500/60 text-cyan-400 bg-cyan-950/30 shadow-[0_0_8px_rgba(0,255,255,0.2)]"
|
||||
: "border-gray-700 text-gray-500 hover:text-cyan-400 hover:border-cyan-500/50 hover:bg-cyan-950/20"
|
||||
: "border-[var(--border-primary)] text-[var(--text-muted)] hover:text-cyan-400 hover:border-cyan-500/50 hover:bg-cyan-950/20"
|
||||
}`}
|
||||
title={measureMode ? "Exit measurement mode" : "Measure distance (click up to 3 points)"}
|
||||
>
|
||||
@@ -159,7 +159,7 @@ function ScaleBar({ zoom, latitude, measureMode, measurePoints, onToggleMeasure,
|
||||
{measureMode && measurePoints && measurePoints.length > 0 && (
|
||||
<button
|
||||
onClick={onClearMeasure}
|
||||
className="flex items-center gap-1 text-[8px] font-mono tracking-widest px-1.5 py-0.5 rounded border border-gray-700 text-gray-500 hover:text-red-400 hover:border-red-500/50 hover:bg-red-950/20 transition-all"
|
||||
className="flex items-center gap-1 text-[8px] font-mono tracking-widest px-1.5 py-0.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-red-400 hover:border-red-500/50 hover:bg-red-950/20 transition-all"
|
||||
title="Clear all waypoints"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
@@ -172,7 +172,7 @@ function ScaleBar({ zoom, latitude, measureMode, measurePoints, onToggleMeasure,
|
||||
{segmentDistances.map((d, i) => (
|
||||
<span key={i} className={`text-[9px] font-mono tracking-wider px-1.5 py-0.5 rounded border ${d.startsWith("Σ")
|
||||
? "border-cyan-500/50 text-cyan-300 bg-cyan-950/30"
|
||||
: "border-gray-700 text-gray-400"
|
||||
: "border-[var(--border-primary)] text-[var(--text-secondary)]"
|
||||
}`}>
|
||||
{d}
|
||||
</span>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Settings, Eye, EyeOff, Copy, Check, ExternalLink, Key, Shield, X, Save, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Settings, ExternalLink, Key, Shield, X, Save, ChevronDown, ChevronUp, Rss, Plus, Trash2, RotateCcw } from "lucide-react";
|
||||
|
||||
interface ApiEntry {
|
||||
id: string;
|
||||
@@ -14,9 +15,25 @@ interface ApiEntry {
|
||||
has_key: boolean;
|
||||
env_key: string | null;
|
||||
value_obfuscated: string | null;
|
||||
value_plain: string | null;
|
||||
is_set: boolean;
|
||||
}
|
||||
|
||||
interface FeedEntry {
|
||||
name: string;
|
||||
url: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
const WEIGHT_LABELS: Record<number, string> = { 1: "LOW", 2: "MED", 3: "STD", 4: "HIGH", 5: "CRIT" };
|
||||
const WEIGHT_COLORS: Record<number, string> = {
|
||||
1: "text-gray-400 border-gray-600",
|
||||
2: "text-blue-400 border-blue-600",
|
||||
3: "text-cyan-400 border-cyan-600",
|
||||
4: "text-orange-400 border-orange-600",
|
||||
5: "text-red-400 border-red-600",
|
||||
};
|
||||
const MAX_FEEDS = 20;
|
||||
|
||||
// Category colors for the tactical UI
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
Aviation: "text-cyan-400 border-cyan-500/30 bg-cyan-950/20",
|
||||
@@ -30,91 +47,139 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
SIGINT: "text-rose-400 border-rose-500/30 bg-rose-950/20",
|
||||
};
|
||||
|
||||
type Tab = "api-keys" | "news-feeds";
|
||||
|
||||
const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("api-keys");
|
||||
|
||||
// --- API Keys state ---
|
||||
const [apis, setApis] = useState<ApiEntry[]>([]);
|
||||
const [revealedKeys, setRevealedKeys] = useState<Set<string>>(new Set());
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(["Aviation", "Maritime"]));
|
||||
|
||||
// --- News Feeds state ---
|
||||
const [feeds, setFeeds] = useState<FeedEntry[]>([]);
|
||||
const [feedsDirty, setFeedsDirty] = useState(false);
|
||||
const [feedSaving, setFeedSaving] = useState(false);
|
||||
const [feedMsg, setFeedMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
const fetchKeys = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/settings/api-keys");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setApis(data);
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/api/settings/api-keys`);
|
||||
if (res.ok) setApis(await res.json());
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch API keys", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) fetchKeys();
|
||||
}, [isOpen, fetchKeys]);
|
||||
|
||||
const toggleReveal = (id: string) => {
|
||||
setRevealedKeys(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = async (id: string, value: string) => {
|
||||
const fetchFeeds = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
} catch {
|
||||
// Clipboard API may fail in some contexts
|
||||
const res = await fetch(`${API_BASE}/api/settings/news-feeds`);
|
||||
if (res.ok) {
|
||||
setFeeds(await res.json());
|
||||
setFeedsDirty(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch news feeds", e);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startEditing = (api: ApiEntry) => {
|
||||
setEditingId(api.id);
|
||||
setEditValue(api.value_plain || "");
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchKeys();
|
||||
fetchFeeds();
|
||||
}
|
||||
}, [isOpen, fetchKeys, fetchFeeds]);
|
||||
|
||||
// API Keys handlers
|
||||
const startEditing = (api: ApiEntry) => { setEditingId(api.id); setEditValue(""); };
|
||||
|
||||
const saveKey = async (api: ApiEntry) => {
|
||||
if (!api.env_key) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/settings/api-keys", {
|
||||
const res = await fetch(`${API_BASE}/api/settings/api-keys`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ env_key: api.env_key, value: editValue }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEditingId(null);
|
||||
fetchKeys(); // Refresh to get new obfuscated value
|
||||
}
|
||||
if (res.ok) { setEditingId(null); fetchKeys(); }
|
||||
} catch (e) {
|
||||
console.error("Failed to save API key", e);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const toggleCategory = (cat: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(cat)) next.delete(cat);
|
||||
else next.add(cat);
|
||||
if (next.has(cat)) next.delete(cat); else next.add(cat);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Group APIs by category
|
||||
const grouped = apis.reduce<Record<string, ApiEntry[]>>((acc, api) => {
|
||||
if (!acc[api.category]) acc[api.category] = [];
|
||||
acc[api.category].push(api);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// News Feeds handlers
|
||||
const updateFeed = (idx: number, field: keyof FeedEntry, value: string | number) => {
|
||||
setFeeds(prev => prev.map((f, i) => i === idx ? { ...f, [field]: value } : f));
|
||||
setFeedsDirty(true);
|
||||
setFeedMsg(null);
|
||||
};
|
||||
|
||||
const removeFeed = (idx: number) => {
|
||||
setFeeds(prev => prev.filter((_, i) => i !== idx));
|
||||
setFeedsDirty(true);
|
||||
setFeedMsg(null);
|
||||
};
|
||||
|
||||
const addFeed = () => {
|
||||
if (feeds.length >= MAX_FEEDS) return;
|
||||
setFeeds(prev => [...prev, { name: "", url: "", weight: 3 }]);
|
||||
setFeedsDirty(true);
|
||||
setFeedMsg(null);
|
||||
};
|
||||
|
||||
const saveFeeds = async () => {
|
||||
setFeedSaving(true);
|
||||
setFeedMsg(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/settings/news-feeds`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(feeds),
|
||||
});
|
||||
if (res.ok) {
|
||||
setFeedsDirty(false);
|
||||
setFeedMsg({ type: "ok", text: "Feeds saved. Changes take effect on next news refresh (~30min) or manual /api/refresh." });
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
setFeedMsg({ type: "err", text: d.message || "Save failed" });
|
||||
}
|
||||
} catch (e) {
|
||||
setFeedMsg({ type: "err", text: "Network error" });
|
||||
} finally { setFeedSaving(false); }
|
||||
};
|
||||
|
||||
const resetFeeds = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/settings/news-feeds/reset`, { method: "POST" });
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
setFeeds(d.feeds || []);
|
||||
setFeedsDirty(false);
|
||||
setFeedMsg({ type: "ok", text: "Reset to defaults" });
|
||||
}
|
||||
} catch (e) {
|
||||
setFeedMsg({ type: "err", text: "Reset failed" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
@@ -134,192 +199,258 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -300 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
className="fixed left-0 top-0 bottom-0 w-[480px] bg-gray-950/95 backdrop-blur-xl border-r border-cyan-900/50 z-[9999] flex flex-col shadow-[4px_0_40px_rgba(0,0,0,0.8)]"
|
||||
className="fixed left-0 top-0 bottom-0 w-[480px] bg-[var(--bg-secondary)]/95 backdrop-blur-xl border-r border-cyan-900/50 z-[9999] flex flex-col shadow-[4px_0_40px_rgba(0,0,0,0.3)]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-800/80">
|
||||
<div className="flex items-center justify-between p-6 border-b border-[var(--border-primary)]/80">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center">
|
||||
<Settings size={16} className="text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-[0.2em] text-white font-mono">SYSTEM CONFIG</h2>
|
||||
<span className="text-[9px] text-gray-500 font-mono tracking-widest">API KEY REGISTRY</span>
|
||||
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">SYSTEM CONFIG</h2>
|
||||
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">SETTINGS & DATA SOURCES</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-lg border border-gray-700 hover:border-red-500/50 flex items-center justify-center text-gray-500 hover:text-red-400 transition-all hover:bg-red-950/20"
|
||||
className="w-8 h-8 rounded-lg border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="mx-4 mt-4 p-3 rounded-lg border border-cyan-900/30 bg-cyan-950/10">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-[10px] text-gray-400 font-mono leading-relaxed">
|
||||
API keys are stored locally in the backend <span className="text-cyan-400">.env</span> file. Keys marked with <Key size={8} className="inline text-yellow-500" /> are required for full functionality. Public APIs need no key.
|
||||
</p>
|
||||
</div>
|
||||
{/* Tab Bar */}
|
||||
<div className="flex border-b border-[var(--border-primary)]/60">
|
||||
<button
|
||||
onClick={() => setActiveTab("api-keys")}
|
||||
className={`flex-1 px-4 py-2.5 text-[10px] font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === "api-keys" ? "text-cyan-400 border-b-2 border-cyan-500 bg-cyan-950/10" : "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"}`}
|
||||
>
|
||||
<Key size={10} />
|
||||
API KEYS
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("news-feeds")}
|
||||
className={`flex-1 px-4 py-2.5 text-[10px] font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === "news-feeds" ? "text-orange-400 border-b-2 border-orange-500 bg-orange-950/10" : "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"}`}
|
||||
>
|
||||
<Rss size={10} />
|
||||
NEWS FEEDS
|
||||
{feedsDirty && <span className="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* API List */}
|
||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-3">
|
||||
{Object.entries(grouped).map(([category, categoryApis]) => {
|
||||
const colorClass = CATEGORY_COLORS[category] || "text-gray-400 border-gray-700 bg-gray-900/20";
|
||||
const isExpanded = expandedCategories.has(category);
|
||||
|
||||
return (
|
||||
<div key={category} className="rounded-lg border border-gray-800/60 overflow-hidden">
|
||||
{/* Category Header */}
|
||||
<button
|
||||
onClick={() => toggleCategory(category)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 bg-gray-900/50 hover:bg-gray-900/80 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${colorClass}`}>
|
||||
{category.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-500 font-mono">
|
||||
{categoryApis.length} {categoryApis.length === 1 ? 'service' : 'services'}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? <ChevronUp size={12} className="text-gray-500" /> : <ChevronDown size={12} className="text-gray-500" />}
|
||||
</button>
|
||||
|
||||
{/* APIs in Category */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{categoryApis.map((api) => (
|
||||
<div key={api.id} className="border-t border-gray-800/40 px-4 py-3 hover:bg-gray-900/30 transition-colors">
|
||||
{/* API Name + Status */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{api.required && <Key size={10} className="text-yellow-500" />}
|
||||
<span className="text-xs font-mono text-white font-medium">{api.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{api.has_key ? (
|
||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-green-500/30 text-green-400 bg-green-950/20">
|
||||
KEY SET
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-gray-700 text-gray-500">
|
||||
PUBLIC
|
||||
</span>
|
||||
)}
|
||||
{api.url && (
|
||||
<a
|
||||
href={api.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-600 hover:text-cyan-400 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink size={10} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-[10px] text-gray-500 font-mono leading-relaxed mb-2">
|
||||
{api.description}
|
||||
</p>
|
||||
|
||||
{/* Key Field (only for APIs with keys) */}
|
||||
{api.has_key && (
|
||||
<div className="mt-2">
|
||||
{editingId === api.id ? (
|
||||
/* Edit Mode */
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
className="flex-1 bg-black/60 border border-cyan-900/50 rounded px-2 py-1.5 text-[11px] font-mono text-cyan-300 outline-none focus:border-cyan-500/70 transition-colors"
|
||||
placeholder="Enter API key..."
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveKey(api)}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 rounded bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/30 transition-colors text-[10px] font-mono flex items-center gap-1"
|
||||
>
|
||||
<Save size={10} />
|
||||
{saving ? "..." : "SAVE"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
className="px-2 py-1.5 rounded border border-gray-700 text-gray-500 hover:text-white hover:border-gray-600 transition-colors text-[10px] font-mono"
|
||||
>
|
||||
ESC
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Display Mode */
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="flex-1 bg-black/40 border border-gray-800 rounded px-2.5 py-1.5 font-mono text-[11px] cursor-pointer hover:border-gray-700 transition-colors select-none"
|
||||
onClick={() => startEditing(api)}
|
||||
>
|
||||
<span className={revealedKeys.has(api.id) ? "text-cyan-300" : "text-gray-500 tracking-wider"}>
|
||||
{revealedKeys.has(api.id) ? api.value_plain : api.value_obfuscated}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Eye Toggle */}
|
||||
<button
|
||||
onClick={() => toggleReveal(api.id)}
|
||||
className={`w-7 h-7 rounded flex items-center justify-center border transition-all ${revealedKeys.has(api.id)
|
||||
? "border-cyan-500/40 text-cyan-400 bg-cyan-950/30"
|
||||
: "border-gray-700 text-gray-600 hover:text-gray-400 hover:border-gray-600"
|
||||
}`}
|
||||
title={revealedKeys.has(api.id) ? "Hide key" : "Reveal key"}
|
||||
>
|
||||
{revealedKeys.has(api.id) ? <EyeOff size={12} /> : <Eye size={12} />}
|
||||
</button>
|
||||
|
||||
{/* Copy */}
|
||||
<button
|
||||
onClick={() => copyToClipboard(api.id, api.value_plain || "")}
|
||||
className={`w-7 h-7 rounded flex items-center justify-center border transition-all ${copiedId === api.id
|
||||
? "border-green-500/40 text-green-400 bg-green-950/30"
|
||||
: "border-gray-700 text-gray-600 hover:text-gray-400 hover:border-gray-600"
|
||||
}`}
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copiedId === api.id ? <Check size={12} /> : <Copy size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* ==================== API KEYS TAB ==================== */}
|
||||
{activeTab === "api-keys" && (
|
||||
<>
|
||||
{/* Info Banner */}
|
||||
<div className="mx-4 mt-4 p-3 rounded-lg border border-cyan-900/30 bg-cyan-950/10">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||
API keys are stored locally in the backend <span className="text-cyan-400">.env</span> file. Keys marked with <Key size={8} className="inline text-yellow-500" /> are required for full functionality. Public APIs need no key.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-800/80">
|
||||
<div className="flex items-center justify-between text-[9px] text-gray-600 font-mono">
|
||||
<span>{apis.length} REGISTERED APIs</span>
|
||||
<span>{apis.filter(a => a.has_key).length} KEYS CONFIGURED</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* API List */}
|
||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-3">
|
||||
{Object.entries(grouped).map(([category, categoryApis]) => {
|
||||
const colorClass = CATEGORY_COLORS[category] || "text-gray-400 border-gray-700 bg-gray-900/20";
|
||||
const isExpanded = expandedCategories.has(category);
|
||||
return (
|
||||
<div key={category} className="rounded-lg border border-[var(--border-primary)]/60 overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleCategory(category)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 bg-[var(--bg-secondary)]/50 hover:bg-[var(--bg-secondary)]/80 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${colorClass}`}>
|
||||
{category.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono">
|
||||
{categoryApis.length} {categoryApis.length === 1 ? 'service' : 'services'}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? <ChevronUp size={12} className="text-[var(--text-muted)]" /> : <ChevronDown size={12} className="text-[var(--text-muted)]" />}
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{categoryApis.map((api) => (
|
||||
<div key={api.id} className="border-t border-[var(--border-primary)]/40 px-4 py-3 hover:bg-[var(--bg-secondary)]/30 transition-colors">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{api.required && <Key size={10} className="text-yellow-500" />}
|
||||
<span className="text-xs font-mono text-[var(--text-primary)] font-medium">{api.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{api.has_key ? (
|
||||
api.is_set ? (
|
||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-green-500/30 text-green-400 bg-green-950/20">KEY SET</span>
|
||||
) : (
|
||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-yellow-500/30 text-yellow-400 bg-yellow-950/20">MISSING</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)]">PUBLIC</span>
|
||||
)}
|
||||
{api.url && (
|
||||
<a href={api.url} target="_blank" rel="noopener noreferrer" className="text-[var(--text-muted)] hover:text-cyan-400 transition-colors" onClick={(e) => e.stopPropagation()}>
|
||||
<ExternalLink size={10} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--text-muted)] font-mono leading-relaxed mb-2">{api.description}</p>
|
||||
{api.has_key && (
|
||||
<div className="mt-2">
|
||||
{editingId === api.id ? (
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={editValue} onChange={(e) => setEditValue(e.target.value)} className="flex-1 bg-black/60 border border-cyan-900/50 rounded px-2 py-1.5 text-[11px] font-mono text-cyan-300 outline-none focus:border-cyan-500/70 transition-colors" placeholder="Enter API key..." autoFocus />
|
||||
<button onClick={() => saveKey(api)} disabled={saving} className="px-3 py-1.5 rounded bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/30 transition-colors text-[10px] font-mono flex items-center gap-1">
|
||||
<Save size={10} />{saving ? "..." : "SAVE"}
|
||||
</button>
|
||||
<button onClick={() => setEditingId(null)} className="px-2 py-1.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)] transition-colors text-[10px] font-mono">ESC</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1 bg-[var(--bg-primary)]/40 border border-[var(--border-primary)] rounded px-2.5 py-1.5 font-mono text-[11px] cursor-pointer hover:border-[var(--border-secondary)] transition-colors select-none" onClick={() => startEditing(api)}>
|
||||
<span className="text-[var(--text-muted)] tracking-wider">{api.is_set ? api.value_obfuscated : "Click to set key..."}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-[var(--border-primary)]/80">
|
||||
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono">
|
||||
<span>{apis.length} REGISTERED APIs</span>
|
||||
<span>{apis.filter(a => a.has_key).length} KEYS CONFIGURED</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ==================== NEWS FEEDS TAB ==================== */}
|
||||
{activeTab === "news-feeds" && (
|
||||
<>
|
||||
{/* Info Banner */}
|
||||
<div className="mx-4 mt-4 p-3 rounded-lg border border-orange-900/30 bg-orange-950/10">
|
||||
<div className="flex items-start gap-2">
|
||||
<Rss size={12} className="text-orange-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||
Configure RSS/Atom feeds for the Threat Intel news panel. Each feed is scored by keyword heuristics and weighted by the priority you set. Up to <span className="text-orange-400">{MAX_FEEDS}</span> sources.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feed List */}
|
||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-2">
|
||||
{feeds.map((feed, idx) => (
|
||||
<div key={idx} className="rounded-lg border border-[var(--border-primary)]/60 p-3 hover:border-[var(--border-secondary)]/60 transition-colors group">
|
||||
{/* Row 1: Name + Weight + Delete */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={feed.name}
|
||||
onChange={(e) => updateFeed(idx, "name", e.target.value)}
|
||||
className="flex-1 bg-transparent border-b border-[var(--border-primary)] text-xs font-mono text-[var(--text-primary)] outline-none focus:border-cyan-500/70 transition-colors px-1 py-0.5"
|
||||
placeholder="Source name..."
|
||||
/>
|
||||
{/* Weight selector */}
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map(w => (
|
||||
<button
|
||||
key={w}
|
||||
onClick={() => updateFeed(idx, "weight", w)}
|
||||
className={`w-5 h-5 rounded text-[8px] font-mono font-bold border transition-all ${feed.weight === w ? WEIGHT_COLORS[w] + " bg-black/40" : "border-[var(--border-primary)]/40 text-[var(--text-muted)]/50 hover:border-[var(--border-secondary)]"}`}
|
||||
title={WEIGHT_LABELS[w]}
|
||||
>
|
||||
{w}
|
||||
</button>
|
||||
))}
|
||||
<span className={`text-[8px] font-mono ml-1 w-7 ${WEIGHT_COLORS[feed.weight]?.split(" ")[0] || "text-gray-400"}`}>
|
||||
{WEIGHT_LABELS[feed.weight] || "STD"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFeed(idx)}
|
||||
className="w-6 h-6 rounded flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 hover:bg-red-950/20 transition-all opacity-0 group-hover:opacity-100"
|
||||
title="Remove feed"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Row 2: URL */}
|
||||
<input
|
||||
type="text"
|
||||
value={feed.url}
|
||||
onChange={(e) => updateFeed(idx, "url", e.target.value)}
|
||||
className="w-full bg-black/30 border border-[var(--border-primary)]/40 rounded px-2 py-1 text-[10px] font-mono text-[var(--text-muted)] outline-none focus:border-cyan-500/50 focus:text-cyan-300 transition-colors"
|
||||
placeholder="https://example.com/rss.xml"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add Feed Button */}
|
||||
<button
|
||||
onClick={addFeed}
|
||||
disabled={feeds.length >= MAX_FEEDS}
|
||||
className="w-full py-2.5 rounded-lg border border-dashed border-[var(--border-primary)]/60 text-[var(--text-muted)] hover:border-orange-500/50 hover:text-orange-400 hover:bg-orange-950/10 transition-all text-[10px] font-mono flex items-center justify-center gap-1.5 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus size={10} />
|
||||
ADD FEED ({feeds.length}/{MAX_FEEDS})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
{feedMsg && (
|
||||
<div className={`mx-4 mb-2 px-3 py-2 rounded text-[10px] font-mono ${feedMsg.type === "ok" ? "text-green-400 bg-green-950/20 border border-green-900/30" : "text-red-400 bg-red-950/20 border border-red-900/30"}`}>
|
||||
{feedMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-[var(--border-primary)]/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={saveFeeds}
|
||||
disabled={!feedsDirty || feedSaving}
|
||||
className="flex-1 px-4 py-2 rounded bg-orange-500/20 border border-orange-500/40 text-orange-400 hover:bg-orange-500/30 transition-colors text-[10px] font-mono flex items-center justify-center gap-1.5 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save size={10} />
|
||||
{feedSaving ? "SAVING..." : "SAVE FEEDS"}
|
||||
</button>
|
||||
<button
|
||||
onClick={resetFeeds}
|
||||
className="px-3 py-2 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)] transition-all text-[10px] font-mono flex items-center gap-1.5"
|
||||
title="Reset to defaults"
|
||||
>
|
||||
<RotateCcw size={10} />
|
||||
RESET
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono mt-2">
|
||||
<span>{feeds.length}/{MAX_FEEDS} SOURCES</span>
|
||||
<span>WEIGHT: 1=LOW 5=CRITICAL</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Github, MessageSquare, Download, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import packageJson from "../../package.json";
|
||||
|
||||
export default function TopRightControls() {
|
||||
const [updateStatus, setUpdateStatus] = useState<"idle" | "checking" | "available" | "uptodate" | "error">("idle");
|
||||
const [latestVersion, setLatestVersion] = useState<string>("");
|
||||
|
||||
const currentVersion = packageJson.version;
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
setUpdateStatus("checking");
|
||||
try {
|
||||
const res = await fetch("https://api.github.com/repos/BigBodyCobain/Shadowbroker/releases/latest");
|
||||
if (!res.ok) throw new Error("Failed to fetch");
|
||||
const data = await res.json();
|
||||
|
||||
// Remove 'v' prefix if it exists to compare semver cleanly
|
||||
const latest = data.tag_name?.replace('v', '') || data.name?.replace('v', '');
|
||||
const current = currentVersion.replace('v', '');
|
||||
|
||||
if (latest && latest !== current) {
|
||||
setLatestVersion(latest);
|
||||
setUpdateStatus("available");
|
||||
} else {
|
||||
setUpdateStatus("uptodate");
|
||||
setTimeout(() => setUpdateStatus("idle"), 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Update check failed:", err);
|
||||
setUpdateStatus("error");
|
||||
setTimeout(() => setUpdateStatus("idle"), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-1 justify-end">
|
||||
<a
|
||||
href="https://github.com/BigBodyCobain/Shadowbroker/discussions"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-[var(--bg-primary)]/50 backdrop-blur-md border border-[var(--border-primary)] rounded-lg hover:border-cyan-500/50 hover:bg-[var(--hover-accent)] transition-all text-[10px] text-[var(--text-secondary)] font-mono cursor-pointer"
|
||||
>
|
||||
<MessageSquare size={12} className="text-cyan-400 w-3 h-3" />
|
||||
<span className="tracking-widest">DISCUSSIONS</span>
|
||||
</a>
|
||||
|
||||
{updateStatus === "available" ? (
|
||||
<a
|
||||
href="https://github.com/BigBodyCobain/Shadowbroker/releases/latest"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-green-500/10 backdrop-blur-md border border-green-500/50 rounded-lg hover:bg-green-500/20 transition-all text-[10px] text-green-400 font-mono cursor-pointer shadow-[0_0_15px_rgba(34,197,94,0.3)]"
|
||||
>
|
||||
<Download size={12} className="w-3 h-3" />
|
||||
<span className="tracking-widest animate-pulse">v{latestVersion} UPDATE!</span>
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
onClick={checkForUpdates}
|
||||
disabled={updateStatus === "checking"}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-[var(--bg-primary)]/50 backdrop-blur-md border border-[var(--border-primary)] rounded-lg hover:border-cyan-500/50 hover:bg-[var(--hover-accent)] transition-all text-[10px] text-[var(--text-secondary)] font-mono cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{updateStatus === "checking" && <Github size={12} className="w-3 h-3 animate-spin text-cyan-400" />}
|
||||
{updateStatus === "idle" && <Github size={12} className="w-3 h-3 text-cyan-400" />}
|
||||
{updateStatus === "uptodate" && <CheckCircle2 size={12} className="w-3 h-3 text-green-400" />}
|
||||
{updateStatus === "error" && <AlertCircle size={12} className="w-3 h-3 text-red-400" />}
|
||||
|
||||
<span className="tracking-widest">
|
||||
{updateStatus === "checking" ? "CHECKING..." :
|
||||
updateStatus === "uptodate" ? "UP TO DATE" :
|
||||
updateStatus === "error" ? "CHECK FAILED" :
|
||||
"CHECK UPDATES"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ const _cache: Record<string, { url: string | null; done: boolean }> = {};
|
||||
* maxH: Max height class (default "max-h-32")
|
||||
* accent: Border hover color class (default "hover:border-cyan-500/50")
|
||||
*/
|
||||
export default function WikiImage({ wikiUrl, label, maxH = 'max-h-32', accent = 'hover:border-cyan-500/50' }: {
|
||||
export default function WikiImage({ wikiUrl, label, maxH = 'max-h-52', accent = 'hover:border-cyan-500/50' }: {
|
||||
wikiUrl: string;
|
||||
label?: string;
|
||||
maxH?: string;
|
||||
@@ -49,14 +49,14 @@ export default function WikiImage({ wikiUrl, label, maxH = 'max-h-32', accent =
|
||||
return (
|
||||
<div className="pb-2">
|
||||
{loading && (
|
||||
<div className={`w-full h-20 rounded bg-gray-800/60 animate-pulse`} />
|
||||
<div className={`w-full h-20 rounded bg-[var(--bg-tertiary)]/60 animate-pulse`} />
|
||||
)}
|
||||
{imgUrl && (
|
||||
<a href={wikiUrl} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt={label || title.replace(/_/g, ' ')}
|
||||
className={`w-full h-auto ${maxH} object-cover rounded border border-gray-700/50 ${accent} transition-colors`}
|
||||
className={`w-full h-auto ${maxH} object-contain rounded border border-[var(--border-primary)]/50 ${accent} transition-colors`}
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
|
||||
@@ -1,16 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, BookOpen, Radio } from "lucide-react";
|
||||
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi, Server, Shield, ToggleLeft, ToggleRight } from "lucide-react";
|
||||
import packageJson from "../../package.json";
|
||||
import { useTheme } from "@/lib/ThemeContext";
|
||||
|
||||
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void }) {
|
||||
function relativeTime(iso: string | undefined): string {
|
||||
if (!iso) return "";
|
||||
const diff = Date.now() - new Date(iso + "Z").getTime();
|
||||
if (diff < 0) return "now";
|
||||
const sec = Math.floor(diff / 1000);
|
||||
if (sec < 60) return `${sec}s ago`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
return `${Math.floor(hr / 24)}d ago`;
|
||||
}
|
||||
|
||||
// Map layer IDs to freshness keys from the backend source_timestamps dict
|
||||
const FRESHNESS_MAP: Record<string, string> = {
|
||||
flights: "commercial_flights",
|
||||
private: "private_flights",
|
||||
jets: "private_jets",
|
||||
military: "military_flights",
|
||||
tracked: "military_flights",
|
||||
earthquakes: "earthquakes",
|
||||
satellites: "satellites",
|
||||
ships_important: "ships",
|
||||
ships_civilian: "ships",
|
||||
ships_passenger: "ships",
|
||||
ukraine_frontline: "frontlines",
|
||||
global_incidents: "gdelt",
|
||||
cctv: "cctv",
|
||||
gps_jamming: "commercial_flights",
|
||||
kiwisdr: "kiwisdr",
|
||||
firms: "firms_fires",
|
||||
internet_outages: "internet_outages",
|
||||
datacenters: "datacenters",
|
||||
};
|
||||
|
||||
// POTUS fleet ICAO hex codes for client-side filtering
|
||||
const POTUS_ICAOS: Record<string, { label: string; type: string }> = {
|
||||
'ADFDF8': { label: 'Air Force One (82-8000)', type: 'AF1' },
|
||||
'ADFDF9': { label: 'Air Force One (92-9000)', type: 'AF1' },
|
||||
'ADFEB7': { label: 'Air Force Two (98-0001)', type: 'AF2' },
|
||||
'ADFEB8': { label: 'Air Force Two (98-0002)', type: 'AF2' },
|
||||
'ADFEB9': { label: 'Air Force Two (99-0003)', type: 'AF2' },
|
||||
'ADFEBA': { label: 'Air Force Two (99-0004)', type: 'AF2' },
|
||||
'AE4AE6': { label: 'Air Force Two (09-0015)', type: 'AF2' },
|
||||
'AE4AE8': { label: 'Air Force Two (09-0016)', type: 'AF2' },
|
||||
'AE4AEA': { label: 'Air Force Two (09-0017)', type: 'AF2' },
|
||||
'AE4AEC': { label: 'Air Force Two (19-0018)', type: 'AF2' },
|
||||
'AE0865': { label: 'Marine One (VH-3D)', type: 'M1' },
|
||||
'AE5E76': { label: 'Marine One (VH-92A)', type: 'M1' },
|
||||
'AE5E77': { label: 'Marine One (VH-92A)', type: 'M1' },
|
||||
'AE5E79': { label: 'Marine One (VH-92A)', type: 'M1' },
|
||||
};
|
||||
|
||||
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity, onEntityClick, onFlyTo }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void; onEntityClick?: (entity: { type: string; id: number; extra?: any }) => void; onFlyTo?: (lat: number, lng: number) => void }) {
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [gibsPlaying, setGibsPlaying] = useState(false);
|
||||
const [potusEnabled, setPotusEnabled] = useState(true);
|
||||
const gibsIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Compute ship category counts
|
||||
const importantShipCount = data?.ships?.filter((s: any) => ['carrier', 'military_vessel', 'tanker', 'cargo'].includes(s.type))?.length || 0;
|
||||
const passengerShipCount = data?.ships?.filter((s: any) => s.type === 'passenger')?.length || 0;
|
||||
const civilianShipCount = data?.ships?.filter((s: any) => !['carrier', 'military_vessel', 'tanker', 'cargo', 'passenger'].includes(s.type))?.length || 0;
|
||||
// GIBS time slider play/pause animation
|
||||
useEffect(() => {
|
||||
if (!gibsPlaying || !setGibsDate) {
|
||||
if (gibsIntervalRef.current) clearInterval(gibsIntervalRef.current);
|
||||
gibsIntervalRef.current = null;
|
||||
return;
|
||||
}
|
||||
gibsIntervalRef.current = setInterval(() => {
|
||||
if (!gibsDate) return;
|
||||
const d = new Date(gibsDate + 'T00:00:00');
|
||||
d.setDate(d.getDate() + 1);
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (d > yesterday) {
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - 30);
|
||||
setGibsDate(start.toISOString().slice(0, 10));
|
||||
} else {
|
||||
setGibsDate(d.toISOString().slice(0, 10));
|
||||
}
|
||||
}, 1500);
|
||||
return () => { if (gibsIntervalRef.current) clearInterval(gibsIntervalRef.current); };
|
||||
}, [gibsPlaying, gibsDate, setGibsDate]);
|
||||
|
||||
// Compute ship category counts (memoized — ships array can be 1000+ items)
|
||||
const { importantShipCount, passengerShipCount, civilianShipCount } = useMemo(() => {
|
||||
const ships = data?.ships;
|
||||
if (!ships || !ships.length) return { importantShipCount: 0, passengerShipCount: 0, civilianShipCount: 0 };
|
||||
let important = 0, passenger = 0, civilian = 0;
|
||||
for (const s of ships) {
|
||||
const t = s.type;
|
||||
if (t === 'carrier' || t === 'military_vessel' || t === 'tanker' || t === 'cargo') important++;
|
||||
else if (t === 'passenger') passenger++;
|
||||
else civilian++;
|
||||
}
|
||||
return { importantShipCount: important, passengerShipCount: passenger, civilianShipCount: civilian };
|
||||
}, [data?.ships]);
|
||||
|
||||
// Find POTUS fleet planes currently airborne from tracked flights
|
||||
const potusFlights = useMemo(() => {
|
||||
const tracked = data?.tracked_flights;
|
||||
if (!tracked) return [];
|
||||
const results: { index: number; flight: any; meta: { label: string; type: string } }[] = [];
|
||||
for (let i = 0; i < tracked.length; i++) {
|
||||
const f = tracked[i];
|
||||
const icao = (f.icao24 || '').toUpperCase();
|
||||
if (POTUS_ICAOS[icao]) {
|
||||
results.push({ index: i, flight: f, meta: POTUS_ICAOS[icao] });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}, [data?.tracked_flights]);
|
||||
|
||||
const layers = [
|
||||
{ id: "flights", name: "Commercial Flights", source: "adsb.lol", count: data?.commercial_flights?.length || 0, icon: Plane },
|
||||
@@ -19,7 +126,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
||||
{ id: "military", name: "Military Flights", source: "adsb.lol", count: data?.military_flights?.length || 0, icon: AlertTriangle },
|
||||
{ id: "tracked", name: "Tracked Aircraft", source: "Plane-Alert DB", count: data?.tracked_flights?.length || 0, icon: Eye },
|
||||
{ id: "earthquakes", name: "Earthquakes (24h)", source: "USGS", count: data?.earthquakes?.length || 0, icon: Activity },
|
||||
{ id: "satellites", name: "Satellites", source: "CelesTrak SGP4", count: data?.satellites?.length || 0, icon: Satellite },
|
||||
{ id: "satellites", name: "Satellites", source: data?.satellite_source === "celestrak" ? "CelesTrak SGP4" : data?.satellite_source === "tle_api" ? "TLE API · SGP4" : data?.satellite_source === "disk_cache" ? "Cached · SGP4 (est.)" : "CelesTrak SGP4", count: data?.satellites?.length || 0, icon: Satellite },
|
||||
{ id: "ships_important", name: "Carriers / Mil / Cargo", source: "AIS Stream", count: importantShipCount, icon: Ship },
|
||||
{ id: "ships_civilian", name: "Civilian Vessels", source: "AIS Stream", count: civilianShipCount, icon: Anchor },
|
||||
{ id: "ships_passenger", name: "Cruise / Passenger", source: "AIS Stream", count: passengerShipCount, icon: Anchor },
|
||||
@@ -27,6 +134,12 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
||||
{ id: "global_incidents", name: "Global Incidents", source: "GDELT", count: data?.gdelt?.length || 0, icon: Activity },
|
||||
{ id: "cctv", name: "CCTV Mesh", source: "CCTV Mesh + Street View", count: data?.cctv?.length || 0, icon: Cctv },
|
||||
{ id: "gps_jamming", name: "GPS Jamming", source: "ADS-B NACp", count: data?.gps_jamming?.length || 0, icon: Radio },
|
||||
{ id: "gibs_imagery", name: "MODIS Terra (Daily)", source: "NASA GIBS", count: null, icon: Globe },
|
||||
{ id: "highres_satellite", name: "High-Res Satellite", source: "Esri World Imagery", count: null, icon: Satellite },
|
||||
{ id: "kiwisdr", name: "KiwiSDR Receivers", source: "KiwiSDR.com", count: data?.kiwisdr?.length || 0, icon: Radio },
|
||||
{ id: "firms", name: "Fire Hotspots (24h)", source: "NASA FIRMS VIIRS", count: data?.firms_fires?.length || 0, icon: Flame },
|
||||
{ id: "internet_outages", name: "Internet Outages", source: "IODA / Georgia Tech", count: data?.internet_outages?.length || 0, icon: Wifi },
|
||||
{ id: "datacenters", name: "Data Centers", source: "DC Map (GitHub)", count: data?.datacenters?.length || 0, icon: Server },
|
||||
{ id: "day_night", name: "Day / Night Cycle", source: "Solar Calc", count: null, icon: Sun },
|
||||
];
|
||||
|
||||
@@ -41,14 +154,21 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-6 pointer-events-auto">
|
||||
<div className="text-[10px] text-gray-400 font-mono tracking-widest mb-1">TOP SECRET // SI-TK // NOFORN</div>
|
||||
<div className="text-[10px] text-gray-500 font-mono tracking-widest mb-4">KH11-4094 OPS-4168</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] font-mono tracking-widest mb-1">TOP SECRET // SI-TK // NOFORN</div>
|
||||
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest mb-4">KH11-4094 OPS-4168</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold tracking-[0.2em] text-cyan-50">FLIR</h1>
|
||||
<h1 className="text-2xl font-bold tracking-[0.2em] text-[var(--text-heading)]">FLIR</h1>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)]`}
|
||||
title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
|
||||
</button>
|
||||
{onSettingsClick && (
|
||||
<button
|
||||
onClick={onSettingsClick}
|
||||
className="w-7 h-7 rounded-lg border border-gray-700 hover:border-cyan-500/50 flex items-center justify-center text-gray-500 hover:text-cyan-400 transition-all hover:bg-cyan-950/20 group"
|
||||
className={`w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)] group`}
|
||||
title="System Settings"
|
||||
>
|
||||
<Settings size={14} className="group-hover:rotate-90 transition-transform duration-300" />
|
||||
@@ -57,28 +177,49 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
||||
{onLegendClick && (
|
||||
<button
|
||||
onClick={onLegendClick}
|
||||
className="h-7 px-2 rounded-lg border border-gray-700 hover:border-cyan-500/50 flex items-center justify-center gap-1 text-gray-500 hover:text-cyan-400 transition-all hover:bg-cyan-950/20"
|
||||
className={`h-7 px-2 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center gap-1 ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)]`}
|
||||
title="Map Legend / Icon Key"
|
||||
>
|
||||
<BookOpen size={12} />
|
||||
<span className="text-[8px] font-mono tracking-widest font-bold">KEY</span>
|
||||
</button>
|
||||
)}
|
||||
<span className={`h-7 px-2 rounded-lg border border-[var(--border-primary)] flex items-center justify-center text-[8px] ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} font-mono tracking-widest select-none`}>
|
||||
v{packageJson.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Layers Box */}
|
||||
<div className="bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.5)] flex flex-col relative overflow-hidden max-h-full">
|
||||
<div className="bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.2)] flex flex-col relative overflow-hidden max-h-full">
|
||||
|
||||
{/* Header / Toggle */}
|
||||
<div
|
||||
className="flex justify-between items-center p-4 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
className="flex justify-between items-center p-4 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
|
||||
>
|
||||
<span className="text-[10px] text-gray-500 font-mono tracking-widest">DATA LAYERS</span>
|
||||
<button className="text-gray-500 hover:text-white transition-colors">
|
||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest" onClick={() => setIsMinimized(!isMinimized)}>DATA LAYERS</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
title={Object.entries(activeLayers).filter(([k]) => k !== 'gibs_imagery').every(([, v]) => v) ? "Disable all layers" : "Enable all layers"}
|
||||
className={`${Object.entries(activeLayers).filter(([k]) => k !== 'gibs_imagery').every(([, v]) => v) ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-400 transition-colors`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const allOn = Object.entries(activeLayers).filter(([k]) => k !== 'gibs_imagery').every(([, v]) => v);
|
||||
setActiveLayers((prev: any) => {
|
||||
const next: any = {};
|
||||
for (const k of Object.keys(prev)) {
|
||||
next[k] = k === 'gibs_imagery' ? false : !allOn;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{Object.entries(activeLayers).filter(([k]) => k !== 'gibs_imagery').every(([, v]) => v) ? <ToggleRight size={16} /> : <ToggleLeft size={16} />}
|
||||
</button>
|
||||
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors" onClick={() => setIsMinimized(!isMinimized)}>
|
||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
@@ -90,39 +231,168 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
||||
className="overflow-y-auto styled-scrollbar"
|
||||
>
|
||||
<div className="flex flex-col gap-6 p-4 pt-2 pb-6">
|
||||
{/* POTUS Fleet — pinned to TOP when aircraft are active */}
|
||||
{potusEnabled && potusFlights.length > 0 && (
|
||||
<div className="bg-[#ff1493]/5 border border-[#ff1493]/30 rounded-lg p-3 -mt-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={14} className="text-[#ff1493]" />
|
||||
<span className="text-[10px] text-[#ff1493] font-mono tracking-widest font-bold">POTUS FLEET</span>
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded-full bg-[#ff1493]/20 border border-[#ff1493]/40 text-[#ff1493] animate-pulse">
|
||||
{potusFlights.length} ACTIVE
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setPotusEnabled(false); }}
|
||||
className="text-[8px] font-mono text-[var(--text-muted)] hover:text-[#ff1493] border border-[var(--border-primary)] hover:border-[#ff1493]/40 rounded px-1.5 py-0.5 transition-colors"
|
||||
title="Hide POTUS Fleet tracker"
|
||||
>
|
||||
HIDE
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{potusFlights.map((pf) => {
|
||||
const color = pf.meta.type === 'AF1' ? '#ff1493' : pf.meta.type === 'M1' ? '#ff1493' : '#3b82f6';
|
||||
const alt = pf.flight.alt_baro || pf.flight.alt || 0;
|
||||
const speed = pf.flight.gs || pf.flight.speed || 0;
|
||||
return (
|
||||
<div
|
||||
key={pf.flight.icao24}
|
||||
className="flex items-center justify-between p-2 rounded-lg border cursor-pointer transition-all hover:bg-[var(--bg-secondary)]/60"
|
||||
style={{ borderColor: `${color}40`, background: `${color}10` }}
|
||||
onClick={() => {
|
||||
if (onFlyTo && pf.flight.lat != null && pf.flight.lng != null) {
|
||||
onFlyTo(pf.flight.lat, pf.flight.lng);
|
||||
}
|
||||
if (onEntityClick) {
|
||||
onEntityClick({ type: 'tracked_flight', id: pf.index });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold font-mono" style={{ color }}>{pf.meta.label}</span>
|
||||
<span className="text-[8px] text-[var(--text-muted)] font-mono mt-0.5">
|
||||
{alt > 0 ? `${Math.round(alt).toLocaleString()} ft` : 'GND'} · {speed > 0 ? `${Math.round(speed)} kts` : 'STATIC'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ backgroundColor: color }} />
|
||||
<span className="text-[8px] font-mono" style={{ color }}>TRACK</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{layers.map((layer, idx) => {
|
||||
const Icon = layer.icon;
|
||||
const active = activeLayers[layer.id as keyof typeof activeLayers] || false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start justify-between group cursor-pointer"
|
||||
onClick={() => setActiveLayers((prev: any) => ({ ...prev, [layer.id]: !active }))}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className={`mt-1 ${active ? 'text-cyan-400' : 'text-gray-600 group-hover:text-gray-400'} transition-colors`}>
|
||||
{(['ships_important', 'ships_civilian', 'ships_passenger'].includes(layer.id)) ? shipIcon : <Icon size={16} strokeWidth={1.5} />}
|
||||
<div key={idx} className="flex flex-col">
|
||||
<div
|
||||
className="flex items-start justify-between group cursor-pointer"
|
||||
onClick={() => setActiveLayers((prev: any) => ({ ...prev, [layer.id]: !active }))}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className={`mt-1 ${active ? 'text-cyan-400' : 'text-gray-600 group-hover:text-gray-400'} transition-colors`}>
|
||||
{(['ships_important', 'ships_civilian', 'ships_passenger'].includes(layer.id)) ? shipIcon : <Icon size={16} strokeWidth={1.5} />}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className={`text-sm font-medium ${active ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'} tracking-wide`}>{layer.name}</span>
|
||||
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-wider mt-0.5">{layer.source} · {active ? (() => {
|
||||
const fKey = FRESHNESS_MAP[layer.id];
|
||||
const freshness = fKey && data?.freshness?.[fKey];
|
||||
const rt = freshness ? relativeTime(freshness) : '';
|
||||
return rt ? <span className="text-cyan-500/70">{rt}</span> : 'LIVE';
|
||||
})() : 'OFF'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className={`text-sm font-medium ${active ? 'text-white' : 'text-gray-400'} tracking-wide`}>{layer.name}</span>
|
||||
<span className="text-[9px] text-gray-600 font-mono tracking-wider mt-0.5">{layer.source} · {active ? 'LIVE' : 'OFF'}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{active && layer.count > 0 && (
|
||||
<span className="text-[10px] text-gray-300 font-mono">{layer.count.toLocaleString()}</span>
|
||||
)}
|
||||
<div className={`text-[9px] font-mono tracking-wider px-2 py-0.5 rounded-full border ${active
|
||||
? 'border-cyan-500/50 text-cyan-400 bg-cyan-950/30 shadow-[0_0_10px_rgba(34,211,238,0.2)]'
|
||||
: 'border-[var(--border-primary)] text-[var(--text-muted)] bg-transparent'
|
||||
}`}>
|
||||
{active ? 'ON' : 'OFF'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{active && layer.count > 0 && (
|
||||
<span className="text-[10px] text-gray-300 font-mono">{layer.count.toLocaleString()}</span>
|
||||
)}
|
||||
<div className={`text-[9px] font-mono tracking-wider px-2 py-0.5 rounded-full border ${active
|
||||
? 'border-cyan-500/50 text-cyan-400 bg-cyan-950/30 shadow-[0_0_10px_rgba(34,211,238,0.2)]'
|
||||
: 'border-gray-800 text-gray-600 bg-transparent'
|
||||
}`}>
|
||||
{active ? 'ON' : 'OFF'}
|
||||
{/* GIBS Imagery inline controls: time slider + play/pause + opacity */}
|
||||
{active && layer.id === 'gibs_imagery' && gibsDate && setGibsDate && setGibsOpacity && (
|
||||
<div className="ml-7 mt-2 flex flex-col gap-2" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setGibsPlaying(p => !p)}
|
||||
className="w-5 h-5 flex items-center justify-center rounded border border-cyan-500/30 text-cyan-400 hover:bg-cyan-950/30 transition-colors"
|
||||
>
|
||||
{gibsPlaying ? <Pause size={10} /> : <Play size={10} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={29}
|
||||
value={(() => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const selected = new Date(gibsDate + 'T00:00:00');
|
||||
const diff = Math.round((yesterday.getTime() - selected.getTime()) / 86400000);
|
||||
return 29 - Math.max(0, Math.min(29, diff));
|
||||
})()}
|
||||
onChange={e => {
|
||||
const daysAgo = 29 - parseInt(e.target.value);
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 1 - daysAgo);
|
||||
setGibsDate(d.toISOString().slice(0, 10));
|
||||
}}
|
||||
className="flex-1 h-1 accent-cyan-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[8px] text-cyan-400 font-mono">{gibsDate}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[8px] text-[var(--text-muted)] font-mono">OPC</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={Math.round((gibsOpacity ?? 0.6) * 100)}
|
||||
onChange={e => setGibsOpacity(parseInt(e.target.value) / 100)}
|
||||
className="w-16 h-1 accent-cyan-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* POTUS Fleet — bottom section when inactive or hidden */}
|
||||
{(potusFlights.length === 0 || !potusEnabled) && (
|
||||
<div className="border-t border-[var(--border-primary)]/50 pt-4 mt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={14} className="text-[var(--text-muted)]" />
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">POTUS FLEET</span>
|
||||
</div>
|
||||
{!potusEnabled ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setPotusEnabled(true); }}
|
||||
className="text-[8px] font-mono text-[var(--text-muted)] hover:text-[#ff1493] border border-[var(--border-primary)] hover:border-[#ff1493]/40 rounded px-1.5 py-0.5 transition-colors"
|
||||
>
|
||||
SHOW
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[8px] font-mono text-[var(--text-muted)]">NO ACTIVE AIRCRAFT</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -26,14 +26,14 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className={`w-full bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl z-10 flex flex-col font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden transition-all duration-300 flex-shrink-0 ${isMinimized ? 'h-[50px]' : 'h-[320px]'}`}
|
||||
className={`w-full bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl z-10 flex flex-col font-mono shadow-[0_4px_30px_rgba(0,0,0,0.2)] pointer-events-auto overflow-hidden transition-all duration-300 flex-shrink-0 ${isMinimized ? 'h-[50px]' : 'h-[320px]'}`}
|
||||
>
|
||||
{/* Record / Orbit Tracker Header */}
|
||||
<div className="flex items-center gap-3 mb-6 border border-gray-800 bg-black/40 backdrop-blur-md px-4 py-2 rounded-sm relative shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto">
|
||||
<div className="absolute -top-1 -left-1 w-2 h-2 border-t border-l border-gray-500/50"></div>
|
||||
<div className="absolute -bottom-1 -right-1 w-2 h-2 border-b border-r border-gray-500/50"></div>
|
||||
<div className="flex items-center gap-3 mb-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]/40 backdrop-blur-md px-4 py-2 rounded-sm relative shadow-[0_4px_30px_rgba(0,0,0,0.2)] pointer-events-auto">
|
||||
<div className="absolute -top-1 -left-1 w-2 h-2 border-t border-l border-[var(--text-muted)]/50"></div>
|
||||
<div className="absolute -bottom-1 -right-1 w-2 h-2 border-b border-r border-[var(--text-muted)]/50"></div>
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
|
||||
<div className="text-[10px] font-mono text-gray-400 tracking-wider">
|
||||
<div className="text-[10px] font-mono text-[var(--text-secondary)] tracking-wider">
|
||||
REC {currentTime.date} {currentTime.time}
|
||||
<br />
|
||||
ORB: 47696 PASS: DESC-284
|
||||
@@ -41,15 +41,15 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
|
||||
</div>
|
||||
|
||||
{/* Right side controls box */}
|
||||
<div className="bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl pointer-events-auto border-r-2 border-r-cyan-900 flex flex-col relative overflow-hidden h-full">
|
||||
<div className="bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl pointer-events-auto border-r-2 border-r-cyan-900 flex flex-col relative overflow-hidden h-full">
|
||||
|
||||
{/* Header / Toggle */}
|
||||
<div
|
||||
className="flex justify-between items-center p-4 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
|
||||
className="flex justify-between items-center p-4 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<span className="text-[10px] text-gray-500 font-mono tracking-widest">DISPLAY CONFIG</span>
|
||||
<button className="text-gray-500 hover:text-white transition-colors">
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">DISPLAY CONFIG</span>
|
||||
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
@@ -66,14 +66,14 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
|
||||
|
||||
{/* Bloom Toggle */}
|
||||
<div
|
||||
className={`flex items-center justify-between group cursor-pointer border rounded px-4 py-3 transition-colors ${effects.bloom ? 'border-yellow-900/50 bg-yellow-950/10' : 'border-gray-800'}`}
|
||||
className={`flex items-center justify-between group cursor-pointer border rounded px-4 py-3 transition-colors ${effects.bloom ? 'border-yellow-900/50 bg-yellow-950/10' : 'border-[var(--border-primary)]'}`}
|
||||
onClick={() => setEffects({ ...effects, bloom: !effects.bloom })}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-[14px] ${effects.bloom ? 'text-yellow-500' : 'text-gray-600'}`}>✧</span>
|
||||
<span className={`text-xs font-mono tracking-widest ${effects.bloom ? 'text-white' : 'text-gray-500'}`}>BLOOM</span>
|
||||
<span className={`text-xs font-mono tracking-widest ${effects.bloom ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'}`}>BLOOM</span>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono tracking-wider text-gray-500">{effects.bloom ? 'ON' : 'OFF'}</span>
|
||||
<span className="text-[9px] font-mono tracking-wider text-[var(--text-muted)]">{effects.bloom ? 'ON' : 'OFF'}</span>
|
||||
</div>
|
||||
|
||||
{/* Sharpen Slider */}
|
||||
@@ -86,7 +86,7 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
|
||||
<span className="text-xs font-mono tracking-widest text-cyan-400 font-bold">SHARPEN</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 mt-1">
|
||||
<div className="h-0.5 bg-gray-800 flex-1 relative rounded-full">
|
||||
<div className="h-0.5 bg-[var(--border-primary)] flex-1 relative rounded-full">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[49%] bg-cyan-500 shadow-[0_0_10px_rgba(34,211,238,0.5)]"></div>
|
||||
<div className="absolute left-[49%] top-1/2 -translate-y-1/2 w-2 h-2 bg-white rounded-full"></div>
|
||||
</div>
|
||||
@@ -96,14 +96,14 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
|
||||
|
||||
{/* HUD Dropdown */}
|
||||
<div className="flex flex-col gap-2 relative">
|
||||
<div className="flex items-center gap-3 border border-gray-800 rounded px-4 py-3 text-gray-500 cursor-default">
|
||||
<div className="flex items-center gap-3 border border-[var(--border-primary)] rounded px-4 py-3 text-[var(--text-muted)] cursor-default">
|
||||
<span className="w-3 h-3 border border-gray-500 rounded-full flex items-center justify-center"></span>
|
||||
<span className="text-xs font-mono tracking-widest">HUD</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border border-gray-800 rounded px-4 py-2 mt-1 bg-black/50">
|
||||
<span className="text-[10px] text-gray-500 font-mono">LAYOUT</span>
|
||||
<span className="text-xs text-white tracking-widest border-b border-dashed border-gray-600 pb-0.5 cursor-pointer flex items-center gap-2">
|
||||
<div className="flex items-center justify-between border border-[var(--border-primary)] rounded px-4 py-2 mt-1 bg-[var(--bg-primary)]/50">
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono">LAYOUT</span>
|
||||
<span className="text-xs text-[var(--text-primary)] tracking-widest border-b border-dashed border-[var(--border-secondary)] pb-0.5 cursor-pointer flex items-center gap-2">
|
||||
Tactical
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||
|
||||
type Theme = "dark" | "light";
|
||||
|
||||
const ThemeContext = createContext<{ theme: Theme; toggleTheme: () => void }>({
|
||||
theme: "dark",
|
||||
toggleTheme: () => {},
|
||||
});
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>("dark");
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("sb-theme") as Theme | null;
|
||||
if (saved === "light" || saved === "dark") {
|
||||
setTheme(saved);
|
||||
document.documentElement.setAttribute("data-theme", saved);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const next = theme === "dark" ? "light" : "dark";
|
||||
setTheme(next);
|
||||
localStorage.setItem("sb-theme", next);
|
||||
document.documentElement.setAttribute("data-theme", next);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user